Insert on a nested (reference) block definition creates a ghost object

Hi McNeel team. We have ran into a bug with linked blocks that sometimes causes kind of ghost objects to appear. I have used Claude and the Rhino MCP to get to the bottom of this and provide a reproducible example outside of our normal working files. So this is a bit of a co-authored bug report, but it is verified by me. -Jirri

Summary

When a linked block is inserted, Rhino adds the block definitions nested inside the
linked file to the document’s definition table as reference definitions
(InstanceDefinition.IsReference == True, IsTenuous == True). These definitions are
findable by name.

If you then run -Insert with the name of one of those nested definitions, Rhino
creates a top level, non-reference instance object bound to that reference
definition. The resulting object is broken in three ways:

  1. It cannot be deleted. _Delete, rs.DeleteObject() and
    RhinoDoc.Objects.Delete() all refuse it and report no error. Only
    RhinoDoc.Objects.Purge() removes it.
  2. It is silently discarded on save. Neither the object nor the definition is
    written to the 3dm. The part is simply gone when the file is reopened. No warning,
    no message, nothing in the command history.
  3. The document’s own bookkeeping is inconsistent about it.
    InstanceDefinition.InUse(0) returns False while
    InstanceDefinition.GetReferences(0) returns that same object.
  4. It doesn’t have a gumball and cannot be moved

We also have no way to recover this situation. A reference definition cannot be promoted in
place: InstanceDefinitionTable.ModifySourceArchive(...) returns True but changes
nothing, and -BlockManager Properties <name> UpdateType Embedded|Linked returns
False and refuses.

Environment

  • Rhino 8 SR35, 8.35.26251.13001, Windows 11 Pro 26200
  • Reproduced from a pristine, 25 KB file authored through Rhino.FileIO.File3dm
  • Reproduced using stock Rhino commands only. Our own plug-in was loaded in the test
    session; no part of the repro calls into it.

Files

  • parent.3dm — 25 KB. Contains one static block definition named Widget
    (a 100 mm cube) and one instance of it. Nothing else.

Reproduction

Start Rhino 8 with a new, empty model, then:

Step 1 — insert parent.3dm as a linked block

_-Insert _File=_Yes _LinkMode=_Link _LayerStyle=_Active "<path>\parent.3dm" _Block 0 _Enter 0

The document now has two definitions:

name UpdateType IsReference IsTenuous
parent Linked False False
Widget Static True True

Widget came along as a nested child of the linked file. It is a reference
definition. It is returned by InstanceDefinitionTable.Find("Widget") and by rs.IsBlock("Widget").

Step 2 — insert an instance of the nested definition by name

_-Insert "Widget" _Block 500,500,0 1 0 _Enter

A cube appears at 500,500,0. Rhino accepts this without any warning.

Observed state:

Widget.IsReference        True
Widget.InUse(0)           False     <-- claims no top level references
Widget.GetReferences(0)   1         <-- but returns one
object.IsReference        False     <-- a normal, user owned object

Step 3 — try to delete or move the new object

Select the cube at 500,500,0 and press Delete, or run _Delete. Or try to use Move.

Expected: the object is deleted.
Actual: nothing happens. The object stays. No error, no message.
RhinoDoc.Objects.Delete() returns False. Only RhinoDoc.Objects.Purge() works.

Step 4 — save and reopen

Save the model, then reopen it.

Expected: the block instance is still there, or Rhino warns that it cannot be
saved.
Actual: the object and the Widget definition are both absent from the written
file. Silent data loss.

verify_repro.py performs steps 1–4 and prints a pass/fail line for each.

Expected behaviour

Our prefence:

  1. -Insert promotes the reference definition to a real definition when a top
    level instance of it is created, which is the state you get when the same block is
    inserted from file before the linked parent is loaded. That state is legal and
    works correctly today, so the target is well defined.

The InUse(0) / GetReferences(0) disagreement looks like a separate bookkeeping bug
and may be the underlying cause: whatever -Insert uses to decide the insert is legal
appears not to be what the object table actually records.

How we hit this in production

We keep a library of several thousand parts as linked blocks, and we assemble models
from linked module files. A module file routinely contains the same library parts a
user will later place by hand.

So the order of operations decides whether a part is healthy or poisoned:

  • Insert library part first, then link the module that also uses it → correct.
    The definition stays IsReference=False and is shared by both. We have 38 such
    definitions in the affected model, all fine.
  • Link the module first, then insert the same library part → broken, as above.

Nothing in the UI distinguishes the two. In one production model we found 304
definitions primed for this, and one part that had already been placed and was
silently lost on every save. Tracking it down took a long time precisely because the
evidence destroys itself: isolate the block, save, and it is gone.

Scripts

Standalone, no dependencies on our codebase. Run with ScriptEditor
(Python 3): verify_repro.py: runs the four steps above and asserts each symptom.

verify_repro.py (3.8 KB)

parent.3dm (24.7 KB)

Hi @jirrivdb,

Thanks for the co-authored report — the scripted repro made this quick to check.

I ran your steps against Rhino 9 (9.0.26259.6303). The symptoms that caused actual harm are gone:

  • The object deletes normally — _Delete, rs.DeleteObject() and RhinoDoc.Objects.Delete() all work. No Purge() needed.
  • It moves normally and has a gumball.
  • It saves. Widget is written to the 3dm as a real static definition with its geometry, and the instance points at it correctly. No silent data loss.

That last one is effectively the outcome you asked for: the definition gets materialized into the file on save.

Two things are still true in 9, both cosmetic as far as I can tell:

  • -Insert still accepts a nested reference definition by name without warning, and the definition stays flagged IsReference / IsTenuous in the live document.
  • InUse(0) still disagrees with GetReferences(0). I went looking for a consequence and couldn’t find one — Purge does not delete the definition out from under a live instance.

Could you try your script on the Rhino 9 Beta and confirm? One heads-up: your script asserts the buggy behaviour, so “all PASS” means the bug reproduced. On 9 you should see FAIL on steps 3 and 4 — that’s the good result, just inverted from how it reads.

If you do still see the ghost objects on the Beta, let me know the build number and I’ll dig further.

Thanks,

– Dale

Hi @dale

Thanks for the quick response. I just installed the Beta for Rhino 9 and ran the same tests. Everything works fine there indeed. I also ran into a Youtrack that was previously made for what I think is the same topic. This does pose a pretty big challenge for us currently.

Do you know if this is something that will remain planned for Rhino 9 or can it be fixed in a Rhino 8 service release?

Hi @jirrivdb,

Thanks for testing the Rhino Beta,

We’ve made a number of Block-related fixes in the Rhino Beta. Most, if not all, of these issues pre-date Rhino 8. And together, these fixes are quite disruptive to core Rhino - so much that we won’t be back porting these to 8.x.

Thanks again,

– Dale

For anyone stumbling on this topic pre-Rhino 9, the best workaround we have found is to:
-Select the ghost block, run Copy, run Paste
-You now have a block that is top level and moveable
-Then run Save and Revert which clears the ghost block and keeps the newly pasted functional block

We have built a warning into our block library interface that will explain this method to our colleagues, this is workable for now.