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 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-10 15:26:33 -05:00
parent 403308a923
commit 8f9164bf72
5 changed files with 143 additions and 34 deletions
@@ -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():
@@ -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)
@@ -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):
+44 -11
View File
@@ -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(