From 8f9164bf7237cf71ed52dfba9ec403c018a70fd0 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 10 Jul 2026 15:26:33 -0500 Subject: [PATCH] Bonsai: include/exclude filter pair for linked models A single selector query cannot express set differences (the grammar only unions groups, and the parent facet cannot negate), so links now carry an Exclude query beside the include, mirroring the drawing Include/Exclude pattern: final set = include (or the default set when empty) minus exclude. Applied in LoadLinkedProject and per link in create_drawing so prints match the viewport. The cache key hashes both strings when an exclude exists - keying on the query alone would let same-include/different-exclude links serve each other's geometry. Include-only filters keep the pre-exclude hash and empty filters the legacy names, so existing caches stay valid. Persistence in IfcDocumentReference.Description stays backwards compatible: a plain include is stored as-is, an exclude promotes the value to a small JSON blob, and non-JSON decodes as a legacy include. The Exclude field appears in Link IFC and the Reload Link dialog (carried through the file browser round trip, SKIP_SAVE like the rest). Co-Authored-By: Claude Fable 5 --- docs/dev-notes/linked-file-features.md | 26 +++++++ .../bonsai/bim/module/drawing/operator.py | 13 ++-- .../bonsai/bim/module/project/operator.py | 77 ++++++++++++++----- src/bonsai/bonsai/bim/module/project/prop.py | 6 ++ src/bonsai/bonsai/tool/project.py | 55 ++++++++++--- 5 files changed, 143 insertions(+), 34 deletions(-) diff --git a/docs/dev-notes/linked-file-features.md b/docs/dev-notes/linked-file-features.md index 0af9c5d9b9..7253e1783d 100644 --- a/docs/dev-notes/linked-file-features.md +++ b/docs/dev-notes/linked-file-features.md @@ -105,6 +105,32 @@ no `Description` — custom queries are not restorable there (accepted). clearing tolerates a missing blend (a reload with a brand-new query points at a not-yet-existing filename). +### Include/Exclude filter pair + +The selector grammar's only cross-group combiner is `+` (union) and the `parent` +facet cannot express "not under X" (its `!=`/regex paths also match by GlobalId, so +negation removes everything with any parent), which makes set differences like +"group members minus the slabs under aggregate X" structurally inexpressible in one +query string. Links therefore carry an **Exclude** query beside the include — +mirroring `EPset_Drawing`'s Include/Exclude pattern: final set = include (or the +default set when empty) − exclude, applied in `LoadLinkedProject` and per link in +`create_drawing`. + +- **Cache key**: `get_link_cache_paths` hashes `md5(query + "\0" + exclude)` when an + exclude exists; include-only filters keep the pre-exclude `md5(query)` so existing + caches stay valid; empty filter keeps legacy un-suffixed names. Keying on query + alone would let same-include/different-exclude links silently serve each other's + geometry. +- **Persistence**: `encode_link_filter`/`decode_link_filter` — a plain include is + stored in `Description` as-is (backwards compatible); an exclude promotes the + value to `{"include": …, "exclude": …}` JSON. Decode treats non-JSON as a legacy + include string. +- Exclude applies on top of the **default** element set too, so + "everything except X" needs no explicit include. +- Verified headless: `query=""`/`exclude="IfcDoor"` loads only the window; + same file with a different filter gets its own cache; both filters survive + save → reopen → reload. + ### External styles + layerset slicing in the linked loader `LoadLinkedProject.get_external_material(style_id)` resolves a style id → appended diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 3fbb752d73..675ddaf95a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -946,9 +946,9 @@ class CreateDrawing(bpy.types.Operator): # 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, "") + # Each entry is (path, file, link transformation or None, link query, link exclude). + file_entries: list[tuple[str, ifcopenshell.file, Optional[np.ndarray], str, str]] = [ + (bim_props.ifc_file, tool.Ifc.get(), None, "", "") ] for link in props.get_loaded_links_for_drawings(): file_entries.append( @@ -957,6 +957,7 @@ class CreateDrawing(bpy.types.Operator): self.get_linked_file(link), tool.Project.get_link_transformation_matrix(link), link.query, + link.exclude, ) ) @@ -972,7 +973,7 @@ class CreateDrawing(bpy.types.Operator): raycast_objs = set() elements_with_faces = set() - for ifc_path, ifc, link_transform, link_query in file_entries: + for ifc_path, ifc, link_transform, link_query, link_exclude 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() @@ -981,8 +982,10 @@ 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. + # Draw only what the link's selector filter loaded in the viewport. drawing_elements &= ifcopenshell.util.selector.filter_elements(ifc, link_query) + if link_exclude: + drawing_elements -= ifcopenshell.util.selector.filter_elements(ifc, link_exclude) if self.cprops.fill_mode == "SHAPELY": for element in drawing_elements.copy(): diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 2bbf537d29..41277a6487 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1366,6 +1366,14 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): "Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement." ), ) + exclude: bpy.props.StringProperty( + name="Exclude", + description=( + "Selector query whose matches are excluded from the loaded elements.\n\n" + "Applied on top of the query (or the default set), providing the set " + "difference a single query cannot express. E.g. 'IfcSlab, parent=\"X\"'." + ), + ) filename_ext = ".ifc" @@ -1377,6 +1385,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): use_relative_path: bool use_cache: bool query: str + exclude: str def draw(self, context): assert self.layout @@ -1395,6 +1404,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): row = self.layout.row() row.prop(pprops, "project_north") self.layout.prop(self, "query", placeholder="IfcElement") + self.layout.prop(self, "exclude", placeholder='IfcSlab, parent="..."') def _execute(self, context): start = time.time() @@ -1427,14 +1437,16 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document) reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()]) reference.Location = filepath.replace("\\", "/") - # Persist the query per reference (Description is IFC4+ only). - if self.query and hasattr(reference, "Description"): - reference.Description = self.query + # Persist the filter per reference (Description is IFC4+ only). + description = tool.Project.encode_link_filter(self.query, self.exclude) + if description and hasattr(reference, "Description"): + reference.Description = description new.ifc_definition_id = reference.id() new.name = filepath new.filepath = filepath new.query = self.query - bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query) + new.exclude = self.exclude + bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query, exclude=self.exclude) class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator): @@ -1499,18 +1511,22 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): link_index: bpy.props.IntProperty(name="Link Index") use_cache: bpy.props.BoolProperty(name="Use Cache", default=True, options={"SKIP_SAVE"}) query: bpy.props.StringProperty(options={"SKIP_SAVE"}) + exclude: bpy.props.StringProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: link_index: int use_cache: bool query: str + exclude: str def _execute(self, context): self.link = tool.Project.get_project_props().links[self.link_index] - # Fall back to the Link's stored query so callers that omit it + # Fall back to the Link's stored filter so callers that omit it # still replay the filter the link was created with. if not self.query and self.link.query: self.query = self.link.query + if not self.exclude and self.link.exclude: + self.exclude = self.link.exclude filepath = Path(tool.Ifc.resolve_uri(self.link.filepath)) if not filepath.exists(): self.report({"ERROR"}, f"File does not exist: '{filepath}'") @@ -1547,7 +1563,7 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): self.link.is_loaded = False def link_ifc(self) -> Union[set[str], None]: - blend_filepath, json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query) + blend_filepath, json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude) def should_clear_cache() -> bool: if not self.use_cache: @@ -1559,8 +1575,7 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): data = json.loads(json_filepath.read_text()) # Empty 'query' - model loaded without custom query. # Missing 'query' - model was loaded before custom queries were introduced in Bonsai. - query = data.get("query", "") - return query != self.query + return data.get("query", "") != self.query or data.get("exclude", "") != self.exclude if should_clear_cache() and blend_filepath.exists(): os.remove(blend_filepath) @@ -1590,7 +1605,7 @@ def run(): pprops.project_north = "{pprops.project_north}" # Use absolute path to be safe from cwd changes. try: - bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)}) + bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)}, exclude={repr(self.exclude)}) except RuntimeError as e: # Operator failed (returned CANCELLED with error report) print(f"Failed to load linked project: {{e}}") @@ -1639,7 +1654,7 @@ except Exception as e: if len(tool.Project.get_project_props().links) > 1: return # Only the first link sets the origin - json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query)[1] + json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)[1] if not json_filepath.exists(): return @@ -1658,7 +1673,7 @@ except Exception as e: if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")): self.link.georeferenced = "NONE" return - json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query)[1] + json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)[1] if not json_filepath.exists(): self.link.georeferenced = "NONE" return @@ -1705,6 +1720,15 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): ), options={"SKIP_SAVE"}, ) + exclude: bpy.props.StringProperty( + name="Exclude", + description=( + "Selector query whose matches are excluded from the loaded elements.\n\n" + "Applied on top of the query (or the default set), providing the set " + "difference a single query cannot express. E.g. 'IfcSlab, parent=\"X\"'." + ), + options={"SKIP_SAVE"}, + ) if TYPE_CHECKING: link_index: int @@ -1712,6 +1736,7 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): use_relative_path: bool use_cache: bool query: str + exclude: str def invoke(self, context, event): link = tool.Project.get_project_props().links[self.link_index] @@ -1723,6 +1748,8 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): self.use_relative_path = not Path(link.filepath).is_absolute() if not self.properties.is_property_set("query"): self.query = link.query + if not self.properties.is_property_set("exclude"): + self.exclude = link.exclude return context.window_manager.invoke_props_dialog(self) def draw(self, context): @@ -1736,6 +1763,7 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): op.use_relative_path = self.use_relative_path op.use_cache = self.use_cache op.query = self.query + op.exclude = self.exclude row = self.layout.row() row.prop(self, "use_relative_path") row = self.layout.row() @@ -1750,6 +1778,7 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): row = self.layout.row() row.prop(pprops, "project_north") self.layout.prop(self, "query", placeholder="IfcElement") + self.layout.prop(self, "exclude", placeholder='IfcSlab, parent="..."') def _execute(self, context): link = tool.Project.get_project_props().links[self.link_index] @@ -1758,6 +1787,8 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): # of overwriting them with the defaults. if self.properties.is_property_set("query"): link.query = self.query + if self.properties.is_property_set("exclude"): + link.exclude = self.exclude filepath = self.filepath if self.properties.is_property_set("filepath") else link.filepath if self.properties.is_property_set("use_relative_path"): @@ -1781,12 +1812,12 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): if tool.Ifc.get() and link.ifc_definition_id: reference = tool.Ifc.get().by_id(link.ifc_definition_id) if hasattr(reference, "Description"): - reference.Description = link.query or None + reference.Description = tool.Project.encode_link_filter(link.query, link.exclude) bpy.ops.bim.unload_link(link_index=self.link_index) - return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=self.use_cache, query=link.query) or { - "FINISHED" - } + return bpy.ops.bim.load_link( + link_index=self.link_index, use_cache=self.use_cache, query=link.query, exclude=link.exclude + ) or {"FINISHED"} class SelectLinkFilepath(bpy.types.Operator): @@ -1802,6 +1833,7 @@ class SelectLinkFilepath(bpy.types.Operator): use_relative_path: bpy.props.BoolProperty(options={"HIDDEN"}) use_cache: bpy.props.BoolProperty(options={"HIDDEN"}) query: bpy.props.StringProperty(options={"HIDDEN"}) + exclude: bpy.props.StringProperty(options={"HIDDEN"}) if TYPE_CHECKING: link_index: int @@ -1810,6 +1842,7 @@ class SelectLinkFilepath(bpy.types.Operator): use_relative_path: bool use_cache: bool query: str + exclude: str def invoke(self, context, event): link = tool.Project.get_project_props().links[self.link_index] @@ -1825,6 +1858,7 @@ class SelectLinkFilepath(bpy.types.Operator): use_relative_path=self.use_relative_path, use_cache=self.use_cache, query=self.query, + exclude=self.exclude, ) return {"FINISHED"} @@ -1844,7 +1878,7 @@ class ToggleLinkSelectability(bpy.types.Operator): props = tool.Project.get_project_props() link = props.links[self.link_index] self.library_filepath = tool.Blender.ensure_blender_path_is_abs( - tool.Project.get_link_cache_paths(link.filepath, link.query)[0] + tool.Project.get_link_cache_paths(link.filepath, link.query, link.exclude)[0] ) link.is_selectable = (is_selectable := not link.is_selectable) for collection in self.get_linked_collections(): @@ -1881,7 +1915,7 @@ class ToggleLinkVisibility(bpy.types.Operator): props = tool.Project.get_project_props() link = props.links[self.link_index] self.library_filepath = tool.Blender.ensure_blender_path_is_abs( - tool.Project.get_link_cache_paths(link.filepath, link.query)[0] + tool.Project.get_link_cache_paths(link.filepath, link.query, link.exclude)[0] ) if self.mode == "WIREFRAME": self.toggle_wireframe(link) @@ -2208,9 +2242,12 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): query: bpy.props.StringProperty() """See ``bim.link_ifc``.""" + exclude: bpy.props.StringProperty() + """See ``bim.link_ifc``.""" if TYPE_CHECKING: query: str + exclude: str file: ifcopenshell.file meshes: dict[str, bpy.types.Mesh] @@ -2278,6 +2315,9 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): else: self.elements |= set(self.file.by_type("IfcSpatialElement")) self.elements -= set(self.file.by_type("IfcFeatureElement")) + if self.exclude: + # The set difference a single selector query cannot express. + self.elements -= ifcopenshell.util.selector.filter_elements(self.file, self.exclude) if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin: tool.Loader.set_manual_blender_offset(self.file) @@ -2285,7 +2325,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): tool.Loader.guess_false_origin(self.file) tool.Georeference.set_model_origin() - self.json_filepath = str(tool.Project.get_link_cache_paths(self.filepath, self.query)[1]) + self.json_filepath = str(tool.Project.get_link_cache_paths(self.filepath, self.query, self.exclude)[1]) data = { "model_is_georeferenced": gprops.model_is_georeferenced, "model_crs": gprops.model_crs, @@ -2303,6 +2343,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): "false_origin": pprops.false_origin, "project_north": pprops.project_north, "query": self.query, + "exclude": self.exclude, } with open(self.json_filepath, "w") as f: json.dump(data, f) diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 00a6d70d29..e39db72730 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -265,6 +265,11 @@ class Link(PropertyGroup): description="Selector query used to filter elements when loading the linked model", default="", ) + exclude: StringProperty( + name="Exclude", + description="Selector query whose matches are excluded when loading the linked model", + default="", + ) if TYPE_CHECKING: name: str @@ -281,6 +286,7 @@ class Link(PropertyGroup): empty_handle: Union[bpy.types.Object, None] ifc_definition_id: int query: str + exclude: str class EditedObj(PropertyGroup): diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 40527bec5e..296ae328af 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -91,24 +91,56 @@ class Project(bonsai.core.tool.Project): link.empty_handle = empty @classmethod - def get_link_cache_paths(cls, filepath: Union[Path, str], query: str) -> tuple[Path, Path]: - """Get the (blend, json) cache paths for a linked model loaded with ``query``. + def get_link_cache_paths(cls, filepath: Union[Path, str], query: str, exclude: str = "") -> tuple[Path, Path]: + """Get the (blend, json) cache paths for a linked model's filter. - Cache files are per-query so the same IFC file can be linked several - times with different queries without the caches overwriting each - other. An empty query keeps the legacy un-suffixed names. + Cache files are per-filter so the same IFC file can be linked several + times with different include/exclude queries without the caches + overwriting each other. An empty filter keeps the legacy un-suffixed + names, and an include-only filter keeps the pre-exclude hash so + existing caches stay valid. """ filepath = Path(filepath) - suffix = "" if not query else "." + hashlib.md5(query.encode("utf-8")).hexdigest()[:8] + if not query and not exclude: + suffix = "" + elif not exclude: + suffix = "." + hashlib.md5(query.encode("utf-8")).hexdigest()[:8] + else: + suffix = "." + hashlib.md5(f"{query}\0{exclude}".encode("utf-8")).hexdigest()[:8] return ( filepath.with_suffix(f".ifc.cache{suffix}.blend"), filepath.with_suffix(f".ifc.cache{suffix}.json"), ) + @classmethod + def encode_link_filter(cls, query: str, exclude: str) -> Union[str, None]: + """Serialize a link's filter for IfcDocumentReference.Description. + + A plain include query is stored as-is (backwards compatible); an + exclude promotes the value to a small JSON blob. + """ + if exclude: + return json.dumps({"include": query, "exclude": exclude}) + return query or None + + @classmethod + def decode_link_filter(cls, description: Union[str, None]) -> tuple[str, str]: + """Get (query, exclude) from a reference Description written by encode_link_filter.""" + if not description: + return "", "" + if description.startswith("{"): + try: + data = json.loads(description) + if isinstance(data, dict): + return data.get("include", "") or "", data.get("exclude", "") or "" + except json.JSONDecodeError: + pass + return description, "" + @classmethod def calculate_link_matrix(cls, link: Link) -> Matrix: filepath = Path(tool.Ifc.resolve_uri(link.filepath)) - with open(cls.get_link_cache_paths(filepath, link.query)[1], "r") as f: + with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f: metadata = json.load(f) rot = ifcopenshell.util.shape_builder.np_rotation_matrix( @@ -181,7 +213,7 @@ class Project(bonsai.core.tool.Project): new_obj_matrix = np.array(obj.matrix_world) filepath = Path(tool.Ifc.resolve_uri(link.filepath)) - with open(cls.get_link_cache_paths(filepath, link.query)[1], "r") as f: + with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f: metadata = json.load(f) rot = ifcopenshell.util.shape_builder.np_rotation_matrix( @@ -441,11 +473,11 @@ class Project(bonsai.core.tool.Project): if reference[1]: m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4) link.has_transformation = not np.allclose(m, np.eye(4)) - # The selector query used at link time is persisted per + # The selector filter used at link time is persisted per # reference in its Description (IFC4+); restore it so # Reload/Load replay the filter. - query = getattr(reference, "Description", None) or "" - if not query and location_counts[filepath] == 1: + query, exclude = cls.decode_link_filter(getattr(reference, "Description", None)) + if not query and not exclude and location_counts[filepath] == 1: # Fall back to the legacy sidecar cache JSON where older # versions persisted the query. Only unambiguous: with # several links to one file the shared JSON can't say @@ -457,6 +489,7 @@ class Project(bonsai.core.tool.Project): except (OSError, json.JSONDecodeError): pass link.query = query + link.exclude = exclude @classmethod def get_project_library_elements(