Add BBIM_DimensionTarget: parametric dimensions anchored to element geometry

New modal operator (bim.set_dimension_anchor) anchors dimension vertices
to IFC element faces. Anchors are stored as JSON in a BBIM_DimensionTarget
pset on the IfcAnnotation and resolved via tessellation at regeneration time.

- resolve_anchor.py / regenerate_dimension.py: new ifcopenshell API modules
- bim.set_dimension_anchor: 2-phase Object Mode modal (pick vertex → pick face)
- bim.regenerate_dimensions: recomputes all parametric dimensions
- Auto-regeneration via depsgraph_update_post when referenced elements move
- placement_override reads Blender matrix_world for G-moved elements
- Plan-view annotations flattened to annotation plane (Z=0 in local space)
- IfcIndexedPolyCurve.Segments rebuilt to handle n-point chains correctly
This commit is contained in:
Ryan Schultz
2026-05-15 20:49:10 -05:00
parent d2f18709cb
commit 0874c4e59c
8 changed files with 1620 additions and 1 deletions
@@ -25,12 +25,19 @@ annotations may have relationships which indicate smart data being populated.
from .. import wrap_usecases
from .assign_product import assign_product
from .edit_text_literal import edit_text_literal
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths
from .resolve_anchor import build_anchor_from_hit, make_world_anchor, resolve_anchor
from .unassign_product import unassign_product
wrap_usecases(__path__, __name__)
__all__ = [
"assign_product",
"build_anchor_from_hit",
"edit_text_literal",
"get_dimension_segment_lengths",
"make_world_anchor",
"regenerate_dimension",
"resolve_anchor",
"unassign_product",
]
@@ -0,0 +1,238 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Regenerate a parametric dimension annotation from its BBIM_DimensionTarget anchors.
This module operates purely on IFC data. It:
1. Reads the ``Anchors`` JSON array from the ``BBIM_DimensionTarget`` pset on an
``IfcAnnotation``.
2. Resolves each anchor to a world-space point (IFC project units) using
``resolve_anchor``.
3. Computes per-segment distances and updates (or creates) the linked
``IfcMetric`` + ``IfcRelAssociatesConstraint`` entities.
4. Returns the ordered list of resolved world-space points so that the
Bonsai operator layer can update the Blender curve object.
Updating the Blender curve (converting IFC world coords → annotation local
coords) is the *caller's* responsibility and does **not** happen here.
"""
from __future__ import annotations
import json
import math
from typing import Optional
import ifcopenshell
import ifcopenshell.api.owner
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.guid
import ifcopenshell.util.element
from .resolve_anchor import resolve_anchor
_PSET_NAME = "BBIM_DimensionTarget"
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
def regenerate_dimension(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
) -> list[tuple[float, float, float]]:
"""Regenerate a parametric dimension from its stored anchor references.
Resolves every anchor in ``BBIM_DimensionTarget.Anchors``, updates the
per-segment ``IfcMetric`` values (creating them when absent), and returns
the resolved world-space points in metres.
:param file: The open IFC file.
:param annotation: An ``IfcAnnotation`` with a ``BBIM_DimensionTarget`` pset.
:param settings: Geometry settings for tessellation (shared across calls).
:param shape_cache: Shape cache dict (shared across calls for performance).
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
matrix (metres, row-major). Pass ``{elem.id(): np.array(obj.matrix_world)}``
for each referenced element so that viewport moves not yet synced to the
IFC ``ObjectPlacement`` are reflected. See ``resolve_anchor`` for details.
:return: Ordered list of ``(x, y, z)`` tuples, one per anchor.
Empty list if the pset is missing or malformed.
"""
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
if not pset_data or "Anchors" not in pset_data:
return []
try:
anchors: list[dict] = json.loads(pset_data["Anchors"])
except (json.JSONDecodeError, TypeError):
return []
if not anchors:
return []
if shape_cache is None:
shape_cache = {}
resolved: list[Optional[tuple]] = []
for anchor in anchors:
pt = resolve_anchor(file, anchor, settings, shape_cache, placement_override)
if pt is None:
pt = tuple(anchor["pt"]) if anchor.get("pt") else (0.0, 0.0, 0.0)
resolved.append(pt)
anchor["pt"] = list(pt)
pset_entity_id = pset_data.get("id")
if pset_entity_id:
pset_entity = file.by_id(pset_entity_id)
ifcopenshell.api.pset.edit_pset(
file,
pset=pset_entity,
properties={"Anchors": json.dumps(anchors)},
)
n_segments = len(resolved) - 1
if n_segments >= 1:
existing_metrics = _get_segment_metrics(file, annotation)
_sync_segment_metrics(file, annotation, resolved, existing_metrics)
return [pt for pt in resolved if pt is not None]
def get_dimension_segment_lengths(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
) -> list[float]:
"""Return the segment lengths for a parametric dimension from stored anchor pts.
Distances are computed from the cached ``pt`` fields in ``BBIM_DimensionTarget.Anchors``
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
is absent or malformed.
"""
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
if not pset_data or not pset_data.get("Anchors"):
return []
try:
anchors: list[dict] = json.loads(pset_data["Anchors"])
except Exception:
return []
lengths: list[float] = []
for i in range(len(anchors) - 1):
pt_a = anchors[i].get("pt")
pt_b = anchors[i + 1].get("pt")
if pt_a and pt_b:
lengths.append(_dist(tuple(pt_a), tuple(pt_b)))
else:
lengths.append(0.0)
return lengths
# ---------------------------------------------------------------------------
# IfcMetric / IfcRelAssociatesConstraint management
# ---------------------------------------------------------------------------
def _get_segment_metrics(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
) -> dict[int, ifcopenshell.entity_instance]:
"""Return {segment_index: IfcMetric} for all constraint rels on the annotation."""
metrics: dict[int, ifcopenshell.entity_instance] = {}
for rel in annotation.HasAssociations:
if not rel.is_a("IfcRelAssociatesConstraint"):
continue
intent: str = rel.Intent or ""
if not intent.startswith(_METRIC_INTENT_PREFIX):
continue
try:
seg_idx = int(intent[len(_METRIC_INTENT_PREFIX):])
except ValueError:
continue
constraint = rel.RelatingConstraint
if constraint.is_a("IfcMetric"):
metrics[seg_idx] = constraint
return metrics
def _sync_segment_metrics(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
resolved_pts: list[tuple],
existing: dict[int, ifcopenshell.entity_instance],
) -> None:
"""Create missing and update existing IfcMetric entities for each segment."""
n_segments = len(resolved_pts) - 1
seen_guids: set[str] = set()
# Build a lookup of which elements are at each anchor endpoint
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
anchors: list[dict] = []
if pset_data and pset_data.get("Anchors"):
try:
anchors = json.loads(pset_data["Anchors"])
except Exception:
pass
for seg_idx in range(n_segments):
if seg_idx in existing:
pass # metric already exists; association is still valid
else:
# Create new IfcMetric + IfcRelAssociatesConstraint
# DataValue is IfcMetricValueSelect (entity-only SELECT in IFC4) — omit it;
# the measured distance is derivable from the anchor pt fields.
metric = file.create_entity(
"IfcMetric",
Name=f"seg_{seg_idx}",
ConstraintGrade="ADVISORY",
Benchmark="EQUALTO",
)
# Gather related products for this segment (the two anchor elements)
related: list[ifcopenshell.entity_instance] = [annotation]
for anchor_idx in (seg_idx, seg_idx + 1):
if anchor_idx < len(anchors):
guid = anchors[anchor_idx].get("guid")
if guid and guid not in seen_guids:
try:
elem = file.by_guid(guid)
related.append(elem)
seen_guids.add(guid)
except Exception:
pass
file.create_entity(
"IfcRelAssociatesConstraint",
GlobalId=ifcopenshell.guid.new(),
OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
Intent=f"{_METRIC_INTENT_PREFIX}{seg_idx}",
RelatingConstraint=metric,
RelatedObjects=related,
)
# Remove orphaned metrics for segments that no longer exist
for seg_idx, metric in existing.items():
if seg_idx >= n_segments:
for rel in file.get_inverse(metric):
if rel.is_a("IfcRelAssociatesConstraint"):
file.remove(rel)
file.remove(metric)
def _dist(a: tuple, b: tuple) -> float:
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
@@ -0,0 +1,696 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Resolve a parametric dimension anchor to a world-space coordinate in metres.
NOTE ON COORDINATE SPACE
ifcopenshell.geom.create_shape() always outputs geometry in **metres** (its
internal unit), regardless of the IFC project's declared length unit (feet, mm,
etc.). All anchor coordinates (``pt``, ``hint``, fingerprint ``centroid``) are
therefore stored in metres, which is also Blender world space. The IFC
project's unit_scale is NOT applied here. Callers that need IFC project units
must divide by ``ifcopenshell.util.unit.calculate_unit_scale(file)`` themselves.
Anchor schema (JSON-serialisable dict stored in BBIM_DimensionTarget.Anchors):
{
"guid": str | None, # element GlobalId; None → WORLD type (free point)
"type": str, # "FACE" | "CIRCLE_CENTER" | "WORLD"
"addr": {
"method": str, # "ANALYTIC" | "TESS_INDEX" | "TESS_FINGERPRINT"
"repr_id": int, # STEP id of representation item (ANALYTIC / TESS_INDEX)
"repr_type": str, # IFC class of representation item
"face_role": str, # "TOP" | "BOTTOM" | "SIDE_<n>" (IfcExtrudedAreaSolid only)
"tess_index": int, # coplanar face-group index (-1 = skip)
"fingerprint": {
"normal": [x, y, z], # world-space unit normal (IFC project units)
"area": float, # total face area
"centroid": [x, y, z] # area-weighted centroid
}
} | None,
"hint": [x, y, z] | None, # original click position for disambiguation
"pt": [x, y, z] # last resolved position — used as fallback
}
"""
from __future__ import annotations
import math
from typing import Optional
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.placement
import ifcopenshell.util.unit
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def resolve_anchor(
file: ifcopenshell.file,
anchor: dict,
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
) -> Optional[tuple[float, float, float]]:
"""Resolve an anchor dict to a world-space point in metres.
Resolution order:
1. WORLD / null guid → return stored ``pt`` directly.
2. ANALYTIC for IfcExtrudedAreaSolid → analytical TOP/BOTTOM face centre.
3. TESS_INDEX → centroid of a pre-recorded face group by index.
4. TESS_FINGERPRINT → best face group matched by normal + centroid proximity.
5. Fallback → stored ``pt``.
:param file: The open IFC file.
:param anchor: Anchor descriptor dict.
:param settings: ifcopenshell.geom settings; created automatically when None.
:param shape_cache: Mutable dict keyed by element STEP id to cache shapes.
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
matrix (row-major, metres). When provided, this matrix is used instead of
``element.ObjectPlacement`` for the local→world transform. Pass the
Blender object's ``matrix_world`` here so that elements moved in the
viewport but not yet explicitly synced to IFC are handled correctly.
:return: ``(x, y, z)`` in metres, or ``None``.
"""
anchor_type = anchor.get("type", "WORLD")
guid = anchor.get("guid")
if anchor_type == "WORLD" or not guid:
return _pt_or_none(anchor.get("pt"))
try:
element = file.by_guid(guid)
except Exception:
return _pt_or_none(anchor.get("pt"))
addr = anchor.get("addr") or {}
method = addr.get("method", "TESS_FINGERPRINT")
# --- 1. Analytical path (fast, exact) ---
if method == "ANALYTIC" and addr.get("repr_type") == "IfcExtrudedAreaSolid":
pt = _resolve_extruded_area_solid_analytic(file, element, addr, placement_override)
if pt is not None:
return pt
# --- 2 & 3. Tessellation path (universal) ---
shape = _get_shape(file, element, settings, shape_cache)
if shape is None:
return _pt_or_none(anchor.get("pt"))
verts, tris = _extract_mesh(shape)
if not tris:
return _pt_or_none(anchor.get("pt"))
groups = _group_coplanar_tris(verts, tris)
group_props = [_face_group_props(g, verts, tris) for g in groups]
# group_props centroids/normals are in LOCAL metres (no USE_WORLD_COORDS).
# Build world-space equivalents using placement_override (Blender matrix_world)
# when available, otherwise fall back to element.ObjectPlacement from IFC.
world_group_props = [
{
"centroid": _local_to_world_m(file, element, gp["centroid"], placement_override),
"normal": _rotate_local_to_world(element, gp["normal"], placement_override),
"area": gp["area"],
}
for gp in group_props
]
# TESS_INDEX (fast, index into the cached face-group list)
tess_index = addr.get("tess_index", -1)
if 0 <= tess_index < len(groups):
return world_group_props[tess_index]["centroid"]
# TESS_FINGERPRINT (robust across topology changes)
fingerprint = addr.get("fingerprint")
hint = anchor.get("hint")
if fingerprint:
pt = _find_by_fingerprint(world_group_props, fingerprint, hint)
if pt is not None:
return pt
return _pt_or_none(anchor.get("pt"))
def build_anchor_from_hit(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
hit_location_ifc: tuple[float, float, float],
hit_normal_ifc: tuple[float, float, float],
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
) -> dict:
"""Build an anchor dict from a viewport ray-cast hit.
Tessellates the element, finds the best-matching face group for the hit
normal/location, computes the fingerprint, and optionally detects an
IfcExtrudedAreaSolid face role (TOP/BOTTOM) for the analytical path.
:param file: The open IFC file.
:param element: The IFC element that was hit.
:param hit_location_ifc: Hit point in metres (world space).
:param hit_normal_ifc: Face normal at the hit point (world space, unit vec).
:param settings: Geometry settings for tessellation.
:param shape_cache: Mutable shape-cache dict.
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
matrix (metres). See ``resolve_anchor`` for details.
:return: Anchor dict ready for JSON serialisation into BBIM_DimensionTarget.
"""
shape = _get_shape(file, element, settings, shape_cache)
tess_index = -1
fingerprint: dict = {
"normal": list(hit_normal_ifc),
"area": 0.0,
"centroid": list(hit_location_ifc),
}
if shape is not None:
verts, tris = _extract_mesh(shape)
groups = _group_coplanar_tris(verts, tris)
local_group_props = [_face_group_props(g, verts, tris) for g in groups]
world_group_props = [
{
"centroid": _local_to_world_m(file, element, gp["centroid"], placement_override),
"normal": _rotate_local_to_world(element, gp["normal"], placement_override),
"area": gp["area"],
}
for gp in local_group_props
]
best = _best_group(world_group_props, hit_normal_ifc, hit_location_ifc)
if best is not None:
tess_index, props = best
fingerprint = {
"normal": list(props["normal"]),
"area": props["area"],
"centroid": list(props["centroid"]),
}
repr_type, repr_id, face_role = _detect_extruded_face(file, element, hit_location_ifc, hit_normal_ifc)
method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT"
return {
"guid": element.GlobalId,
"type": "FACE",
"addr": {
"method": method,
"repr_id": repr_id,
"repr_type": repr_type,
"face_role": face_role,
"tess_index": tess_index,
"fingerprint": fingerprint,
},
"hint": list(hit_location_ifc),
"pt": list(hit_location_ifc),
}
def make_world_anchor(pt_ifc: tuple[float, float, float]) -> dict:
"""Build a free-floating (WORLD) anchor — not connected to any element."""
return {
"guid": None,
"type": "WORLD",
"addr": None,
"hint": None,
"pt": list(pt_ifc),
}
# ---------------------------------------------------------------------------
# Mesh extraction helpers
# ---------------------------------------------------------------------------
def _get_shape(file, element, settings, shape_cache):
if shape_cache is None:
shape_cache = {}
elem_id = element.id()
if elem_id in shape_cache:
return shape_cache[elem_id]
if settings is None:
settings = ifcopenshell.geom.settings()
# Do NOT set USE_WORLD_COORDS — tessellate in local (element-origin) space.
# The geom kernel caches by representation ID; with USE_WORLD_COORDS=True,
# moving an element would return stale world-space coords from the cache.
# We apply the current placement manually via placement_override.
settings.set("APPLY_DEFAULT_MATERIALS", False)
try:
shape = ifcopenshell.geom.create_shape(settings, element)
except Exception:
shape = None
shape_cache[elem_id] = shape
return shape
def _local_to_world_m(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
local_pt_m: tuple,
placement_override: Optional[dict] = None,
) -> tuple[float, float, float]:
"""Convert a local-space point (metres, from create_shape without USE_WORLD_COORDS)
to a world-space point in metres.
When *placement_override* contains the element's STEP id, that 4×4 matrix
(row-major, already in metres — typically ``np.array(obj.matrix_world)``) is
used instead of reading ``element.ObjectPlacement`` from the IFC file. This
ensures that elements moved in the Blender viewport but not yet explicitly
synced to IFC (via "Edit Object Placement") are handled correctly.
Without an override, falls back to ``get_local_placement`` which reads the IFC
placement and scales IFC-unit translation to metres via ``unit_scale``.
"""
x, y, z = float(local_pt_m[0]), float(local_pt_m[1]), float(local_pt_m[2])
if placement_override is not None and element.id() in placement_override:
m = placement_override[element.id()] # 4×4, metres, row-major
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]),
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3] * unit_scale),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3] * unit_scale),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3] * unit_scale),
)
def _rotate_local_to_world(
element: ifcopenshell.entity_instance,
local_vec: tuple,
placement_override: Optional[dict] = None,
) -> tuple[float, float, float]:
"""Rotate a direction vector from local to world space (no translation)."""
x, y, z = float(local_vec[0]), float(local_vec[1]), float(local_vec[2])
if placement_override is not None and element.id() in placement_override:
m = placement_override[element.id()]
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z),
)
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z),
)
def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]:
"""Return (verts, tris) from a tessellated shape."""
vf = shape.geometry.verts
ff = shape.geometry.faces
verts = [(vf[i * 3], vf[i * 3 + 1], vf[i * 3 + 2]) for i in range(len(vf) // 3)]
tris = [(ff[i * 3], ff[i * 3 + 1], ff[i * 3 + 2]) for i in range(len(ff) // 3)]
return verts, tris
# ---------------------------------------------------------------------------
# Coplanar face grouping
# ---------------------------------------------------------------------------
_NORMAL_THRESHOLD = 0.005 # max angle deviation between coplanar normals (~0.3°)
_PLANE_THRESHOLD = 1e-4 # max distance from origin along normal (metres — matches geom output)
def _tri_normal(v0, v1, v2) -> tuple[float, float, float]:
ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
nx = ay * bz - az * by
ny = az * bx - ax * bz
nz = ax * by - ay * bx
mag = math.sqrt(nx * nx + ny * ny + nz * nz)
if mag < 1e-12:
return (0.0, 0.0, 0.0)
return (nx / mag, ny / mag, nz / mag)
def _dot(a, b) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _group_coplanar_tris(verts: list, tris: list) -> list[list[int]]:
"""Group triangle indices whose faces are coplanar (same normal + plane)."""
n_tris = len(tris)
normals: list[tuple] = []
plane_d: list[float] = []
for a, b, c in tris:
n = _tri_normal(verts[a], verts[b], verts[c])
normals.append(n)
# plane distance: n · centroid
cx = (verts[a][0] + verts[b][0] + verts[c][0]) / 3
cy = (verts[a][1] + verts[b][1] + verts[c][1]) / 3
cz = (verts[a][2] + verts[b][2] + verts[c][2]) / 3
plane_d.append(n[0] * cx + n[1] * cy + n[2] * cz)
assigned = [False] * n_tris
groups: list[list[int]] = []
for i in range(n_tris):
if assigned[i]:
continue
group = [i]
assigned[i] = True
ni, di = normals[i], plane_d[i]
if ni == (0.0, 0.0, 0.0):
groups.append(group)
continue
for j in range(i + 1, n_tris):
if assigned[j]:
continue
nj, dj = normals[j], plane_d[j]
if nj == (0.0, 0.0, 0.0):
continue
dot_val = _dot(ni, nj) # signed — opposite normals (dot≈-1) must NOT merge
if dot_val > 1.0 - _NORMAL_THRESHOLD and abs(di - dj) < _PLANE_THRESHOLD:
group.append(j)
assigned[j] = True
groups.append(group)
return groups
def _tri_area(v0, v1, v2) -> float:
ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
cx = ay * bz - az * by
cy = az * bx - ax * bz
cz = ax * by - ay * bx
return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
def _face_group_props(group: list[int], verts: list, tris: list) -> dict:
"""Compute normal, total area, and area-weighted centroid for a face group."""
total_area = 0.0
wx = wy = wz = 0.0
nx = ny = nz = 0.0
for idx in group:
a, b, c = tris[idx]
va, vb, vc = verts[a], verts[b], verts[c]
area = _tri_area(va, vb, vc)
total_area += area
cx = (va[0] + vb[0] + vc[0]) / 3
cy = (va[1] + vb[1] + vc[1]) / 3
cz = (va[2] + vb[2] + vc[2]) / 3
wx += cx * area
wy += cy * area
wz += cz * area
n = _tri_normal(va, vb, vc)
nx += n[0] * area
ny += n[1] * area
nz += n[2] * area
if total_area < 1e-12:
return {"normal": (0.0, 0.0, 1.0), "area": 0.0, "centroid": (wx, wy, wz)}
centroid = (wx / total_area, wy / total_area, wz / total_area)
mag = math.sqrt(nx * nx + ny * ny + nz * nz)
if mag > 1e-12:
normal: tuple[float, ...] = (nx / mag, ny / mag, nz / mag)
else:
normal = (0.0, 0.0, 1.0)
return {"normal": normal, "area": total_area, "centroid": centroid}
# ---------------------------------------------------------------------------
# Fingerprint matching
# ---------------------------------------------------------------------------
_NORMAL_MATCH_THRESHOLD = 0.02 # max dot-product deviation for normal match
_CENTROID_MAX_DIST = 10.0 # max IFC-unit distance for centroid proximity
def _dist(a, b) -> float:
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
def _find_by_fingerprint(
group_props: list[dict],
fingerprint: dict,
hint: Optional[list],
) -> Optional[tuple[float, float, float]]:
"""Return the centroid of the best-matching face group."""
fp_normal = fingerprint["normal"]
fp_centroid = fingerprint["centroid"]
best_score = -1.0
best_centroid = None
for props in group_props:
dot_val = _dot(props["normal"], fp_normal)
if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
continue # wrong-facing face
# Score: prefer face whose centroid is closest to stored fingerprint centroid,
# then to the original click hint.
centroid_dist = _dist(props["centroid"], fp_centroid)
if centroid_dist > _CENTROID_MAX_DIST:
continue
score = dot_val - centroid_dist / _CENTROID_MAX_DIST * 0.3
if hint:
hint_dist = _dist(props["centroid"], hint)
score -= hint_dist / _CENTROID_MAX_DIST * 0.1
if score > best_score:
best_score = score
best_centroid = props["centroid"]
return best_centroid
def _best_group(
group_props: list[dict],
hit_normal: tuple,
hit_location: tuple,
) -> Optional[tuple[int, dict]]:
"""Return (index, props) for the best face group matching a ray-cast hit."""
best_score = -1.0
best = None
for i, props in enumerate(group_props):
dot_val = _dot(props["normal"], hit_normal)
if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
continue
dist = _dist(props["centroid"], hit_location)
score = dot_val - dist / max(_CENTROID_MAX_DIST, 0.001) * 0.2
if score > best_score:
best_score = score
best = (i, props)
return best
# ---------------------------------------------------------------------------
# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM
# ---------------------------------------------------------------------------
def _resolve_extruded_area_solid_analytic(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
addr: dict,
placement_override: Optional[dict] = None,
) -> Optional[tuple[float, float, float]]:
"""Analytically resolve TOP or BOTTOM face centre of an IfcExtrudedAreaSolid."""
face_role = addr.get("face_role", "")
if face_role not in ("TOP", "BOTTOM"):
return None
repr_id = addr.get("repr_id")
if not repr_id:
return None
try:
solid = file.by_id(repr_id)
except Exception:
return None
if not solid.is_a("IfcExtrudedAreaSolid"):
return None
try:
profile_centroid_local = _profile_centroid(solid.SweptArea)
dir_ratios = solid.ExtrudedDirection.DirectionRatios
depth = solid.Depth
mag = math.sqrt(sum(d * d for d in dir_ratios))
if mag < 1e-12:
return None
dir_vec = tuple(d / mag for d in dir_ratios)
px = profile_centroid_local[0] + dir_vec[0] * (depth if face_role == "TOP" else 0.0)
py = profile_centroid_local[1] + dir_vec[1] * (depth if face_role == "TOP" else 0.0)
pz = dir_vec[2] * (depth if face_role == "TOP" else 0.0)
if solid.Position:
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
else:
local_pt = (px, py, pz)
# Apply element placement — use placement_override (Blender matrix_world, metres)
# when available so that unsync'd viewport moves are reflected.
return _local_to_world_m(file, element, local_pt, placement_override)
except Exception:
return None
def _profile_centroid(profile) -> tuple[float, float]:
"""Return (x, y) centroid of a profile def in its local 2D space."""
if profile.is_a("IfcRectangleProfileDef"):
pos = profile.Position
if pos:
loc = pos.Location
return (loc.Coordinates[0], loc.Coordinates[1])
return (0.0, 0.0)
if profile.is_a("IfcCircleProfileDef"):
pos = profile.Position
if pos:
loc = pos.Location
return (loc.Coordinates[0], loc.Coordinates[1])
return (0.0, 0.0)
# Fallback for arbitrary profiles — use position location if available
if hasattr(profile, "Position") and profile.Position:
loc = profile.Position.Location
return (loc.Coordinates[0], loc.Coordinates[1])
return (0.0, 0.0)
def _apply_axis2placement3d(placement, pt: tuple) -> tuple[float, float, float]:
"""Apply an IfcAxis2Placement3D to a local point."""
loc = placement.Location.Coordinates
ox, oy, oz = float(loc[0]), float(loc[1]), float(loc[2])
# Z axis (extrusion direction in placement space)
if placement.Axis:
zr = placement.Axis.DirectionRatios
zx, zy, zz = float(zr[0]), float(zr[1]), float(zr[2])
else:
zx, zy, zz = 0.0, 0.0, 1.0
# X axis (ref direction)
if placement.RefDirection:
xr = placement.RefDirection.DirectionRatios
xx, xy, xz = float(xr[0]), float(xr[1]), float(xr[2])
else:
xx, xy, xz = 1.0, 0.0, 0.0
# Y axis = Z × X
yx = zy * xz - zz * xy
yy = zz * xx - zx * xz
yz = zx * xy - zy * xx
px, py, pz = pt
return (
ox + px * xx + py * yx + pz * zx,
oy + px * xy + py * yy + pz * zy,
oz + px * xz + py * yz + pz * zz,
)
def _mat_apply(m, pt: tuple) -> tuple[float, float, float]:
"""Apply a 4×4 numpy placement matrix to a point."""
x, y, z = float(pt[0]), float(pt[1]), float(pt[2])
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]),
)
# ---------------------------------------------------------------------------
# IfcExtrudedAreaSolid face role detection
# ---------------------------------------------------------------------------
def _detect_extruded_face(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
hit_location: tuple,
hit_normal: tuple,
) -> tuple[str, int, str]:
"""Try to identify if the hit face is a TOP or BOTTOM of an IfcExtrudedAreaSolid.
Returns (repr_type, repr_id, face_role).
repr_type is empty string if not detected as extruded solid.
"""
if not hasattr(element, "Representation") or not element.Representation:
return ("", -1, "")
for rep in element.Representation.Representations:
for item in rep.Items:
solid = _unwrap_mapped(item)
if not solid or not solid.is_a("IfcExtrudedAreaSolid"):
continue
role = _extruded_face_role(solid, hit_normal)
if role:
return ("IfcExtrudedAreaSolid", solid.id(), role)
return ("", -1, "")
def _unwrap_mapped(item):
"""Unwrap IfcMappedItem to its underlying representation item (first item)."""
if item.is_a("IfcMappedItem"):
items = item.MappingSource.MappedRepresentation.Items
return items[0] if items else None
return item
def _extruded_face_role(solid, hit_normal: tuple) -> str:
"""Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion."""
try:
dr = solid.ExtrudedDirection.DirectionRatios
mag = math.sqrt(sum(d * d for d in dr))
if mag < 1e-12:
return ""
extrude_dir = tuple(d / mag for d in dr)
dot_val = _dot(extrude_dir, hit_normal)
if dot_val > 0.99:
return "TOP"
if dot_val < -0.99:
return "BOTTOM"
except Exception:
pass
return ""
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def _pt_or_none(pt) -> Optional[tuple[float, float, float]]:
if pt:
return (float(pt[0]), float(pt[1]), float(pt[2]))
return None