Changelog
Follow new versions in a feed reader: RSS.
Added
- Activation codes. The licence form's code box also takes an activation code,
R2B-7QK4-M2XD-9PLA: the short stand-in for the 200-character key that fulfordstudio.com now gives its buyers. The two kinds are told apart by length (15 characters against a Superhive order code's 6). An activation code is exchanged for its key by the licence worker's/activate, a Superhive order code is redeemed as before, and either way the key is installed through the ordinary Activate path, so licence checks stay offline. Pasting a full key still works, and is the route for a machine without internet. - The NURBS tessellation cleanup is now a toggle in the sidebar panel, and flipping it applies to the scene you already have. Clean up NURBS tessellation — the pass that collapses the coplanar triangles in Rhino's render mesh of Breps, surfaces and extrusions into clean n-gons — existed only as an addon preference. It is the single largest cost of a Brep-heavy cold sync and the one geometry trade-off worth changing mid-session (clean topology to model on, versus a fast load for rendering), so it now sits under Import in the Rhino2Blender sidebar panel next to the connection it affects, with a line under it saying which way it currently leans.
Surfacing it uncovered that the preference only ever reached objects created after it changed. The dissolve runs while a mesh is built in Blender, and a resync answers with the same payloads carrying the same geometry hashes — which the sync correctly recognises as unchanged and skips. A checkbox that silently did nothing to a scene full of Breps is exactly what the 3.0.0 panel cleanup was about, so the toggle now clears the tracked hashes (
invalidate_mesh_hashes) before asking every connected Rhino to resend: each mesh is rebuilt with the new setting, from the Rhino-side cache, with no re-meshing in Rhino. With nothing connected there is nothing to ask and the log says so — the objects are marked, and the rebuild happens on the next sync or reconnect.Objects you have edited in Blender are not touched. Fingerprints are deliberately left in place, so the existing edit-conflict guard still fires and keeps the local edits, logging that the incoming geometry was skipped — the same behaviour as any other Rhino-side geometry change arriving on an edited object. Curves and annotations are not marked at all; they are not built through the mesh path and nothing about them depends on the option.
tests/verify_clean_tess.pygrows from 4 to 10 checks: that an unchanged resync is still recognised as unchanged (the short-circuit the toggle has to defeat is still doing its job), that flipping the toggle rebuilds an object already in the scene with the new topology, that it is symmetric both ways, and that a mesh edited in Blender survives the rebuild untouched.
Fixed
- The documentation still described seven panel controls that 3.0.0 removed. That release's Panel cleanup retired Clean quads, Shared source, the per-object Objects (n) filter and the Detail preset from the Rhino panel, and the Audit shading button, the in-panel Allow/Reject box and the Organize by preference from Blender — and DOCUMENTATION.md, FAQ.md and SUPERHIVE_LISTING.md went on documenting every one of them as a shipping feature, down to the Coarse/Medium/Fine density table and a QuadRemesh pass in the store listing. A reader following any of it went looking for a control that is not there. (The eighth, Quick sync, was never documented.)
All three are now reconciled against the panels as they actually build:
- The Rhino panel section is laid out by its real sections (Connection / Mesh / Filter / Log) and gains the buttons that were never documented — Full sync, Log file, Audit document… — alongside Max vertices / object. Detail is described as what it now is: not a control, but the document's own Document Properties > Mesh settings.
- Advice that named a removed lever now names a real one. "Lower Detail to Coarse, leave Clean quads off" (slow first sync, heavy models, Rhino feels laggy) becomes coarsening the document's mesh settings and turning the Blender-side Clean up NURBS tessellation off. "Turn on Shared source" (PC/Mac, two machines on one file) becomes pinning the same source name — which is what actually did the job even before the checkbox was retired, since
_effective_source_nameignored it outright. - The sync filter is layer-only, so the docs say so, and say which tool covers the gap: Selection only scopes by object, the filter scopes by layer.
- The preferences tables drop Organize by and gain Sync hidden Rhino layers and Clean up NURBS tessellation — both of which existed and neither of which was listed.
- Audit shading is documented as an
F3command rather than a button, in the three places that told the reader to press it. - The main-panel table documents the connection-request notice (the decision itself is made in the modal dialog) and the skipped-object notice with Retry skipped objects, neither of which appeared anywhere.
GEOMETRY_AUDIT.md's pull-path diagram no longer annotates_mesh_geometrywith "optional QuadRemesh".
- Activation codes. The licence form's code box also takes an activation code,
Fixed
- The Rhino plug-in now ships for both .NET runtimes, so it loads whichever one Rhino 8 is using. Rhino 8 for Windows runs on either .NET Framework 4.8 or .NET Core, chosen per install (the
SetDotNetRuntimecommand). The.rhpwas built only fornet7.0, and a .NET 7 assembly cannot load in the .NET Framework host — so on those machines the plug-in never loaded, theRhino2Blendercommand came back as unknown, and the panel could not be opened at all. Multi-targeting is McNeel's own recommendation for this.
The package now carries both builds side by side in the Yak multi-targeting layout —
manifest.ymlat the top withnet48/andnet7.0/folders, each holding that framework's.rhpand its own copy of the Python engine — and Rhino loads the one matching its runtime.Two .NET Core-only APIs needed .NET Framework stand-ins, both in the new
licensing/LicenseCore/Compat.cs, which compiles to nothing on .NET 7:ECDsa.ImportSubjectPublicKeyInfo→ the embedded key is P-256, whose SubjectPublicKeyInfo ends with the uncompressed point, so the net48 build reads the point off the end and imports explicitECParameters. Verified against a structural DER walk of the real key: same curve, same X and Y.System.Text.Json→ .NET Framework's own serializer, deliberately not the NuGet package, whose assemblies would sit next to the.rhpinside Rhino's .NET Framework process and could clash with Rhino's own copy, which a plug-in cannot fix with binding redirects. The net48 build ships no extra DLLs at all.
This cannot change whether any existing key validates: a signature is verified over the payload bytes exactly as received —
VerifyDataruns on those bytes and only then are they parsed — so how a payload is read is not part of the check. The one byte sequence both ends must agree on,StatusDoc.SigningBytes, is plain string concatenation and uses no JSON. Issuing and status-signing stay .NET 7-only, so the signed format still has exactly one implementation.- The Rhino plug-in now ships for both .NET runtimes, so it loads whichever one Rhino 8 is using. Rhino 8 for Windows runs on either .NET Framework 4.8 or .NET Core, chosen per install (the
Added
- A "Licence…" button on the Rhino panel, so a licence can be entered at any time rather than only when the gate rejects one.
The licence form was reachable only when
Evaluate()returned something other thanValid. A trial licence is valid — it is an ordinary signed key that happens to carry an object cap — so a customer who had just bought a full key sailed straight past the gate into the panel with nowhere to paste it. The Superhive Get my licence box was equally out of reach, which hit exactly the people who had just paid. The only ways through were to overwrite~/.rhino2blender.licenseby hand or to wait for the trial to expire.The button reopens the same form, with all three routes on it, plus a ← Back to panel button that returns an unchanged session for anyone who was only looking (the gate path has no session to return to, so it does not show one).
Structural note: the Python UI is now built into an inner container rather than into the dockable panel itself, so the licence bar around it survives every engine rebuild. The bootstrap only ever assigns
host.Content, so handing it that container changes nothing on the Python side, and no live control is ever re-parented. Licensing stays entirely in the compiled.rhp— the Python UI still never sees a key.Changed
- Every Rhino layer is now mirrored as a Blender collection, and collections are never removed for being empty. Blender mirrors Rhino's layer tree, not just the parts of it currently holding geometry. This is what render-context tools need — they drive visibility per collection, so the collection has to exist for every layer being synced, including layers switched off in Rhino and layers holding nothing.
The Rhino client used to narrow the layer table it sent, dropping layers with no synced content (
_filtered_layers), and Blender then pruned empty collections from three directions. Both halves are gone: Rhino sends every layer its filter admits, whatever its visibility or contents, and Blender keeps what it is sent.Removed on the Blender side:
_prune_empty_collections, which deleted any empty layer collection after a removal or a hidden-layer toggle._prune_collection_chain, which walked up from the collections a moved object had vacated, deleting the empty ones.
_prune_unlisted_layer_collectionsstays and is now the only prune: a collection is cleaned up when Rhino stops reporting its layer at all — the layer was deleted, or the user's layer filter no longer admits it — and never while it is non-empty or carries a material assigned through a material-by-collection addon.The user's layer filter still narrows what syncs. Only emptiness and visibility stopped being reasons to drop a layer.
Fixed
- A material assigned to a layer collection is no longer lost when the layer empties. Deleting the last object on a layer in Rhino removed the Blender collection, and with it any material assigned to that collection — the assignment lives on the collection datablock. The loss was silent and unrecoverable: the next sync recreated the collection bare, so geometry added to that layer afterwards arrived on the sync's own material instead of the assigned one. The same happened when a layer change vacated a collection.
Independently of the above, a collection carrying such an assignment is now never pruned, including
BY LAYERgroup nodes, even once Rhino has stopped reporting its layer.tests/verify_empty_layer_survives.py(17 checks), covering both the Rhino-side layer table and the Blender-side collection lifetime.
Changed
- Rhino's layer visibility is recorded even while a CameraList render context filters the layer, and a filter is never written over. Rhino is the live baseline and a context's collection filter sits on top of it. The baseline stamp (
rhino_vis) used to be refreshed only when rhino2blender actually applied Rhino's state, so a layer toggled in Rhino while a context filtered it was lost: every other context kept showing the old state. The stamp now records Rhino's state on every layer sync, applied or not.
A collection the active CameraList context filters is never written: putting Rhino's state on screen there would have been recorded into the context by CameraList's tracker, and the filter would have been gone. This is read straight from CameraList's scene data, so nothing changes without CameraList installed; a manual Outliner toggle is still respected as before.
- Rhino's layer visibility is recorded even while a CameraList render context filters the layer, and a filter is never written over. Rhino is the live baseline and a context's collection filter sits on top of it. The baseline stamp (
Changed
- A layer switched off in Rhino now goes invisible in Blender instead of leaving the view layer. Since 0.6.0 an off layer had its collection excluded from the view layer, which mirrors Rhino but is far more destructive than it looks: exclusion drops the objects out of
view_layer.objectsentirely. They disappear from the Outliner, cannot be selected, and stop receiving depsgraph updates — so nothing that works per view layer can reach them. Concretely, the collection cannot be made the active layer collection, which is how MaterialByCollection (and most material tooling) picks its target, so material work on an off layer was impossible and material overrides on one were never reapplied. Switching a layer off in Rhino cost you the materials on it.
Off layers now set
hide_viewportandhide_renderon the collection instead. Both cascade to sub-layers, so a switched-off parent still takes its children with it, and nothing renders — but the objects stay in the view layer with their materials intact and editable. This is the same reasoning that already governsBLOCK DEFINITIONS, which has been hidden rather than excluded since 2.2.9 for a closely related reason.Existing
.blendfiles carrying the old excluded state are migrated on the next sync that touches the layer. A collection you (or a CameraList render context) changed yourself is still left alone — the baseline stamp decides, as before.- Rhino layer visibility is a baseline, not a verdict — your own changes now stick. Layer sync re-asserted exclude /
hide_selectand clearedhide_viewport/hide_renderon every pass, so anything else managing collection visibility lost the argument. A CameraList render context was the sharp case: its per-context collection state was both overwritten by the next sync and silently recorded as though you had made the change.
Rhino layer state is still the baseline, but it stops re-asserting itself once a collection's visibility has changed since we last set it — the same "user edits win" rule already used for named-view cameras and
BLOCK DEFINITIONS. Each managed collection carries arhino_visstamp of what we last applied, which is also what lets a downstream tool tell a Rhino-driven change from one of yours. Visibility is left alone entirely while a scene carries thecameralist_renderingflag, since a change landing between two cameras of a batch would be captured by that batch's own restore.- A layer switched off in Rhino now goes invisible in Blender instead of leaving the view layer. Since 0.6.0 an off layer had its collection excluded from the view layer, which mirrors Rhino but is far more destructive than it looks: exclusion drops the objects out of
Major because the panels — the part of this plugin users actually touch — lose eight controls, and one scoping capability goes with them. The sync protocol, the .blend-side tracking properties and the settings file are all unchanged: an older settings file loads without error, and existing scenes are untouched on open.
Added
- Blender preference: "Sync hidden Rhino layers" (Preferences ▸ Add-ons ▸ Rhino2Blender ▸ Import). On by default, which is the existing behaviour — geometry on a Rhino layer that is switched off is imported, with its collection excluded from the view layer, so turning the layer back on in Rhino shows it immediately with no resync.
Turn it off to keep that geometry out of the .blend entirely, for files carrying construction layers, design alternates or survey data that will never be rendered but still cost memory and file size. The trade is the other direction: turning such a layer back on in Rhino then needs a sync to bring its objects in, because Blender no longer holds them.
Details worth knowing:
- Effective visibility, not the literal flag. Rhino reports a child layer's own visibility, so a visible sub-layer of a switched-off parent reads
visible: Truewhile being invisible in the viewport. It counts as hidden here, matching what you see. - Covers everything filed into a layer collection: meshes and Breps, curves, text and dimensions, and block instances (whose layer is read back from the collection they were filed into, since an instance empty carries no layer property of its own). Lights and clipping planes live in their own sub-collections rather than layer collections and are unaffected — each already has its own sync-type toggle on the Rhino side.
- Switching it off takes effect immediately, including with nothing connected: the objects already in the scene are removed and the emptied collections pruned. Switching it on asks every connected source to resend; with nothing connected, the objects arrive on the next sync, and the Sync Log says so rather than leaving the toggle looking inert.
- The filter is Blender-side, so Rhino still meshes and sends hidden-layer geometry. This keeps the wire protocol unchanged and works against any plugin version, at the cost of bandwidth and Rhino-side meshing that is then discarded. Filtering at the source would need a protocol addition and is a possible follow-up.
tests/verify_hidden_layers.py(30 checks) — the effective-hidden computation including sub-layer inheritance, both preference states across full and partial syncs, convergence when the preference is flipped, and that a partial sync carrying no layer table does not forget which layers are off.
Removed
Panel cleanup: eight controls retired. Both panels had accumulated options that either duplicated another control, silently did nothing in the cases that mattered, or were diagnostics that had outlived the bug they were built for. Nothing here removes a capability that wasn't already reachable another way, except where noted.
Rhino panel:
- "Clean quads (QuadRemesh, slow)". It was a no-op on multi-material Breps (which return before the mesher ever sees the flag), it silently returned the input mesh whenever
QuadRemeshthrew, and on textured objects it discarded the UVs — a direct conflict with the texture support added in 2.22.0. The Blender-side Clean up NURBS tessellation preference delivers the same "clean topology, not triangle fans" result with none of those holes. - "Quick sync".
quick_syncwas Full sync minus the re-index, and since the mesh cache landed, a Full sync of an unchanged document costs almost the same. Choosing correctly between them required knowing that Blender had lost state while Rhino hadn't — an internal invariant, not something the panel could show. Blender's own Refresh still uses that fast path over the wire; only the button is gone. - "Shared source (same file on several computers)". A pinned source name already did this and more —
_effective_source_namegave the pin priority and ignored the checkbox outright. To sync one file from several machines into one Blender source, pin the same name on each. - The "Objects (N)" filter. A frozen snapshot of a Rhino selection, sitting in a different section from the live Selection only toggle that does the same job. With both set they silently intersected with nothing on screen to say so. The sync filter is now layer-only, which leaves the two scoping tools orthogonal: Selection only for objects, Filter for layers. This does remove one capability — excluding a handful of individual objects from a layer that otherwise syncs.
- The "Detail" preset (Coarse / Medium / Fine). Render-mesh detail now follows the document's own settings (Document Properties > Mesh), so what Rhino shades is what Blender receives. The panel preset was a second, invisible density that could disagree with the viewport with nothing to explain why, and every change to it threw away the whole mesh cache. Changing the document's mesh settings now takes effect on the next Full sync, and only then re-meshes.
Blender panel:
- The "Audit shading" button. A diagnostic that logs internal shading state for the wrongly-smoothed-object bug, which is fixed. The operator stays registered and is still reachable from F3 > "Audit shading" when a report needs that log line; it no longer occupies standing panel space.
- The in-panel Allow/Reject box for incoming connections. Two complete UIs existed for one decision. The modal dialog is now the only one; the panel keeps a short notice that a request is outstanding, for when the dialog was dismissed before being answered.
- The "Organize by: Layers / Materials" preference. Material mode built a second collection taxonomy competing with the layer mirror Rhino itself defines, while Blender already groups by material through material slots and Select Linked. Imports are filed by layer, as they were by default. Existing scenes are untouched; nothing re-files on load.
Saved settings for the retired controls are ignored and dropped the next time the panel writes its settings file — no migration step, and no error on an older file.
Changed
tests/verify_organize.pynow covers layer filing (10 checks) instead of the retired material mode;verify_sync,verify_clientandverify_serializewere updated for the layer-only filter, per-machine source naming, and document-driven meshing.
Added
BLOCK DEFINITIONS ▸ BY LAYER: block geometry grouped by Rhino layer, across every definition. Geometry inside a block already kept its own Rhino layer, mirrored as sub-collections inside its definition — but that mirror is per definition, so a layer used by twenty blocks was twenty separate collections and a material-by-collection pass had to be repeated once per block, opening each definition in turn.
The new tree groups the same objects a second way: one collection per layer path, holding every block's geometry on that layer, from every definition and at any nesting depth. One assignment now covers the layer everywhere it appears.
Details worth knowing:
- The objects are linked into these collections, not copied. A material assigned there lands on the definition's own mesh — which is what every instance of that block renders — so all copies pick it up, and nothing is duplicated in the .blend.
- The tree sits under the hidden
BLOCK DEFINITIONSparent and inherits its hidden state, so it adds nothing to a render. An object linked into two collections still draws once. - Nested-block empties are excluded. Such an empty stands for a whole child definition whose members carry their own layers, and those members are already grouped by the child's own build; grouping the empty too would let one layer's material repaint the child's entire contents.
- Nodes are keyed by the
rhino_bd_bylayercustom property, which carries the unsuffixed full layer path — Blender de-duplicates collection names globally, so these often read asWood.001. Match on the property, not on the name. - Rebuilding a definition re-links its new geometry into the groups, and materials you assigned there survive the rebuild under the existing "your own materials win" rule. A layer no block uses any more has its node pruned.
- Scenes synced by an earlier version are grouped on the next sync by a one-shot backfill, without waiting for every block to change in Rhino.
Fixed
Blender no longer has to be force-quit repeatedly to get past bad geometry. Three separate gaps made a single freeze turn into a hunt.
- The skip list now survives restarts and accumulates. The in-flight marker only ever named the one object being built, and was consumed when read — so each force-quit retired exactly one object and forgot it at the next launch. A scene with three bad objects meant three force-quits with the first two coming back every time. Ids now land permanently in
~/.rhino2blender_quarantine, so each freeze costs one restart and the scene converges. - Every build path is guarded, not just one. The marker was only wired into the chunked full-sync path.
process_full_syncandprocess_partial_sync— every live edit — built objects with no protection at all, so a freeze while modelling left no trace and recurred identically. All three now share one guard. - Degenerate geometry is caught before it can hang. Size caps never addressed the real mechanism: a well-formed 140k-vertex Brep dissolves fine, while a near-singular one at the same size can still peg
dissolve_limit, because what thrashes the op is coincident vertices, not how many there are. A cheap screen now checks for non-finite coordinates and a high proportion of coincident vertices, and on a hit downgrades that object to Rhino's raw tessellation instead of dropping it — the geometry still arrives, only the n-gon cleanup is lost. The expensive part of the screen is gated above 20k vertices so it costs nothing on ordinary objects.
Added
- Flagged objects are reported back to Rhino. Blender finds the bad geometry, but the object that needs repairing lives in Rhino — so findings now travel back over the existing link and into
serialize.note_problem, behind the Rhino panel's existing "Select problem objects (N)" button. After a freeze, relaunch and resync, then click it to select the offending geometry in the document instead of bisecting the model by hand. - Blender panel: a "N object(s) skipped" notice with a Retry skipped objects button (
rhiblendsync.clear_quarantine), so the skip list is visible and clearable once the geometry has been repaired. Hidden entirely when the list is empty. tests/verify_quarantine.py(23 checks) — the skip list accumulating across simulated restarts, all three build paths guarding, the degeneracy screen's hits and misses, and that a degenerate object is downgraded rather than dropped.tests/verify_problem_report.py(9 checks) — the full Blender to Rhino round trip over a loopback socket, including that a malformed entry cannot break the connection.
Performance
Large cold syncs. Measured on a synthetic 20,000-object scene (196 verts each) with the new
tests/bench_ingest.pyharness: 74.4s → 38.3s effective, a 1.94x speedup, with no change to geometry handling. Ingest rate had been degrading with scene size (902 obj/s at 5k objects, 402 obj/s at 20k), which is why big scenes felt disproportionately worse than medium ones rather than merely longer.- The in-flight poison-object marker no longer opens, writes, closes, stats and unlinks a file for every single object. It holds one descriptor and rewrites in place, retiring the file when the sync drains. Same crash-recovery guarantee — a
pwriteis in the page cache before it returns, so a Blender segfault still leaves the id readable — at ~1/10th the syscall cost. Worth 26% of ingest time on its own. process_pendingnow uses a larger batch and time budget (1000 objects / 0.4s instead of 100 / 0.1s) while a bulk backlog exists, keeping the responsive steady-state caps for live edits. Tick count on the 20k scene fell from 492 to 106. The budget keys on actual backlog rather than the chunked-sync flag, because Rhino clears that flag when it finishes sending while Blender is usually still thousands of objects behind — draining that tail is the bulk case too, and often the larger half of the load.- The sync timer no longer sleeps 0.05s between batches during a load, which had been a third of total wall clock spent idle.
- Sharp-edge derivation no longer forces a BMesh round-trip on every mesh. Native Rhino meshes (and oversized Breps whose dissolve is skipped) now compute the
sharp_edgeattribute directly from Blender's own C-computed polygon normals, vectorised over the mesh buffers — 1.43x faster on that step, and it removes afrom_mesh/to_meshpair that profiling put at over half of all ingest time. NURBS-derived geometry still builds a BMesh for the coplanar dissolve and is marked in place there, which measured ~10% cheaper than writing the mesh and reading the buffers back, so neither path regressed. Sharp-edge output is bit-identical to the previous implementation on both paths (verified against mixed smooth/creased geometry), so_SHADING_Vis unchanged and no mesh re-derives.
bpy.ops.object.shade_smooth_by_anglewas measured and rejected: it produces identical results but is 21x slower than the Python loop it would replace, because per-object selection, context and depsgraph overhead dwarf the C computation in a bulk loop.End-to-end on the 20k-object benchmark: 74.4s → 25.5s (2.9x) for native meshes. Brep-heavy scenes get the scheduling wins but not the shading win — 36.5s with the coplanar dissolve on, 25.8s with it off (see the new preference below).
- The dissolve selection no longer builds a Python set of
BMVertto test boundary membership — the flag rides on each vertex's C-level.tag, and the classification shares a pass with the vertex list. Marginal on its own (~1.02x), kept because it is strictly cheaper and clearer.
Vectorising that selection over the mesh index buffers was tried and rejected on measurement: it is a consistent 0.67x — a third slower — at every size from 196 to 90,000 vertices, because
dissolve_limittakes BMesh objects and mapping the selection back throughbm.verts[i]costs as much as theis_boundarycall it replaced. Recorded in the function docstring so it is not retried.- The 3D viewport is redrawn at most twice a second while a cold sync streams in, instead of on every tick. Viewport redraw costs time proportional to what is already in the scene, so redrawing once per batch was quadratic in object count — the main reason a 100k-object load degrades far worse than 5x a 20k one. The sidebar (which carries the progress readout) still refreshes every tick.
Added
- Import preference: "Clean up NURBS tessellation" (on by default — existing behaviour). Turning it off skips the coplanar-dissolve pass on Breps, surfaces and extrusions, so they arrive with Rhino's raw triangulation instead of clean n-gons. On a Brep-heavy 20k-object scene that is 36.5s → 25.8s (1.42x), which is the largest single lever left for scenes loaded purely for rendering, where the extra edges are invisible.
tests/verify_clean_tess.py— pins that the toggle actually changes topology in both directions rather than silently doing nothing.tests/verify_dissolve_selection.py— pins the boundary-awareness contract of the dissolve selection (no boundary vertex or boundary-touching edge is ever dissolved, every interior one is, kept edges are manifold) across closed, open, holed, non-manifold and spherical topology. Dissolving a hole's supporting loop collapses it into the surrounding n-gon, and nothing was guarding that.tests/bench_ingest.py— headless ingest benchmark. Synthesises an N-object chunked sync and reports objects/sec, tick count and the share of wall clock lost to timer gaps, so big-scene throughput work can be measured instead of estimated.blender -b --factory-startup --python tests/bench_ingest.py -- 20000 200 1(third argument selects NURBS-derived geometry, which takes the dissolve path).
- The skip list now survives restarts and accumulates. The in-flight marker only ever named the one object being built, and was consumed when read — so each force-quit retired exactly one object and forgot it at the next launch. A scene with three bad objects meant three force-quits with the first two coming back every time. Ids now land permanently in
Fixed
- Image textures showed as one flat colour in Blender. Objects arrived with no texture coordinates, and the texture's mapping in Rhino was never sent. Textures now land where Rhino puts them:
- WCS and WCS box projections are rebuilt as shader nodes (a shared R2B Rhino Texture Projection group), so the placement is computed per pixel from world positions, in Rhino's model units, with Rhino's box orientation on every face.
- Mapping-channel (UV) textures get Rhino's own texture coordinates, one pair per face corner, including custom mappings set on the object. Objects that already synced without them are rebuilt on the next sync.
- Repeat, offset and rotation of each texture are applied as Rhino applies them.
- OCS frames move WCS textures with the object. Exact in Rhino's viewport for an object that hasn't moved since its OCS frame was set. After a move, Rhino 8.33's own viewport shifts the texture in a way Blender doesn't reproduce.
- Checked by rendering the same objects in Rhino and Blender: WCS box, OCS, a UV-mapped polysurface, a UV-mapped mesh and a per-face-material box matched on every compared pixel.
- Changing an object's material to one that needs different coordinates re-meshes it.
- Textured objects that need coordinates are meshed on the main thread (Rhino needs the document object for them), so they skip parallel meshing; about +0.35 s on an 80,000-vertex mesh.
- Per-face materials on polysurfaces never reached Blender. The per-face lookup called a method RhinoCommon doesn't have, the error was swallowed, and every face got the object's material. It now uses
GetMaterial(ComponentIndex, renderer).
Added
- Audit document button in the Rhino panel. Opens a table of what in the file will be heavy or broken in Blender, before you sync it. Works with or without a connection.
- Heaviest objects, layers and blocks. Blocks are ranked by instances × vertices, because a 3k-vertex table placed 56 times outweighs any single object, and that never showed up anywhere before.
- Predicts the sync outcome. Objects the current Max vertices cap would skip are errors; objects over the warning threshold are warnings. Counts are taken through the sync's own meshing code, so the prediction matches what a sync actually does.
- Problems: invalid geometry (with Rhino's reason), degenerate objects smaller than the model tolerance, stacked duplicates (identical geometry or block instances in the same place), objects a sync drops (e.g. SubD), unused and deeply nested blocks, and objects with no material.
- Click a row to select its objects in Rhino; zoom to them; filter by category; sort any column; export CSV.
- Fast by default, no meshing: it reads the mesh counts the sync already sent, or the meshes Rhino already built, and reports the rest as unmeasured. 0.4 s on a real 2,400-object project. Deep scan meshes everything with the sync's settings on worker threads (about 5 s on the same project; Esc cancels).
- Respects the sync filter and Selection only by default; Whole document ignores them.
Ships together with the 2.20.0 work below, which was prepared but never released.
Fixed
- One bad message no longer costs a whole sync tick. Incoming messages are popped off the queue before they are applied, so anything not handled in that pass is gone rather than retried. A single malformed entry — a light with no position, an object with no id — threw out of the dispatch loop and silently discarded every message left in the tick, for that Rhino source and for any other source that had not been reached yet, leaving Blender holding a partial scene with only a generic "Timer error" in the console. Each message is now handled independently, and so is each light, clipping plane and block instance inside one: the bad item is named in the log and skipped, everything else arrives.
- A slow Rhino connection no longer stalls the others. Sending to one source held the server-wide lock for the length of the send, and a stalled peer can take a full two seconds to time out — during which no other source could be accepted, cleaned up, approved, or have its messages delivered. The target is now chosen under the lock and the send happens outside it.
- Disconnecting a client can no longer land in the middle of a send. Teardown closed the socket without taking the lock that serialises sends, while that client's own receive thread could be replying to a heartbeat — the same race the Rhino side was hardened against after a field diagnosis, on the half that never got the fix.
- Filter and sync-type controls no longer act on the outgoing document when you switch files. Setting a checkbox or dropdown from code fires its handler exactly as a click does. Restoring a document's saved filters did that without the guard that suppresses the side effects, and it ran before the previous document was disconnected — so switching files pushed the new document's filter settings into the old document's live sync. The restore paths now hold the guard, the filter-mode handler carries it like its siblings, and the startup restore releases it in a
finallyso a failure part-way through can no longer leave every mesh, filter and sync-type control silently dead for the session. - Materials deleted or renamed in Rhino are now released in Blender. Materials were only ever added and updated, so each one left behind its datablock, node tree and any images it had loaded; the only cleanup ran when a source was deleted outright. A full sync now releases the ones nothing references any more. It is keyed on actual usage rather than on absence from the message, so materials that arrive split across several messages are safe — and a material you reassigned to your own object by hand is never touched.
- Temporary meshes are released on the failure path during "Send to Rhino". Six extraction paths released their temporary mesh only when everything went well, so anything that raised in between skipped the release — and the per-object error handler swallowed it, so nothing surfaced. Measured rather than assumed: this does not show up as growth in Blender's mesh list, so it is correct resource handling rather than a fix for a visible leak.
- Textures can no longer be written outside the cache directory. A texture arrives as a content hash plus a file extension, and both were concatenated straight into a path and opened for writing — so the sender chose the destination, and an absolute hash did not even need a "../" to escape. The name is now required to be a plain hex digest with a known image extension, which is the same rule the cache already applied when reading names back and never applied when creating them. Only reachable from a source you have approved, and the default is still local connections only.
- A corrupt mesh message no longer freezes Rhino or Blender. Face data arrives as a length-prefixed array of signed 32-bit integers, so a negative length prefix made the decoder's cursor walk backwards through the buffer: the loop never reached the end, and every pass appended another face, so it was a non-terminating loop and an unbounded allocation at the same time. It ran on the main thread of whichever side received the message — the addon while applying a sync, Rhino while applying a push-back — and because it never raised, no error handling anywhere could interrupt it. The application stopped responding and had to be force-quit. It needed no bad actor: a truncated frame or a version mismatch between the two halves produces exactly this shape.
The decoder now rejects a malformed buffer instead of looping on it, and also rejects the quieter variant of the same corruption — a length prefix that overruns the end of the buffer, which used to decode silently into a short face and hand Blender geometry that looked plausible and was wrong.
- A malformed push from Blender now costs one message instead of the connection. The push-back handler guarded the step that writes into the Rhino document but not the step that parses the message, so a short coordinate list or the corrupt face data above threw straight out into the Rhino panel's timer — the same timer that drives live sync, and the one the document watcher already deliberately shields itself from. Each message is now parsed inside the guard and logged by name if it fails; everything else in the batch still applies.
- Objects whose geometry did not arrive intact are skipped rather than half-built. Vertices and faces travel in two separate buffers and nothing compared them, so a truncated message could carry faces pointing past the end of the vertex list. Blender tolerates that and quietly repairs the mesh into something arbitrary, which meant the object appeared subtly wrong with nothing in the log to explain it. The addon now checks the two against each other and skips the object with a warning naming it.
Performance
- Bad geometry can no longer freeze Blender's shading pass. The smoothing pass builds a bmesh and walks the whole mesh before it checks whether the mesh is too big to smooth safely — so the guard could never protect against the cost of reaching it. And the Rhino-side limit that used to keep monsters away is now yours to set, with
0meaning no limit at all, so "the sender protects us" stopped being true. Blender now has its own ceiling, checked before any of that work starts. Above it the object still arrives in full, with its geometry intact — only the smoothing is skipped, and the log names the object. Measured on a dense coplanar mesh: 0.012s instead of 1.73s. - An object that crashes Blender can no longer crash it again on every reopen. If the add-on dies while building a particular object, that was previously unrecoverable: reopen, re-sync, same object, same death, with nothing to say which object was at fault. The object being built is now noted on disk before the work starts and the note removed after, so a note that outlives Blender identifies the culprit exactly. The next sync skips it once, names it, and completes — one lost object instead of an endless loop. Verified by killing Blender outright mid-build and confirming the next run recovered.
- Optional ceilings for Rhino's mesher, deliberately left switched off. Rhino's mesher exposes settings that look like they should bound how much geometry it can produce. They are now plumbed through and documented — and measurement against a live Rhino 8 is exactly why they default to off. Neither behaves as a ceiling: on a thin torus, a tighter limit produced 27 times more geometry than no limit at all, and the grid setting had no effect at small values while helping at one value and hurting at a larger one. Ordinary geometry was unaffected by every value tried, so these are safe to enable — just not blindly, since the harmful range is specific to the geometry and cannot be predicted from the number. The measurements are recorded in the source for whoever tunes them against a real problem file.
Worth saying why this is not a validity check either: the object behind the 1.4-million- vertex incident was near-singular, not invalid. It would have passed one.
- A rejected object now says what is wrong with it. "Render mesh has 1.4M vertices" told you something was broken without telling you what. Rhino is now asked for its own verdict on an object that has already failed the size limit — so the cost falls only on the rare bad object — and its answer is appended: naked edges, bad topology, whatever it found.
- Rhino stays responsive during Array, Import, paste and scripted edits. Rhino raises its document-change events from inside whatever command is running, and the plug-in was meshing each object right there in the handler — so a command that touched a thousand objects paid the full tessellation cost for all of them before it could return. Rhino simply stopped responding for the duration, with no progress bar and no way to interrupt, even though a full sync doing identical work has always shown a meter and honoured Esc.
Edits are now recorded as they happen and meshed on the next tick instead: the command finishes at its normal speed, the meshing shows progress once a batch is big enough to be worth reporting, and Esc gives the UI back — leaving the remaining edits queued rather than discarding them, so nothing is lost. A side effect worth having: the same object touched repeatedly between two ticks is now meshed once rather than once per event.
- Every synced object no longer gets a second, discarded fingerprint. Objects were fingerprinted twice: once from the tessellated mesh, and again from the source geometry — and the second one immediately replaced the first for every Brep, mesh, SubD and surface, which is to say for essentially everything in a real model. The first is now skipped when the better one is available.
- The Rhino panel no longer walks the layer table once a second. It enumerated every layer in the document on every tick to spot newly created ones, in every filter mode and even while disconnected, though only one mode can act on the answer. It now checks the mode first. Noticeable on layer-heavy architectural models.
- Block instances now use the same bulk extraction as everything else on "Send to Rhino". They were the last path still transforming vertices one at a time in Python, building faces and UVs the same way, and re-fetching the evaluated scene once per member — on collection instances, which is exactly the geometry a model has the most copies of. (Coordinates can differ in the sixth decimal, a micron, because this path now computes in double precision like every other one; the payload is otherwise identical.)
- Syncing a model with many block definitions is no longer quadratic. Linking each definition's collection rebuilt the list of already-linked collections from scratch, over a list that grew with every link. Building layer collections had the same shape: every layer new to the file triggered a scan of every collection in it, so a first sync cost layers × collections. Both now do one pass.
- Nested blocks can no longer be flattened for part of a sync and shared for the rest. Whether the addon supports nested block definitions was re-checked for every definition, and the answer arrives shortly after connecting — so on a slow link, or against a busy Blender, a single sync could flatten some definitions (duplicating their geometry into every parent) and share others, decided by when a packet happened to land. It is now settled once per sync. A sync that started before the answer arrived picks it up on the next one.
- Changing "Max vertices / object" now takes effect on reconnect. The cached mesh data reused across reconnects was keyed on mesh density and quad-remesh but not on the vertex cap, even though the cap decides which objects are skipped entirely — so raising or lowering it and reconnecting silently reused the previous result.
- Detach, Re-attach and Make block real are now undoable. None of the three registered an undo step, so Ctrl+Z did nothing after a detach. Make block real was the worse case: it stripped the Rhino link through the data API and then called Blender's own make-duplicates-real, which did record a step — so a single Ctrl+Z rolled back half the operation and left the instances stripped, reverted enough to look undone and not enough to be.
- Object, layer and material names are truncated by byte length. Blender's 63-character name cap is really 63 bytes, and an accented character costs two — so a French name well inside the limit by character count could still overrun it, at which point Blender truncates and de-duplicates on its own terms. That is how two distinct Rhino materials could end up sharing one Blender material.
- Geometry with NaN or infinite coordinates is skipped and reported instead of being sent. Neither used to raise anywhere: NaN never matches itself, so it slipped through vertex welding as its own vertex, and a single infinite coordinate poisoned the bounding box that every other vertex in the object is measured against.
- Curves have a size limit, like meshes. Every mesh path has had one since a degenerate surface meshed to 1.4 million vertices and hung a sync; curves had none, so a control-point count from a bad import or a failed fit was serialized at whatever size it happened to be. It uses the same Max vertices / object setting and reports the object by name.
- Very large textures are no longer embedded in the message. A texture is read whole into memory and then base64-encoded, about a third larger again, on the thread that talks to the document — so one oversized map stalled Rhino and inflated a single frame. Above 64 MB the file path is sent instead, which same-machine setups resolve anyway, and the object is reported.
- Reading a material's texture can no longer hang the push. The walk that follows shader links back to an image node had no depth limit and no memory of where it had been, so a node graph containing a cycle — which Blender's own UI will not let you draw, but scripted graphs and imported files are not bound by it — recursed until the interpreter gave out.
- The panel's settings file is written atomically. It was truncated before the new contents were written, so a crash mid-write left unparseable JSON — which loads as "no settings at all", silently resetting the host, the port, and every per-document filter and pinned name with nothing to say why. It is now written to a temporary file and renamed into place, and the per-document maps are capped so they stop growing for every file ever opened.
- Enabling the add-on twice no longer installs its handlers twice. Registration appended its scene handlers unconditionally while unregistration removed them one at a time, so a double registration left a surplus copy behind that kept firing on every scene change.
Changed
- Packaging fails loudly instead of shipping without the Rhino installer. The previous
.yakwas deleted before the build, and building it is skipped when the .NET SDK is absent — so on a machine without it you got a successful-looking build with the Rhino installer silently gone. The old file is now kept unless it can actually be replaced, a leftover from an earlier version is called out by name, and the script exits non-zero rather than letting a half-built release look finished. codec.pyis now mirrored and guarded like the rest of the shared wire code. It ships in both the Rhino plug-in and the Blender addon, but unlikeprotocol.pyandtransport.pynothing copied one to the other and no test compared them. The two copies happened to match, so nothing was broken — but a fix applied to one side would have silently left the other running the old code, and since both sides decode with it, the unfixed half would have kept freezing. Packaging now mirrors it and a test fails if the copies drift.- CI can now fail. The headless suites were scored by searching their output for a "N/N checks passed" line, with the exit status ignored and error output discarded — so a suite that printed its summary and then crashed was recorded as passing, and a green build meant less than a green local run. Suites are now judged on their exit code, keep their error output, and run under a per-test timeout so a hung test fails loudly instead of quietly consuming the whole job. The duplicate
test.ymlworkflow, a strict subset of this one, is removed, and the pure-Python suites it never ran are now included. - The panel's restore-guard check now catches what it was written to catch. It looked for the guard only in the handler's own body, so a handler that reached the live sync through a helper — which is exactly what the filter-mode handler does — passed while unguarded. It now follows the call chain, and separately requires that any code setting a control's value programmatically holds the guard while it does. Both were confirmed against the real defects: the check went red for each one before the fix, which the previous version of it did not.
Added
tests/verify_codec.py— covers the binary geometry codec: the two malformed-buffer shapes above, that well-formed topology is untouched, a full encode/decode round trip, and that the two copies of the file stay identical. The non-terminating cases run in a killable subprocess with a watchdog, so a regression fails in seconds rather than hanging the suite that is supposed to catch it.tests/verify_server.py— the Blender-side socket server had no test of its own, despite being one of the two places in the project where a bug means a hang rather than a wrong mesh. Seventeen checks drive the framing layer over a real loopback socket: a frame split across many packets, two frames arriving in one write, a truncated frame, an absurd declared length, zero-length and non-JSON payloads, invalid UTF-8, a payload that only looks compressed, and a peer that connects and vanishes. Every one asserts the reader comes back rather than blocking.- Regression cover for everything above, in the suites that already own each area: malformed lights and dropped batches in
verify_sync.py, texture-path escapes inverify_textures.py, temporary-mesh release inverify_push_extract.py, and malformed pushes inverify_pushback.py. Each was checked to actually fail without its fix, so the cover is real rather than decorative.
Added
- Free trial, self-serve, no waiting. Until now someone who hadn't bought could do nothing at all — the Rhino panel refused to load — so evaluating the plugin meant emailing for a hand-issued key. The licence form now offers a third route: enter an email address, press Start free trial, and a 25-object key arrives on the spot. Entering a Superhive order code later removes the cap.
Trial and purchased keys come from two separate pools in one table, and the worker claims strictly within a kind. That filter is load-bearing: without it a purchase could be handed a capped trial key. Both directions are covered — a purchase with no full keys left returns "temporarily unavailable" rather than a trial key, and neither tier can drain the other's pool.
Trial keys carry a one-year expiry rather than the 14 days a hand-issued trial gets: a pooled key is signed when minted but claimed whenever the prospect turns up, so a short-dated pool would expire before most of it was handed out. The 25-object cap does the limiting.
- Marketing consent is recorded, and is genuinely optional. The trial form carries an unticked checkbox saying what will be sent, stored with a timestamp so consent is provable. Ticking it is never a condition of getting the trial — an unticked box, a false value or an absent field all issue the key identically and record nothing — because a trial obtainable only by agreeing to marketing would make both the consent and any resulting mailing unlawful. Returning visitors can only ever upgrade to opted-in, since the box starts unticked on every fresh panel and treating that as withdrawal would silently discard consent already given.
licensing/redeem-worker/export-leads.mjsexports the list as CSV, defaulting to opted-in rows only, with--allfor support and conversion analysis.Added
- A dropped message now says so, and says what it cost. When the sender thread can't deliver a message it discards it — nothing retries. That was logged as a bland "sender error (message dropped)", which badly understated the consequence: the addon treats a full sync as authoritative and removes every object it didn't receive, so a dropped chunk doesn't just fail to add objects, it deletes objects that were already in the Blender scene. The warning now names the message type, says the objects are missing from Blender, gives a running count for the current connection, and tells the user to press Full sync once the cause is fixed.
- Objects that will bog Blender down are now named while they're still fixable. A render mesh over 150k vertices was already refused (it's the degenerate-geometry case that once hung a sync for 15 minutes), but everything below that arrived in silence — including objects heavy enough to make every viewport redraw and every save crawl. Anything over 50k vertices is now reported with its name and vertex count, and still synced: it's a warning about a probable meshing-density mistake, not a refusal. The messages also state plainly which way it went, since "your object is huge" is useless without knowing whether it made it across.
- A recap after each index, so warnings aren't lost in the scroll. Per-object warnings are logged as they happen, which is right for a long index but useless afterwards — in a 3000-object scene they're thousands of lines up. The index summary is now followed by a digest naming every object that needs attention (heavy mesh, skipped mesh, or serialize error), capped at ten entries so the recap stays readable. Objects skipped for ordinary reasons — filters, selection-only, disabled sync types — are not "problems" and stay out of it.
Fixed
- Sync no longer stops dead on a document with accented layer names. On Rhino installs where the plugin runs under the legacy IronPython 2 interpreter, a layer called something like
BétonorRéseauwedged the sync permanently: the scene stopped updating partway through, at an arbitrary object count, with the connection still up and Blender still responsive — so it looked like a hang rather than an error.
Every tick re-derives layer state and JSON-encodes it to decide whether anything changed. Python 2's JSON encoder, in its default ASCII-escaping mode, re-decodes any string it takes for a byte string — and under IronPython that test matches every string, because text there is a .NET
System.Stringwith no distinction betweenstrandunicode. Decoding an already-decoded name sends it through the ANSI code page, which throws on the first accented character. The tick died before reaching the send stage, and died the same way on every tick after it, so nothing further was ever queued.Turning off ASCII escaping avoids that path entirely; frames were already UTF-8 on the wire, so nothing about the protocol changes. The same trap was live in the frame encoder, which would have hit accented object and material names too.
Note this is not the older accented-name crash that
safe_text()fixed. That one produced malformed strings; this one starts from perfectly correct ones and is a defect in IronPython's own JSON module, which no amount of input sanitising could have prevented.Added
- The per-object vertex limit is now yours to set. The 150,000-vertex cap that skips an object exists for a real failure — a degenerate Brep once meshed to 1.4 million vertices and hung a sync for fifteen minutes — but the right number is a property of the model, not of the plug-in: a scan or a dense terrain is legitimately over it, and its author knows that. Mesh > Max vertices / object sets it, and
0removes it entirely. The warning threshold rides along, so lowering the cap doesn't produce warnings about objects that were never going to be sent anyway. The skip message now also says the limit is adjustable, since "this object was skipped" reads as a dead end otherwise. - "Select problem objects" — the recap, acted on rather than read. The end-of-index digest names the objects that need attention, but naming twenty objects in a 3000-object model still leaves the user to find them by hand, which nobody does. The new button next to Log file selects all of them in Rhino in one click, and its label carries the count, so the panel says whether the last sync had anything to complain about without a trip into the log. Objects that are hidden, locked, or already deleted can't be selected; they're counted and reported rather than quietly missing from the selection.
Fixed
- Opening a second file no longer inherits the first file's pinned source name. The pinned name was stored on the panel, so it followed whichever document was in front — open another file and it arrived already pinned to the previous file's name, meaning two documents advertised one name and fought over a single Blender source. It's now a property of the document: each file keeps its own pin, saved against that file, and the field re-targets itself when the active or connected document changes. The name a connection advertises comes from the document being connected rather than from whatever the field happens to be showing — connecting from another document's window is routine on Mac, where each window carries its own panel.
The "last used" pin still restores across Rhino restarts for the first document opened, which is what the pin exists for: live-sync and collaboration tools hand Rhino a fresh temp filename every session, so a per-file lookup can never find it. It just stops there now instead of spreading to every file opened afterwards.
- The per-object vertex limit is now yours to set. The 150,000-vertex cap that skips an object exists for a real failure — a degenerate Brep once meshed to 1.4 million vertices and hung a sync for fifteen minutes — but the right number is a property of the model, not of the plug-in: a scan or a dense terrain is legitimately over it, and its author knows that. Mesh > Max vertices / object sets it, and
Added
- Superhive buyers get their licence instantly, with no action from the author. The unlicensed panel now offers "Bought on Superhive? Enter your 6-character order code" alongside the existing paste-a-key form; the code is redeemed against a small Cloudflare Worker (
licensing/redeem-worker/) which confirms it via Superhive's/creator/salesAPI and returns a key.Activatehandles the rest, so a redeemed key lands in exactly the same state as a pasted one.
Order code, not order ID. A Superhive order carries two identifiers — a long numeric ID (
4152368) and a short alphanumeric code (ap8evg) — and only the code is matched, because the numeric ID is sequential and would let anyone count upward to claim a key for every order that ever bought the product. Since the ID is the more prominent of the two, pasting one gets a message naming the mistake and showing a real code's shape rather than a bare "not found". The panel,DOCUMENTATION.md,FAQ.mdand the store listing all state the distinction./redeemis throttled per IP (5/60s) ahead of parsing, so malformed input consumes budget and a throttled request costs no upstream call. The realistic threat isn't guessing a licence out of 36⁶ but exhausting the daily Workers quota — which would take redemption offline for real buyers — while each miss fires paged calls at Superhive.It works this way round because it has to: Superhive's API is entirely read-only — there is no purchase webhook — and a sale's
customer.emailis populated only when the buyer consented to creator email marketing (otherwise the literal"No consent provided"), withcustomer.idnull for guest checkouts. Nothing can notify us of a sale, and a buyer cannot be reliably emailed, so redemption is pull rather than push.The ECDSA signing key never goes online. The worker cannot sign; it only hands out keys pre-signed offline by the new
r2b-issuer mint-pool --count N. A compromised worker leaks the unassigned remainder — bounded, and every id revocable through the existing signedstatus.json— instead of the signing key, which would orphan every licence ever issued and force a.rhprebuild. Pool files and the SQL generated from them are gitignored.Redemption is idempotent per order: a reinstall returns the same key rather than consuming another, and key claiming is a single atomic
UPDATE ... RETURNINGguarded by aUNIQUEindex, so two concurrent redemptions can never be handed the same key. Refunds before redemption are rejected for free (onlystatus: completesales match); refunds after redemption are traced through the worker'sorder_guid→license_idtable and revoked as usual.- Trial licences with a 25-object cap.
r2b-issuer issue --name "X" --trialnow issues a capped key (25 objects, 14-day expiry;--max-objects Nand--expiresoverride both). The cap rides inside the signed payload asmax_objects, so raising it by hand breaks the signature — the .rhp reads it from the verified licence and injects it into the Python bootstrap before the panel (and therefore anyDocumentSync) exists, so no sync can run in the window before the limit lands.
Behaviour at the cap: the first 25 items sync normally and the rest are skipped, so a prospect sees the live link actually working. Everything that reaches Blender consumes a slot — meshes, curves, block instances and text alike; materials, layers, views and lights ride along with the geometry and are free. Items already synced are never re-blocked, so editing object #3 of a capped scene keeps it and a re-index (filter change, materials toggle) can't reshuffle which 25 survive; deleting an object genuinely frees its slot. The panel shows a trial banner, the index summary reports
N over trial limit, and live adds past the limit log a throttled notice instead of failing silently. Pre-meshing is budgeted to the cap so a trial never waits on meshing thousands of objects it will drop.Existing full licences are unaffected: they carry no
max_objectsfield, their signed bytes are byte-identical to the pre-trial format, and an absent field means unlimited. Note the field is additive, so a pre-trial .rhp build would run a trial key uncapped — the short default trial expiry is what bounds that downgrade path.- Superhive buyers get their licence instantly, with no action from the author. The unlicensed panel now offers "Bought on Superhive? Enter your 6-character order code" alongside the existing paste-a-key form; the code is redeemed against a small Cloudflare Worker (
Added
- Nested blocks now sync. A block placed inside another block's definition used to be dropped entirely (
serialize_block_definitioncould only mesh geometry, so anInstanceReferencemember was skipped with a log line) — the geometry only appeared once you exploded the parent. Both sides now handle nesting properly: - The addon rebuilds Rhino's own structure: a nested block becomes a collection-instance empty inside the parent's definition collection, so the child's mesh data is stored once no matter how many parents place it. Correct at any depth — the empty's matrix is
Translate(-bbox_parent) @ transform @ Translate(bbox_child), which lands the child in the parent's stored space so the top-level instance transform composes cleanly. - Against an addon that can't parse nested definitions, the Rhino client flattens instead: the child's geometry is baked into the parent with the nested transform applied. Renders identically, just without sharing. Chosen per connection from the
capabilitieslist the addon now advertises in itsversion_infohandshake reply. - Cyclic and runaway definitions are guarded (16 levels), and flattened leaves fold the accumulated transform into their hash so moving a nested block re-syncs its parent.
- Block contents follow their Rhino layers. Objects inside a block definition carry their own layer in Rhino; that layer now travels with each sub-object and the addon mirrors the
::path as sub-collections inside the definition collection. Block geometry is therefore reachable by collection exactly like ordinary geometry — which is what material-by-collection workflows key on — without lifting it out of the definition and breaking instancing. Mirror collections are taggedrhino_layerwith the full unsuffixed path (match on that, not.name: Blender de-duplicates collection names), and each object also carriesrhino_layer. - "Show block definitions" preference (Preferences → Import). Reveals the
BLOCK DEFINITIONScollection so block geometry can be selected and given materials. Off by default — the definitions are instancing sources, and showing them draws each block once more at the origin. - "Make block real" operator (Rhino2Blender panel → Object link). Replaces the selected block instances with real, editable meshes, detached from the sync. The escape hatch for when one instance genuinely needs its own material — materials assigned inside a definition necessarily apply to every instance of that block.
Changed
- The sync no longer re-hides the
BLOCK DEFINITIONScollection on every pass. It was re-assertinghide_viewport/hide_renderon each call, so unhiding the definitions to assign materials was undone by the very next sync. The flags are now set only when the collection is created; after that they belong to the user (or the new preference). - Block definitions are refilled in place on rebuild instead of being removed and recreated. The old path nulled every
instance_collectionpointing at the collection, which nested blocks would have broken outright.
Fixed
- Blender-side materials on block definitions survived nothing. Any material assigned to block geometry in Blender was destroyed the moment the block's geometry changed in Rhino, because the rebuild deleted the objects and their mesh datablocks. User-assigned materials are now carried across rebuilds, using the same rule ordinary objects already had (
_reassign_material): a slot still holding what the sync put there is not a user choice; anything else is, and keeps winning on every later rebuild. - License gate (revocable remotely). The Rhino plugin now requires a signed license key: the compiled
.rhpverifies an ECDSA P-256 signature + expiry before ever bootstrapping the Python engine, and checks a revocation list (r2b-license-status/status.jsonon GitHub) once per session on a background thread. Unlicensed/expired/revoked installs show a "paste your license key" panel instead of the UI. Fail closed: a revoked id blocks at the next check; 14 days without a successful online check blocks until connectivity returns. User-side files:~/.rhino2blender.license,~/.rhino2blender.liccache. - The revocation
status.jsonis itself signed, so the plugin trusts no host: an unsigned or wrongly-signed document (a redirected/spoofed status URL) is rejected, and a validly-signed but older document is rejected by a monotonic-timestamp check so a revocation can't be undone by replay. Only the issuer tool can produce a validstatus.json. - Author-side issuer tooling (
licensing/— sharedLicenseCore.cscompiled into both the.rhpand the CLI so signer/verifier can never drift):keygen,issue,revoke/unrevoke(which auto-commit + push the signedstatus.jsonto the status repo at~/r2b-license-status/$R2B_STATUS_REPO, so revoking is one step),publish,list, plus a double-click macOS applet (Issue License.app, built fromIssueLicense.applescript). Key material and ledger live in~/.r2b-issuer/, never in the repo. Runbook inlicensing/README.md. Test matrix:dotnet run --project licensing/Tests(25 crypto/format/signed-status checks) plus a 27-check out-of-Rhino gate harness against the built.rhp(activation, tampering, expiry, signed-revocation fetch, spoof/replay rejection, offline grace). - The Blender addon is unaffected (no license needed on the Blender side).
- Nested blocks now sync. A block placed inside another block's definition used to be dropped entirely (
Fixed
- Converting a synced object into a block left a duplicate behind. Running Rhino's
Blockcommand on an object that was already in Blender correctly removed the original and added the instance — and then drew the same geometry a second time as a stray mesh. Rhino folds the selected geometry into the new definition but leaves it in the document's object table as definition geometry, andAddRhinoObjectfires for it.doc.Objectsiteration skips definition members, so the full-sync index never saw them and a reconnect looked clean; only the live event path was affected, which is why this showed up on conversion and vanished after a resync. Definition members are now recognised (RhinoObject.IsInstanceDefinitionGeometry, with anAttributesfallback) and skipped by the add/replace/delete handlers — they reach the scene through their instances' definition collection, which is the only place they belong. - Edits made inside a block now propagate. Changing geometry within a definition (BlockEdit, or adding/removing members) arrives as events on definition members, which carry no link back to the definitions containing them — so those edits previously only appeared after a full resync. The definitions actually instanced in the scene are now re-read when a member changes; hash comparison means unchanged definitions cost nothing on the wire.
- Converting a synced object into a block left a duplicate behind. Running Rhino's
Fixed
- Synced objects could still show up wrongly smoothed (a box shading as a soft gradient blob) until the user manually applied Shade Flat — the long-standing recurrence of the stale-shading symptom 2.8.x had already diagnosed. Investigation with real captured wire payloads replayed through
build_clean_meshon Blender 5.1 proved the mesh DATA is always correct (a Rhino box → 6 n-gons, all 12 edges sharp, corner normals ≡ face normals), so the remaining mechanisms were fixed: - The depsgraph tag fired at the wrong moment.
build_clean_mesh'smesh.update_tag()(the 2.8.x fix) runs while the mesh datablock is still an orphan — not yet linked to any object or depsgraph — where the tag cannot reach the viewport's draw-batch extraction._create_new,_update_geomand the block sub-object builder now additionally callobj.update_tag(refresh={'DATA'})after the object is linked / the datablock assigned. - Meshes from older addon eras never healed. The hash-unchanged fast path never rebuilds a restored mesh, so objects saved in a .blend under a pre-sharp-edge (or broken) version kept bad shading forever, across addon updates.
_derive_shadingnow stamps every mesh it touches (r2b_shading_v, persisted in the .blend), and.blend-reload restore re-derives shading once for any tracked mesh missing the stamp — smooth flags and sharp edges only, geometry untouched (no coplanar dissolve on heal), idempotent. - New "Audit shading" panel button (diagnostic): logs the active mesh's shading state — loaded addon version, smooth/sharp counts, stamp, max evaluated corner-vs-face normal deviation — then forces a depsgraph re-tag without touching data. If the visual snaps to correct, the bug was draw-batch invalidation; if the data reads wrong, the log names the state. Either way the next report is decisive instead of restarting the guessing game.
- New regression coverage in
tests/verify_shading.py(29 checks): shading stamp written on build, legacy heal re-derives sharp edges + corner normals, stamped meshes (incl. deliberate user flat-shading) are never touched. - Opening a brand-new, never-saved Rhino document ("File > New") could inherit the previous session's pinned source name and object/layer filter instead of starting clean. Root cause: the panel restored the "Pin source name" field and its filter state from a single flat "last used" value in the settings file, with no check for which document (if any) that value actually belonged to — so a fresh Untitled document picked up whatever an unrelated, previously-configured file had left behind. Now the pinned name and its filter/sync-type state are only restored for a document that has a real, on-disk path (including the ever-changing temp filename a live-sync tool hands Rhino each session, which is what the pinned-name field exists for) — a genuinely new, unsaved document always opens with no pinned name, no filter, all sync types on.
- Synced objects could still show up wrongly smoothed (a box shading as a soft gradient blob) until the user manually applied Shade Flat — the long-standing recurrence of the stale-shading symptom 2.8.x had already diagnosed. Investigation with real captured wire payloads replayed through
Added
- Cross-machine incremental sync: syncing the same
.3dmfrom the PC and the Mac no longer re-imports the whole document. The reconnect handshake compares per-object geometry hashes, but those hashes fingerprinted the render-mesh tessellation — which Windows and Mac Rhino compute with platform-specific float results — so every NURBS object failed the comparison on the other machine and the sync degraded to a full resend (transfer + full Blender-side rebuild). Objects and block definitions now carry a platform-stable source-geometry hash (serialize.stable_geometry_hash) built from the data stored in the file — control points, knots, weights, topology counts — quantized to 1e-6 model units so last-bit conversion noise vanishes: - Breps hash every face's underlying-surface NURBS form plus every 3D edge curve (surface CVs alone would miss trimming edits); extrusions hash their Brep form; surfaces their NURBS form; SubDs their control net plus per-edge crease tags (a crease edit changes tessellation without moving a control point); native meshes their stored vertices/faces.
- Control points are recentred on their own bbox, so a pure move keeps the hash and the transform-only fast path still applies. Block-definition sub-objects hash absolute coordinates instead (a sub-object moved within its definition has no per-sub transform, so it must change the definition hash).
- The hash is salted with the mesh-detail settings (density / quad remesh), so changing the preset still resends geometry tessellated at the old detail.
- Types without a stable extraction keep the tessellation hash — worst case a cross-machine resend, never a stale scene. The Blender addon treats hashes as opaque, so no addon-side change or
.blendmigration is needed (the first reconnect after this update re-sends once, then cross-machine reconnects retain). - New regression coverage in
tests/verify_stable_hash.py(11 checks): determinism, translation invariance, sub-quantum noise immunity, edit/salt/topology sensitivity.
Fixed
- Release tooling now runs on the PC too:
bump_version.sh(BSD vs GNUsed -i),package_all.sh(falls back to pythonzipfilewhenzipis missing; skips the broken Windowspython3App-Store stub),package_rhp.sh(findsYak.exein the Windows Rhino 8 install),run_tests.sh(same interpreter probing).
- Cross-machine incremental sync: syncing the same
Fixed
- A single, trivially simple object could stall a sync for tens of seconds — field- diagnosed live via the 2.15.4 breadcrumb log (
~/.rhino2blender_blender.logshowed the sync stuck immediately after logging a plain, low-vertex-count object with no further progress). Root cause:_move_to_layer()— called for every hash-changed object during_smart_updateand for every item intransform_updatesduringprocess_partial_sync— unconditionally called_prune_empty_collections()whenever an object's layer differed from its last-known one. That function does awhile changed: for col in bpy.data.collections: ...full rescan of every collection in the .blend file, re-run from scratch on every removal (cascading parent chains re-triggered the whole scan again) — a cost that scales with total scene collection count (layers × sources + material/block-definition collections, easily into the thousands on a large multi-source project), completely independent of the moved object's own geometric complexity. Once a reconnect's hash comparison flags most objects as "changed" (see 2.15.2's caveat: a session-fresh mesh cache can't distinguish real edits from re-tessellation noise) and a chunk of those also happen to have a stale/reformatted layer path, this fired on a large fraction of a multi-thousand-object sync. Replaced with_prune_collection_chain(), which walks up ONLY from the specific collection(s) the object just vacated — bounded by hierarchy depth, not total scene size. New regression coverage intests/verify_collections.py(object moving layers: vacated chain pruned bottom-up, cascade stops correctly at a non-empty ancestor and at the source root).
- A single, trivially simple object could stall a sync for tens of seconds — field- diagnosed live via the 2.15.4 breadcrumb log (
Fixed
- The freeze persisted even filtering the sync down to a single Rhino layer — the real root cause, found via a 3-way parallel audit (Rhino-side indexing, Blender-side scene building, network/threading layer) after the previous three fixes (2.15.2–2.15.5) turned out to be necessary but not sufficient. The common thread: several hot-path operations scanned the entire document or the entire
.blendfile's collections, uncached, on every sync — cost proportional to total project size, not to how much was actually being synced, so narrowing the filter never helped. - Rhino side (
rhino_client/rhino2blender/sync.py):_obj_allowed()resolveddoc.Layers[...].FullPath+ ran it throughsafe_text's encode/decode round-trip fresh for every object visited during indexing (30,000+ in a large doc) — before the layer filter can even be evaluated, since the filter needs that resolved path. With ~120 actual layers, that's ~250x redundant recomputation of the same handful of strings, unaffected by how narrow the filter is. Now cached perLayerIndex(_layer_full_path, invalidated on_on_layer_table) — resolved ~120 times instead of 30,000+. - Blender side (
Rhino2Blender/scene.py): three collection-pruning helpers —_prune_empty_collections,_prune_unlisted_layer_collections, and_parent_of(the primitive the 2.15.5_move_to_layerfix itself calls once per hierarchy level walked) — each did afor col in bpy.data.collections: ...scan of every collection in the whole file, across every source, on every sync regardless of how few objects were actually touched._parent_ofis now an O(1) lookup backed by an incrementally- maintainedSourceCollectionManager._parentindex (updated in_reparent, the only place a tracked collection's parent actually changes), with the old scan kept only as a one-time-per-collection backfill fallback. The two prune passes now walk a newSourceCollectionManager.walk_subtree()— this source's own collection tree via.children, seeded from its root — instead of the whole.blendfile. - Network/threading layer (
server.py/client.py) audited and confirmed clean — not a contributing factor. - New regression coverage in
tests/verify_collections.py: a two-source scoping test assertingwalk_subtree()never yields another source's collections, and that a prune pass in one source leaves a same-named layer collection in another source untouched.
- The freeze persisted even filtering the sync down to a single Rhino layer — the real root cause, found via a 3-way parallel audit (Rhino-side indexing, Blender-side scene building, network/threading layer) after the previous three fixes (2.15.2–2.15.5) turned out to be necessary but not sufficient. The common thread: several hot-path operations scanned the entire document or the entire
Fixed
- The actual root cause of the ~92%-into-a-full-sync freeze — found via a live process profile of the frozen Blender, not further guesswork.
sample'd the stuck main thread mid-hang: 100% CPU, continuously insidebmo_dissolve_limit_exec→BM_mesh_decimate_dissolve_ex→BM_faces_join/BM_face_create_ngon— Blender's own bmesh coplanar-dissolve operator (_derive_shading'sdissolve_limitcall), not anything in the addon's Python logic (2.15.2–2.15.6's fixes were all real, independent bugs, but none of them were this). Traced to the source with the plugin's own real serialization code run directly against the live document: a handful of objects (Breps with a pathological surface parameter-space aspect ratio) made Rhino's adaptive meshing produce render meshes of 500,000 to 1.4 million vertices for a single object — three to four orders of magnitude beyond anything a legitimate architectural element needs — anddissolve_limiton a mesh that large ran for 15+ minutes with zero progress and no way for Python to interrupt it once started, hanging the entire sync behind that one object. - Rhino side (
serialize.py): new_MAX_MESH_VERTScap (150,000, generous headroom over any legitimate case) checked right after meshing, inmesh_geometry_payload(the single-material path, both serial and parallel-worker callers),_serialize_brep_multimaterial, andserialize_block_definition's per-sub-object loop. An oversized object is now skipped — like an unmeshable one already was — with a specific, actionable log line naming the object and its vertex count, instead of silently hanging every future sync of the whole document. - Blender side (
scene.py, defense in depth):_derive_shadingmirrors the same cap (_MAX_DISSOLVE_VERTS) and skips the dissolve step (keeping the raw tessellation, logged) if an oversized mesh ever reaches it anyway — an older Rhino-side plugin version without the cap, or any future path into this function. - New regression coverage:
tests/verify_client.py(_mesh_size_ok/_describe_for_log, pure-Python, no Rhino needed) andtests/verify_shading.py(dissolve is skipped above the cap and resumes correctly once back under it).
- The actual root cause of the ~92%-into-a-full-sync freeze — found via a live process profile of the frozen Blender, not further guesswork.
Fixed
- "Select Layers" dialog (the layer-filter panel) always opened fully expanded, regardless of how the user actually keeps Rhino's own Layers panel — for a project with many top-level layers this was an unusable wall of rows every time. Root cause: the dialog hardcoded every root layer with children to start expanded, ignoring
Layer.IsExpanded— the same per-layer flag Rhino's native Layers panel persists in the document. Now seeded from that flag across every layer (not just roots, so a nested parent's own expand state is respected too), so the dialog opens matching whatever the user already sees in Rhino's Layers panel — collapsed by default on a project where it's kept collapsed there.
- "Select Layers" dialog (the layer-filter panel) always opened fully expanded, regardless of how the user actually keeps Rhino's own Layers panel — for a project with many top-level layers this was an unusable wall of rows every time. Root cause: the dialog hardcoded every root layer with children to start expanded, ignoring
Fixed
- "Select Layers" dialog's expand/collapse toggle looked like a boxed button next to every row instead of a plain disclosure chevron, unlike Rhino's own Layers panel. Root cause: the toggle was an Eto
Button, which renders with its platform's button chrome (a rounded-rect box on macOS). Replaced with a borderlessLabel+MouseDownhandler (with a pointer cursor for the same clickable affordance) — same ▶/▼ glyphs, no box, matching the native panel's look.
- "Select Layers" dialog's expand/collapse toggle looked like a boxed button next to every row instead of a plain disclosure chevron, unlike Rhino's own Layers panel. Root cause: the toggle was an Eto
Fixed
- Reconnecting to a large scene resent nearly all geometry instead of just what changed, overloading Blender (field-diagnosed: a ~2000-object scene logged "server has 2260 known object hashes" but sent 1854 objects on reconnect, retaining only 1). Root cause:
panel.do_connect()constructs a brand-newDocumentSyncon every connect and reconnect, andDocumentSync.__init__always started that instance's_mesh_cacheempty — so_index_document()re-tessellated every object in the document from scratch on every reconnect, regardless of whether anything actually changed in Rhino. The freshly re-meshed geometry then got a freshly computedgeometry_hash(), compared against Blender's rememberedknown_object_hashesfrom the previous connection — and because Rhino's meshing isn't guaranteed to reproduce byte-identical vertex/face buffers across two independent tessellation passes of the same geometry, that comparison mismatched for nearly every object, turningincremental_sync_messages()'s reconnect path into a near-full resend. The panel now keeps the mesh-geometry cache from a previous connection to the same document (sc.sticky[STICKY_MESH_CACHE], keyed byRuntimeSerialNumber, invalidated when render-mesh density/QuadRemesh settings change) and seeds the newDocumentSyncwith it via a newmesh_cacheconstructor argument, so unchanged objects are recognized without re-meshing or re-hashing them. Caveat (documented inpanel._reusable_mesh_cache): an edit made to an already-cached object while fully disconnected won't be picked up until that object is touched again or the user presses Full Sync — recoverable, versus the previous guaranteed full re-mesh of the entire scene on every single reconnect.
- Reconnecting to a large scene resent nearly all geometry instead of just what changed, overloading Blender (field-diagnosed: a ~2000-object scene logged "server has 2260 known object hashes" but sent 1854 objects on reconnect, retaining only 1). Root cause:
Fixed
- A large sync could still freeze Blender even into a brand-new file (i.e. not specific to the reconnect-hash issue fixed in 2.15.2 — this reproduced on a first- time full sync too). Root cause:
process_pending()drained a fixedOBJECTS_PER_TICK = 100objects perbpy.app.timerscallback, which assumes objects cost about the same to process. NURBS-derived geometry doesn't:_derive_shading()'s coplanar- tessellation cleanup runs abmesh.ops.dissolve_limitpass per object, a genuine, geometry-complexity-dependent cost. A batch of 100 heavy Breps could hold Blender's main thread for several seconds per tick — the timer re-fires quickly (every 0.05s while there's work), but that doesn't help if a single tick itself runs long, sincebpy.app.timerscallbacks run synchronously on the main thread.process_pending()now also yields once a 0.1s wall-clock budget is exceeded within a tick (whichever bound — object count or time — hits first), so a batch of expensive objects can no longer stall the UI for seconds at a stretch; it just takes more (still-responsive) ticks to finish. SeeOBJECTS_PER_TICK/_TICK_TIME_BUDGET_Sinscene.py.
Added
- Hang-diagnosis breadcrumb during Rhino-side indexing. If a specific object's meshing call itself hangs or takes a very long time inside RhinoCommon (a pathological NURBS surface, degenerate Brep, etc.), the previous behaviour gave no clue which object it was stuck on — the log simply went silent for the whole sync.
_index_documentnow logs"indexing N/M: <name> (<id8>)"right before each object's serialize/mesh call is attempted, throttled to at most once every 2s (_HANG_CHECKPOINT_S) so a normal, fast sync isn't spammed. Written through the existing panel log, which is also mirrored to~/.rhino2blender.logon disk — so if Rhino never comes back, the last line in that file names the object closest to where indexing stalled.
- A large sync could still freeze Blender even into a brand-new file (i.e. not specific to the reconnect-hash issue fixed in 2.15.2 — this reproduced on a first- time full sync too). Root cause:
Added
- Hang-diagnosis breadcrumb + durable log file on the Blender side, mirroring the Rhino-side plugin's equivalents added in 2.15.3.
process_pending()now logs"processing N/M: <name> (<id8>)"right before each object's_smart_update()call, throttled to at most once every 2s (_HANG_CHECKPOINT_Sinscene.py) — if a specific object's build/mesh-cleanup work hangs or runs very long, the last breadcrumb before the log goes silent names it (the per-tick time budget added in 2.15.3 only bounds time between objects, not a single object's own processing time, so it can't by itself pinpoint a stall inside one object). Field-diagnosed need: a report of Blender freezing partway through a 2112-object reconnect payload (stuck at 1960/2112) with no way to tell which object it stalled on. Also addedserver.py's_write_log_file, a durable on-disk copy of every log line at~/.rhino2blender_blender.log(rotates at 1MB, mirrors the Rhino side'srlog.py) — previously the ONLY record of a Blender- side sync was the System Console (not even visible on a normally-launched macOS Blender) plus an in-memory 200-line ring buffer, both gone the instant Blender hangs or is force-quit.
- Hang-diagnosis breadcrumb + durable log file on the Blender side, mirroring the Rhino-side plugin's equivalents added in 2.15.3.