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(