Bonsai: draw moved and multi-linked models at their displayed locations

create_drawing opened linked IFCs raw, so a moved link serialized at
its original coordinates and its elements fell outside the drawing.
The stored link transformation is the model-space delta, so it is now
baked into the linework iterator via the model-offset/model-rotation
settings (Trans @ Rot composition matches the rigid matrix
decomposition; the plan-view Z offset adds onto the translation).

The serialization loop also collapsed same-file links into a dict
keyed by filepath, dropping all but the last link. It now iterates one
entry per link and intersects each link's drawing elements with its
selector query, so drawings show what each link displays in the
viewport. Adds tool.Project.get_link_transformation_matrix as the
shared accessor for the stored 4x4.

Verified headless: window link moved +5m appears offset by exactly
5m x scale; unmoved door link at its native position; both present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-08 22:46:34 -05:00
parent cf58c675db
commit 236da3c75a
3 changed files with 96 additions and 25 deletions
+41 -2
View File
@@ -165,6 +165,31 @@ stay compatible.
root empty first (filepath alone is ambiguous with several links per file). The
element's IFC placement syncs to the moved location on save — intended.
### Drawings (`create_drawing`) — moved links and per-link queries
- The linework serializer opened linked IFCs raw, so a moved link's elements were
drawn at their *original* coordinates (usually outside the drawing extents —
"linked objects disappear from prints after moving the link").
- The stored link transformation is already the **model-space** delta (that is how
`save_link_transformation` derives it), which is exactly the space the serializer
works in — so it can be baked straight into the geometry iterator via the existing
`model-offset`/`model-rotation` settings. The mapping composes
`Trans(model-offset) @ Rot(model-rotation)` (see `mapping.cpp`), matching the
`Trans(t) @ Rot(R)` decomposition of the rigid link matrix; `model-rotation` is a
quaternion passed as `(x, y, z, w)`. The pre-existing 2mm plan-view Z-offset simply
adds onto the translation (translations commute).
- The serialization loop previously collected files in a dict keyed by filepath, which
**collapsed same-file links into one pass** (one transform — the last link's — and
no query awareness): with two links of one file, only one showed in the drawing.
It now iterates one entry per link (`(path, file, transform, query)` tuples), and
intersects each link's drawing elements with
`ifcopenshell.util.selector.filter_elements(ifc, link.query)` so the drawing shows
what that link actually displays in the viewport.
- `tool.Project.get_link_transformation_matrix(link)` is the shared accessor for the
stored 4×4 (None when identity/absent).
- Verified headless with the window/door kit: moved window offset in the SVG by
exactly 5m × scale; unmoved door at its native position; both links present.
## Review round 1 (PR #8242, falken10vdl) — decisions
- **Path-form mismatch → duplicate documents (confirmed bug, fixed).**
@@ -202,8 +227,13 @@ Six commits on `Linked_File_Features`:
append placement (`tool/project.py`, `project/operator.py`, `project/decorator.py`).
- `c14592ec0a` per-query caches, Description persistence, SKIP_SAVE.
Plus the review-round path normalization in `get_linked_models_documents` /
`LinkIfc` (see Review round 1), committed together with this note update.
Plus:
- `ee43ed5526` review-round path normalization in `get_linked_models_documents` /
`LinkIfc` (see Review round 1).
- Drawing support for moved links and per-link queries in `create_drawing`
(`drawing/operator.py`, `tool/project.py`) — committed together with this note
update.
End-to-end verified with a two-links-one-file kit (window/door, distinct queries):
correct visuals on load, after save → reopen → reload, in both headless and windowed
@@ -230,3 +260,12 @@ Blender.
bakes the first element's slice — same as normal import, but worth a look with types.
- `bim.select_link_filepath` round trip when the reload dialog was opened for a
non-active link, and dialog-state carry-over after editing the query *then* browsing.
- **Drawing SVG guid cache vs moved links**: `create_drawing` skips elements whose
guids already exist in the drawing's SVG (`cached_linework`, invalidated only for
*edited host objects*). Moving a link does not invalidate its elements, so a
regenerated drawing keeps their old positions until the SVG is deleted. Candidate
fix: subtract a moved link's guids from `cached_linework` (compare stored transform
against the one recorded at last generation).
- Same element appearing in two links of one file (overlapping queries) serializes
twice with different transforms; the SVG guid cache keeps whichever came first on
regeneration. Degenerate case — probably fine to ignore, but note it.
@@ -602,7 +602,7 @@ class CreateDrawing(bpy.types.Operator):
context_type: Literal["body", "annotation"],
drawing_elements: set[ifcopenshell.entity_instance],
target_view: str,
link_matrix: Optional[Matrix] = None,
link_transform: Optional[np.ndarray] = None,
) -> None:
drawing_elements = drawing_elements.copy()
contexts_: list[list[int]] = getattr(contexts, context_type)
@@ -614,19 +614,22 @@ class CreateDrawing(bpy.types.Operator):
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
if link_matrix is not None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
t = link_matrix.to_translation()
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
geom_settings.set("model-offset", offset)
q = link_matrix.to_quaternion()
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
elif z_offset:
offset = np.zeros(3)
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
# A 2mm Z offset to combat Z-fighting in plan or RCPs
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
offset[2] = 0.002 if target_view == "PLAN_VIEW" else -0.002
if link_transform is not None:
# Bake a moved link's transformation into the geometry. The
# mapping composes Trans(model-offset) @ Rot(model-rotation),
# matching the Trans(t) @ Rot(R) decomposition of the rigid
# link matrix, so the Z offset above simply adds on.
offset += link_transform[:3, 3]
quaternion = Matrix(link_transform.tolist()).to_quaternion()
geom_settings.set(
"model-rotation", (quaternion.x, quaternion.y, quaternion.z, quaternion.w)
)
if offset.any():
geom_settings.set("model-offset", tuple(float(o) for o in offset))
geom_settings.set("context-ids", context)
it = ifcopenshell.geom.iterator(
@@ -934,16 +937,24 @@ class CreateDrawing(bpy.types.Operator):
bim_props = tool.Blender.get_bim_props()
prefs = tool.Blender.get_addon_preferences()
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
props = tool.Project.get_project_props()
# One entry per file *and* per link - the same file can be linked
# several times with different queries and transformations, so links
# cannot be collapsed into a dict keyed by filepath.
# Each entry is (path, file, link transformation or None, link query).
file_entries: list[tuple[str, ifcopenshell.file, Optional[np.ndarray], str]] = [
(bim_props.ifc_file, tool.Ifc.get(), None, "")
]
for link in props.get_loaded_links_for_drawings():
try:
link_matrix = tool.Project.calculate_link_matrix(link)
except Exception:
link_matrix = None
files[link.filepath] = (self.get_linked_file(link), link_matrix)
file_entries.append(
(
link.filepath,
self.get_linked_file(link),
tool.Project.get_link_transformation_matrix(link),
link.query,
)
)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -957,7 +968,7 @@ class CreateDrawing(bpy.types.Operator):
raycast_objs = set()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) in files.items():
for ifc_path, ifc, link_transform, link_query in file_entries:
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
@@ -965,6 +976,9 @@ class CreateDrawing(bpy.types.Operator):
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if link_query:
# Draw only what the link's selector query loaded in the viewport.
drawing_elements &= ifcopenshell.util.selector.filter_elements(ifc, link_query)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
@@ -980,8 +994,12 @@ class CreateDrawing(bpy.types.Operator):
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "body", drawing_elements, target_view, link_transform
)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_transform
)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
+14
View File
@@ -133,6 +133,20 @@ class Project(bonsai.core.tool.Project):
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
return Matrix(np.linalg.inv(local_matrix) @ global_matrix)
@classmethod
def get_link_transformation_matrix(cls, link: Link) -> Union[npt.NDArray[np.float64], None]:
"""Get the link's saved 4x4 transformation in model coordinates, or None when identity."""
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if not transformation:
return None
matrix = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if np.allclose(matrix, np.eye(4)):
return None
return matrix
@classmethod
def calculate_link_delta_matrix(cls, link: Link) -> Matrix:
"""Get the matrix mapping the link's unmoved world positions to its moved ones.