From 236da3c75ae8f576eb40e66a1b024c7cf09cf130 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 8 Jul 2026 22:46:34 -0500 Subject: [PATCH] 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 --- docs/dev-notes/linked-file-features.md | 43 ++++++++++++- .../bonsai/bim/module/drawing/operator.py | 64 ++++++++++++------- src/bonsai/bonsai/tool/project.py | 14 ++++ 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/docs/dev-notes/linked-file-features.md b/docs/dev-notes/linked-file-features.md index 906c900157..ce97c59b1d 100644 --- a/docs/dev-notes/linked-file-features.md +++ b/docs/dev-notes/linked-file-features.md @@ -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. diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 6cdb8bb80f..da2a95528f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -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"): diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 131729b3e2..40527bec5e 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -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.