mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-19 03:33:48 +00:00
Add ForcePerpendicularToFace + hover-cycle UX for parametric dimensions
SetDimensionAnchor — hover-select-then-confirm: - Cursor highlights candidate IFC elements (orange Blender selection outline) before committing; Tab cycles through overlapping/coplanar candidates - _compute_candidates: ray-cast all IFC mesh objects; falls back to 2D bounding-box proximity (5 cm tolerance) for plan-view picks where the ray misses the mesh by sub-mm amounts - _write_anchor: after anchoring a face, immediately calls regenerate_dimension with placement_override (Blender matrix_world) and _update_blender_curve so the curve vertex moves to the resolved point DrawParametricDimension — ForcePerpendicularToFace live snap constraint: - Reads force_perpendicular_to_face toggle from annotation props on invoke - After anchor[0] is placed on a FACE, _update_perp_constraint extracts the face normal and stores it as the constraint axis - _apply_perp_constraint runs every modal tick after handle_snap_selection, projecting the current snap point onto pt[0] + t*normal - On finalize, _create_dimension_from_polyline writes ForcePerpendicularToFace to the BBIM_Dimension pset and calls regenerate_dimension to snap the stored curve to the constraint before the operator exits regenerate_dimension.py: - ForcePerpendicularToFace block: after resolving all anchors, projects vertices 1…n onto the line through pt[0] along anchor[0]'s face normal - _get_anchor_face_normal_world: reads normal_local from anchor fingerprint, calls _rotate_local_to_world with placement_override; falls back to stored world-space normal resolve_anchor.py: - _rotate_local_to_world: transforms an element-local direction vector to world space using the element's placement or placement_override matrix pset/operator.py: - EditPset._execute: after editing a BBIM_Dimension pset on an IfcAnnotation, auto-calls regenerate_dimension + _update_blender_curve so changes to anchors/ForcePerpendicularToFace are reflected immediately in the viewport prop.py / workspace.py: - Added force_perpendicular_to_face BoolProperty to BIMAnnotationProperties - UI toggle shown in annotation tool header for DIMENSION/RADIUS/DIAMETER/ ANGLE/PLAN_LEVEL/SECTION_LEVEL types Psets_BBIM_Annotation.ifc: - Added ForcePerpendicularToFace property template (#39) to BBIM_Dimension - Extended BBIM_Dimension applicability to ANGLE, PLAN_LEVEL, SECTION_LEVEL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,10 +16,10 @@
|
||||
# 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.
|
||||
"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors.
|
||||
|
||||
This module operates purely on IFC data. It:
|
||||
1. Reads the ``Anchors`` JSON array from the ``BBIM_DimensionTarget`` pset on an
|
||||
1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an
|
||||
``IfcAnnotation``.
|
||||
2. Resolves each anchor to a world-space point (IFC project units) using
|
||||
``resolve_anchor``.
|
||||
@@ -48,7 +48,7 @@ import ifcopenshell.util.element
|
||||
from .resolve_anchor import resolve_anchor
|
||||
|
||||
|
||||
_PSET_NAME = "BBIM_DimensionTarget"
|
||||
_PSET_NAME = "BBIM_Dimension"
|
||||
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@ def regenerate_dimension(
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Regenerate a parametric dimension from its stored anchor references.
|
||||
|
||||
Resolves every anchor in ``BBIM_DimensionTarget.Anchors``, updates the
|
||||
Resolves every anchor in ``BBIM_Dimension.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 annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` 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
|
||||
@@ -99,6 +99,25 @@ def regenerate_dimension(
|
||||
resolved.append(pt)
|
||||
anchor["pt"] = list(pt)
|
||||
|
||||
# ForcePerpendicularToFace: project vertices 1…n onto the line through
|
||||
# pt[0] in the direction of anchor[0]'s face normal, so the polyline is
|
||||
# constrained perpendicular to the face the first vertex is anchored to.
|
||||
if pset_data.get("ForcePerpendicularToFace") and len(resolved) >= 2 and resolved[0] is not None:
|
||||
normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
||||
if normal:
|
||||
base = resolved[0]
|
||||
for i in range(1, len(resolved)):
|
||||
if resolved[i] is None:
|
||||
continue
|
||||
pt = resolved[i]
|
||||
t = ((pt[0] - base[0]) * normal[0]
|
||||
+ (pt[1] - base[1]) * normal[1]
|
||||
+ (pt[2] - base[2]) * normal[2])
|
||||
resolved[i] = (base[0] + t * normal[0],
|
||||
base[1] + t * normal[1],
|
||||
base[2] + t * normal[2])
|
||||
anchors[i]["pt"] = list(resolved[i])
|
||||
|
||||
pset_entity_id = pset_data.get("id")
|
||||
if pset_entity_id:
|
||||
pset_entity = file.by_id(pset_entity_id)
|
||||
@@ -122,7 +141,7 @@ def get_dimension_segment_lengths(
|
||||
) -> 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``
|
||||
Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors``
|
||||
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
|
||||
is absent or malformed.
|
||||
"""
|
||||
@@ -236,3 +255,39 @@ def _sync_segment_metrics(
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _get_anchor_face_normal_world(
|
||||
file: ifcopenshell.file,
|
||||
anchor: dict,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Return the world-space unit face normal stored in a FACE anchor, or None.
|
||||
|
||||
Prefers ``normal_local`` (element-local, rotation-invariant) transformed by
|
||||
the current element placement. Falls back to the stored world-space normal.
|
||||
"""
|
||||
if anchor.get("type") != "FACE":
|
||||
return None
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
return None
|
||||
fp = (anchor.get("addr") or {}).get("fingerprint") or {}
|
||||
|
||||
normal_local = fp.get("normal_local")
|
||||
if normal_local:
|
||||
try:
|
||||
element = file.by_guid(guid)
|
||||
except Exception:
|
||||
return None
|
||||
from .resolve_anchor import _rotate_local_to_world
|
||||
n = _rotate_local_to_world(element, normal_local, placement_override)
|
||||
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
||||
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
|
||||
|
||||
normal_world = fp.get("normal")
|
||||
if normal_world:
|
||||
mag = math.sqrt(sum(x * x for x in normal_world))
|
||||
return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value]
|
||||
|
||||
return None
|
||||
|
||||
@@ -26,7 +26,7 @@ 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):
|
||||
Anchor schema (JSON-serialisable dict stored in BBIM_Dimension.Anchors):
|
||||
|
||||
{
|
||||
"guid": str | None, # element GlobalId; None → WORLD type (free point)
|
||||
@@ -135,15 +135,28 @@ def resolve_anchor(
|
||||
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:
|
||||
fp_normal_local = fingerprint.get("normal_local") if fingerprint else None
|
||||
|
||||
# TESS_INDEX fast path — only accept when the local fingerprint normal still
|
||||
# matches at that index, guarding against face-group reordering after any
|
||||
# geometry edit or profile change.
|
||||
tess_index = addr.get("tess_index", -1)
|
||||
if 0 <= tess_index < len(groups):
|
||||
candidate_local = group_props[tess_index]
|
||||
if fp_normal_local is None or _dot(candidate_local["normal"], fp_normal_local) >= 1.0 - _NORMAL_MATCH_THRESHOLD:
|
||||
return world_group_props[tess_index]["centroid"]
|
||||
# Local-normal mismatch — face groups reordered; fall through to fingerprint.
|
||||
|
||||
# TESS_FINGERPRINT — match by element-local normal (rotation-invariant).
|
||||
if fp_normal_local:
|
||||
pt = _find_by_local_normal(group_props, world_group_props, fp_normal_local, hint)
|
||||
if pt is not None:
|
||||
return pt
|
||||
elif fingerprint:
|
||||
# Legacy anchors built before normal_local was stored: fall back to
|
||||
# world-space normal matching (not rotation-invariant, but best we can do).
|
||||
pt = _find_by_fingerprint(world_group_props, fingerprint, hint)
|
||||
if pt is not None:
|
||||
return pt
|
||||
@@ -174,7 +187,7 @@ def build_anchor_from_hit(
|
||||
: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.
|
||||
:return: Anchor dict ready for JSON serialisation into BBIM_Dimension.
|
||||
"""
|
||||
shape = _get_shape(file, element, settings, shape_cache)
|
||||
|
||||
@@ -201,12 +214,17 @@ def build_anchor_from_hit(
|
||||
if best is not None:
|
||||
tess_index, props = best
|
||||
fingerprint = {
|
||||
# normal_local: element-local normal — rotation-invariant primary key.
|
||||
"normal_local": list(local_group_props[tess_index]["normal"]),
|
||||
# world-space fields kept for legacy / disambiguation.
|
||||
"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)
|
||||
repr_type, repr_id, face_role = _detect_extruded_face(
|
||||
file, element, hit_location_ifc, hit_normal_ifc, placement_override
|
||||
)
|
||||
method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT"
|
||||
|
||||
return {
|
||||
@@ -322,6 +340,35 @@ def _rotate_local_to_world(
|
||||
)
|
||||
|
||||
|
||||
def _world_normal_to_elem_local(
|
||||
file: ifcopenshell.file,
|
||||
element: ifcopenshell.entity_instance,
|
||||
world_normal: tuple,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Rotate a world-space direction into element-local space (rotation only, no translation).
|
||||
|
||||
Uses placement_override (Blender matrix_world) when available so that
|
||||
elements moved/rotated in the viewport are handled correctly.
|
||||
"""
|
||||
x, y, z = float(world_normal[0]), float(world_normal[1]), float(world_normal[2])
|
||||
if placement_override is not None and element.id() in placement_override:
|
||||
m = placement_override[element.id()]
|
||||
# Inverse rotation = transpose of the 3×3 rotation block.
|
||||
lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z
|
||||
ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z
|
||||
lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z
|
||||
else:
|
||||
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
||||
lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z
|
||||
ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z
|
||||
lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z
|
||||
mag = math.sqrt(lx * lx + ly * ly + lz * lz)
|
||||
if mag > 1e-12:
|
||||
return (lx / mag, ly / mag, lz / mag)
|
||||
return (x, y, z)
|
||||
|
||||
|
||||
def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]:
|
||||
"""Return (verts, tris) from a tessellated shape."""
|
||||
vf = shape.geometry.verts
|
||||
@@ -511,8 +558,36 @@ def _best_group(
|
||||
return best
|
||||
|
||||
|
||||
def _find_by_local_normal(
|
||||
local_group_props: list[dict],
|
||||
world_group_props: list[dict],
|
||||
fp_normal_local: list,
|
||||
hint: Optional[list],
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Return the world-space centroid of the face group whose element-local normal
|
||||
best matches *fp_normal_local*. Matching in local space is rotation-invariant —
|
||||
moving or rotating the element does not change local normals, so the anchor
|
||||
correctly tracks the same face through placement changes and profile edits."""
|
||||
best_score = -1.0
|
||||
best_centroid = None
|
||||
|
||||
for i, lp in enumerate(local_group_props):
|
||||
dot_val = _dot(lp["normal"], fp_normal_local)
|
||||
if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
|
||||
continue
|
||||
score = dot_val
|
||||
if hint:
|
||||
hint_dist = _dist(world_group_props[i]["centroid"], hint)
|
||||
score -= hint_dist / max(_CENTROID_MAX_DIST, 0.001) * 0.1
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_centroid = world_group_props[i]["centroid"]
|
||||
|
||||
return best_centroid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM
|
||||
# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM / SIDE_*
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -522,9 +597,16 @@ def _resolve_extruded_area_solid_analytic(
|
||||
addr: dict,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Analytically resolve TOP or BOTTOM face centre of an IfcExtrudedAreaSolid."""
|
||||
"""Analytically resolve a face centre of an IfcExtrudedAreaSolid.
|
||||
|
||||
Handles TOP, BOTTOM, and SIDE_PLUS_X / SIDE_MINUS_X / SIDE_PLUS_Y / SIDE_MINUS_Y
|
||||
roles. Side-face roles are only supported for IfcRectangleProfileDef; other
|
||||
profile types fall back to tessellation fingerprint matching.
|
||||
"""
|
||||
face_role = addr.get("face_role", "")
|
||||
if face_role not in ("TOP", "BOTTOM"):
|
||||
_top_bottom = ("TOP", "BOTTOM")
|
||||
_sides = ("SIDE_PLUS_X", "SIDE_MINUS_X", "SIDE_PLUS_Y", "SIDE_MINUS_Y")
|
||||
if face_role not in _top_bottom + _sides:
|
||||
return None
|
||||
|
||||
repr_id = addr.get("repr_id")
|
||||
@@ -540,18 +622,64 @@ def _resolve_extruded_area_solid_analytic(
|
||||
return None
|
||||
|
||||
try:
|
||||
profile_centroid_local = _profile_centroid(solid.SweptArea)
|
||||
profile = solid.SweptArea
|
||||
dir_ratios = solid.ExtrudedDirection.DirectionRatios
|
||||
depth = solid.Depth
|
||||
depth = float(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 face_role in _top_bottom:
|
||||
profile_centroid_local = _profile_centroid(profile)
|
||||
scale = depth if face_role == "TOP" else 0.0
|
||||
px = profile_centroid_local[0] + dir_vec[0] * scale
|
||||
py = profile_centroid_local[1] + dir_vec[1] * scale
|
||||
pz = dir_vec[2] * scale
|
||||
|
||||
else: # SIDE_* — only for IfcRectangleProfileDef
|
||||
if not profile.is_a("IfcRectangleProfileDef"):
|
||||
return None
|
||||
|
||||
x_dim = float(profile.XDim)
|
||||
y_dim = float(profile.YDim)
|
||||
half_depth = depth / 2.0
|
||||
|
||||
# Profile centre and local axes (from profile.Position 2D placement).
|
||||
cx, cy = 0.0, 0.0
|
||||
px_axis = (1.0, 0.0) # profile X in profile 2D
|
||||
if hasattr(profile, "Position") and profile.Position:
|
||||
loc = profile.Position.Location
|
||||
cx = float(loc.Coordinates[0])
|
||||
cy = float(loc.Coordinates[1])
|
||||
if profile.Position.RefDirection:
|
||||
pr = profile.Position.RefDirection.DirectionRatios
|
||||
pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2)
|
||||
if pm > 1e-12:
|
||||
px_axis = (pr[0] / pm, pr[1] / pm)
|
||||
py_axis = (-px_axis[1], px_axis[0]) # 90° rotation
|
||||
|
||||
half_x = x_dim / 2.0
|
||||
half_y = y_dim / 2.0
|
||||
|
||||
if face_role == "SIDE_PLUS_X":
|
||||
fx = cx + half_x * px_axis[0]
|
||||
fy = cy + half_x * px_axis[1]
|
||||
elif face_role == "SIDE_MINUS_X":
|
||||
fx = cx - half_x * px_axis[0]
|
||||
fy = cy - half_x * px_axis[1]
|
||||
elif face_role == "SIDE_PLUS_Y":
|
||||
fx = cx + half_y * py_axis[0]
|
||||
fy = cy + half_y * py_axis[1]
|
||||
else: # SIDE_MINUS_Y
|
||||
fx = cx - half_y * py_axis[0]
|
||||
fy = cy - half_y * py_axis[1]
|
||||
|
||||
# Lift from profile 2D to solid-local 3D at mid-extrusion depth.
|
||||
px = fx + dir_vec[0] * half_depth
|
||||
py = fy + dir_vec[1] * half_depth
|
||||
pz = dir_vec[2] * half_depth
|
||||
|
||||
if solid.Position:
|
||||
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
|
||||
@@ -638,21 +766,27 @@ def _detect_extruded_face(
|
||||
element: ifcopenshell.entity_instance,
|
||||
hit_location: tuple,
|
||||
hit_normal: tuple,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> tuple[str, int, str]:
|
||||
"""Try to identify if the hit face is a TOP or BOTTOM of an IfcExtrudedAreaSolid.
|
||||
"""Identify if the hit face is a face of an IfcExtrudedAreaSolid.
|
||||
|
||||
Returns (repr_type, repr_id, face_role).
|
||||
repr_type is empty string if not detected as extruded solid.
|
||||
face_role is one of: 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X',
|
||||
'SIDE_PLUS_Y', 'SIDE_MINUS_Y', or '' (not recognized).
|
||||
Side roles are only returned for IfcRectangleProfileDef.
|
||||
"""
|
||||
if not hasattr(element, "Representation") or not element.Representation:
|
||||
return ("", -1, "")
|
||||
|
||||
# Transform hit_normal from world → element-local for accurate role classification.
|
||||
hit_normal_elem = _world_normal_to_elem_local(file, element, hit_normal, placement_override)
|
||||
|
||||
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)
|
||||
role = _extruded_face_role(solid, hit_normal_elem)
|
||||
if role:
|
||||
return ("IfcExtrudedAreaSolid", solid.id(), role)
|
||||
|
||||
@@ -667,19 +801,99 @@ def _unwrap_mapped(item):
|
||||
return item
|
||||
|
||||
|
||||
def _extruded_face_role(solid, hit_normal: tuple) -> str:
|
||||
"""Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion."""
|
||||
def _apply_axis2placement3d_rotation_inv(placement, vec: tuple) -> tuple[float, float, float]:
|
||||
"""Apply the inverse rotation of an IfcAxis2Placement3D to a direction.
|
||||
|
||||
Transforms a direction from element-local space into solid-local space.
|
||||
The rotation matrix R = [x_axis | y_axis | z_axis]; its inverse for an
|
||||
orthogonal matrix is R^T, computed here by dotting with each basis vector.
|
||||
"""
|
||||
if placement is None:
|
||||
return vec
|
||||
|
||||
x, y, z = float(vec[0]), float(vec[1]), float(vec[2])
|
||||
|
||||
if placement.Axis:
|
||||
zr = placement.Axis.DirectionRatios
|
||||
zm = math.sqrt(zr[0] ** 2 + zr[1] ** 2 + zr[2] ** 2)
|
||||
zx, zy, zz = (zr[0] / zm, zr[1] / zm, zr[2] / zm) if zm > 1e-12 else (0.0, 0.0, 1.0)
|
||||
else:
|
||||
zx, zy, zz = 0.0, 0.0, 1.0
|
||||
|
||||
if placement.RefDirection:
|
||||
xr = placement.RefDirection.DirectionRatios
|
||||
xm = math.sqrt(xr[0] ** 2 + xr[1] ** 2 + xr[2] ** 2)
|
||||
xx, xy, xz = (xr[0] / xm, xr[1] / xm, xr[2] / xm) if xm > 1e-12 else (1.0, 0.0, 0.0)
|
||||
else:
|
||||
xx, xy, xz = 1.0, 0.0, 0.0
|
||||
|
||||
# Y = Z × X
|
||||
yx = zy * xz - zz * xy
|
||||
yy = zz * xx - zx * xz
|
||||
yz = zx * xy - zy * xx
|
||||
|
||||
# R^T: dot input with each column of R (= each basis axis of the placement).
|
||||
inv_x = xx * x + xy * y + xz * z
|
||||
inv_y = yx * x + yy * y + yz * z
|
||||
inv_z = zx * x + zy * y + zz * z
|
||||
|
||||
mag = math.sqrt(inv_x ** 2 + inv_y ** 2 + inv_z ** 2)
|
||||
if mag > 1e-12:
|
||||
return (inv_x / mag, inv_y / mag, inv_z / mag)
|
||||
return vec
|
||||
|
||||
|
||||
def _extruded_face_role(solid, hit_normal_elem_local: tuple) -> str:
|
||||
"""Classify the hit face role on an IfcExtrudedAreaSolid.
|
||||
|
||||
Returns 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X', 'SIDE_PLUS_Y',
|
||||
'SIDE_MINUS_Y', or ''. Side roles require IfcRectangleProfileDef.
|
||||
|
||||
:param hit_normal_elem_local: Face normal in element-local space.
|
||||
"""
|
||||
try:
|
||||
# Map from element-local to solid-local via solid.Position inverse rotation.
|
||||
hit_normal_solid = _apply_axis2placement3d_rotation_inv(solid.Position, hit_normal_elem_local)
|
||||
|
||||
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:
|
||||
|
||||
dot_extrude = _dot(extrude_dir, hit_normal_solid)
|
||||
if dot_extrude > 0.99:
|
||||
return "TOP"
|
||||
if dot_val < -0.99:
|
||||
if dot_extrude < -0.99:
|
||||
return "BOTTOM"
|
||||
|
||||
# Side face detection — only supported for IfcRectangleProfileDef.
|
||||
if not solid.SweptArea.is_a("IfcRectangleProfileDef"):
|
||||
return ""
|
||||
|
||||
profile = solid.SweptArea
|
||||
|
||||
# Profile X axis in solid-local 2D (from profile.Position.RefDirection).
|
||||
px_axis = (1.0, 0.0)
|
||||
if hasattr(profile, "Position") and profile.Position and profile.Position.RefDirection:
|
||||
pr = profile.Position.RefDirection.DirectionRatios
|
||||
pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2)
|
||||
if pm > 1e-12:
|
||||
px_axis = (pr[0] / pm, pr[1] / pm)
|
||||
py_axis = (-px_axis[1], px_axis[0]) # 90° CCW
|
||||
|
||||
# Lift 2D profile axes to solid-local 3D (profile is in the solid XY plane).
|
||||
px_3d = (px_axis[0], px_axis[1], 0.0)
|
||||
py_3d = (py_axis[0], py_axis[1], 0.0)
|
||||
|
||||
dot_x = _dot(hit_normal_solid, px_3d)
|
||||
dot_y = _dot(hit_normal_solid, py_3d)
|
||||
|
||||
if abs(dot_x) > 0.99:
|
||||
return "SIDE_PLUS_X" if dot_x > 0 else "SIDE_MINUS_X"
|
||||
if abs(dot_y) > 0.99:
|
||||
return "SIDE_PLUS_Y" if dot_y > 0 else "SIDE_MINUS_Y"
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user