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.