2026-05-15 20:49:10 -05:00
|
|
|
|
# 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/>.
|
|
|
|
|
|
|
2026-05-16 15:11:05 -05:00
|
|
|
|
"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors.
|
2026-05-15 20:49:10 -05:00
|
|
|
|
|
|
|
|
|
|
This module operates purely on IFC data. It:
|
2026-05-16 15:11:05 -05:00
|
|
|
|
1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an
|
2026-05-15 20:49:10 -05:00
|
|
|
|
``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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 15:11:05 -05:00
|
|
|
|
_PSET_NAME = "BBIM_Dimension"
|
2026-05-15 20:49:10 -05:00
|
|
|
|
_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,
|
2026-05-15 20:49:10 -05:00
|
|
|
|
camera_dir: Optional[tuple[float, float, float]] = None,
|
2026-05-15 20:49:10 -05:00
|
|
|
|
) -> list[tuple[float, float, float]]:
|
|
|
|
|
|
"""Regenerate a parametric dimension from its stored anchor references.
|
|
|
|
|
|
|
2026-05-16 15:11:05 -05:00
|
|
|
|
Resolves every anchor in ``BBIM_Dimension.Anchors``, updates the
|
2026-05-15 20:49:10 -05:00
|
|
|
|
per-segment ``IfcMetric`` values (creating them when absent), and returns
|
|
|
|
|
|
the resolved world-space points in metres.
|
|
|
|
|
|
|
|
|
|
|
|
:param file: The open IFC file.
|
2026-05-16 15:11:05 -05:00
|
|
|
|
:param annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` pset.
|
2026-05-15 20:49:10 -05:00
|
|
|
|
: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)
|
|
|
|
|
|
|
2026-05-16 15:11:05 -05:00
|
|
|
|
# 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])
|
|
|
|
|
|
|
2026-08-15 11:55:32 -05:00
|
|
|
|
# ForceParallelToFace: project vertices 1…n onto the line through
|
|
|
|
|
|
# pt[0] in the direction cross(face_normal, camera_dir), so the polyline
|
|
|
|
|
|
# runs parallel to the face (perpendicular to the face normal).
|
|
|
|
|
|
if pset_data.get("ForceParallelToFace") and len(resolved) >= 2 and resolved[0] is not None:
|
|
|
|
|
|
face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
|
|
|
|
|
if face_normal and camera_dir:
|
|
|
|
|
|
fn, cd = face_normal, camera_dir
|
|
|
|
|
|
tang = (
|
|
|
|
|
|
fn[1] * cd[2] - fn[2] * cd[1],
|
|
|
|
|
|
fn[2] * cd[0] - fn[0] * cd[2],
|
|
|
|
|
|
fn[0] * cd[1] - fn[1] * cd[0],
|
|
|
|
|
|
)
|
|
|
|
|
|
mag = math.sqrt(tang[0] ** 2 + tang[1] ** 2 + tang[2] ** 2)
|
|
|
|
|
|
if mag > 1e-12:
|
|
|
|
|
|
tang = (tang[0] / mag, tang[1] / mag, tang[2] / mag)
|
|
|
|
|
|
base = resolved[0]
|
|
|
|
|
|
for i in range(1, len(resolved)):
|
|
|
|
|
|
if resolved[i] is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
pt = resolved[i]
|
|
|
|
|
|
t = ((pt[0] - base[0]) * tang[0]
|
|
|
|
|
|
+ (pt[1] - base[1]) * tang[1]
|
|
|
|
|
|
+ (pt[2] - base[2]) * tang[2])
|
|
|
|
|
|
resolved[i] = (base[0] + t * tang[0],
|
|
|
|
|
|
base[1] + t * tang[1],
|
|
|
|
|
|
base[2] + t * tang[2])
|
|
|
|
|
|
anchors[i]["pt"] = list(resolved[i])
|
|
|
|
|
|
|
2026-05-15 20:49:10 -05:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-16 19:16:02 -05:00
|
|
|
|
# LinePosition: project all points to a fixed absolute world coordinate along the
|
|
|
|
|
|
# horizontal offset axis (perpendicular to the dimension direction). Applied after
|
|
|
|
|
|
# the pset write so anchor["pt"] always stores the true geometry surface hit.
|
|
|
|
|
|
# Because it is absolute, the dimension line stays put even if the geometry moves.
|
|
|
|
|
|
line_position = pset_data.get("LinePosition")
|
2026-05-15 20:49:10 -05:00
|
|
|
|
if line_position is not None and resolved:
|
2026-05-16 19:16:02 -05:00
|
|
|
|
face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
2026-05-15 20:49:10 -05:00
|
|
|
|
offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None], camera_dir)
|
2026-05-16 19:16:02 -05:00
|
|
|
|
if offset_dir:
|
|
|
|
|
|
resolved = [
|
|
|
|
|
|
_project_to_line_position(pt, offset_dir, float(line_position)) if pt is not None else None
|
|
|
|
|
|
for pt in resolved
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-15 20:49:10 -05:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-05-16 15:11:05 -05:00
|
|
|
|
Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors``
|
2026-05-15 20:49:10 -05:00
|
|
|
|
(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)
|
2026-05-16 15:11:05 -05:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 19:16:02 -05:00
|
|
|
|
def _project_to_line_position(
|
|
|
|
|
|
pt: tuple, offset_dir: tuple, target: float
|
|
|
|
|
|
) -> tuple[float, float, float]:
|
|
|
|
|
|
"""Shift *pt* along *offset_dir* so its projection onto that axis equals *target*.
|
|
|
|
|
|
|
|
|
|
|
|
Keeps every other component of the point unchanged, so only the dimension line
|
|
|
|
|
|
is repositioned — the measured length stays the same.
|
|
|
|
|
|
"""
|
|
|
|
|
|
current = pt[0] * offset_dir[0] + pt[1] * offset_dir[1] + pt[2] * offset_dir[2]
|
|
|
|
|
|
delta = target - current
|
|
|
|
|
|
return (
|
|
|
|
|
|
pt[0] + delta * offset_dir[0],
|
|
|
|
|
|
pt[1] + delta * offset_dir[1],
|
|
|
|
|
|
pt[2] + delta * offset_dir[2],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 15:11:05 -05:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-05-18 06:36:00 -05:00
|
|
|
|
Reads ``normal_local`` (element-local, rotation-invariant) from the anchor
|
|
|
|
|
|
addr and rotates it to world space via the current element placement.
|
|
|
|
|
|
Also accepts the legacy ``addr.fingerprint.normal_local`` format.
|
2026-05-16 15:11:05 -05:00
|
|
|
|
"""
|
|
|
|
|
|
if anchor.get("type") != "FACE":
|
|
|
|
|
|
return None
|
|
|
|
|
|
guid = anchor.get("guid")
|
|
|
|
|
|
if not guid:
|
|
|
|
|
|
return None
|
2026-05-18 06:36:00 -05:00
|
|
|
|
try:
|
|
|
|
|
|
element = file.by_guid(guid)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
2026-05-16 15:11:05 -05:00
|
|
|
|
|
2026-05-18 06:36:00 -05:00
|
|
|
|
addr = anchor.get("addr") or {}
|
|
|
|
|
|
from .resolve_anchor import _rotate_local_to_world
|
2026-05-16 15:11:05 -05:00
|
|
|
|
|
2026-05-18 06:36:00 -05:00
|
|
|
|
if addr.get("method") == "LAYER_BOUNDARY":
|
|
|
|
|
|
import ifcopenshell.util.element as _ifc_elem
|
|
|
|
|
|
usage = _ifc_elem.get_material(element, should_inherit=True)
|
|
|
|
|
|
if not usage or not usage.is_a("IfcMaterialLayerSetUsage"):
|
|
|
|
|
|
return None
|
|
|
|
|
|
axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2")
|
|
|
|
|
|
if axis == "AXIS1":
|
|
|
|
|
|
normal_local: tuple = (1.0, 0.0, 0.0)
|
|
|
|
|
|
elif axis == "AXIS3":
|
|
|
|
|
|
normal_local = (0.0, 0.0, 1.0)
|
|
|
|
|
|
else:
|
|
|
|
|
|
normal_local = (0.0, 1.0, 0.0)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# FACE_NORMAL: normal_local stored in addr (new) or addr.fingerprint (legacy).
|
|
|
|
|
|
normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local")
|
|
|
|
|
|
if not normal_local:
|
|
|
|
|
|
return None
|
2026-05-16 15:11:05 -05:00
|
|
|
|
|
2026-05-18 06:36:00 -05:00
|
|
|
|
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
|
2026-05-16 19:16:02 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_line_offset_direction(
|
|
|
|
|
|
face_normal: Optional[tuple[float, float, float]],
|
|
|
|
|
|
resolved_pts: list[tuple],
|
2026-05-15 20:49:10 -05:00
|
|
|
|
camera_dir: Optional[tuple[float, float, float]] = None,
|
2026-05-16 19:16:02 -05:00
|
|
|
|
) -> Optional[tuple[float, float, float]]:
|
2026-05-15 20:49:10 -05:00
|
|
|
|
"""Return the direction to slide the dimension line (perpendicular to it, in-view).
|
2026-05-16 19:16:02 -05:00
|
|
|
|
|
2026-05-15 20:49:10 -05:00
|
|
|
|
For plan views (camera mostly vertical) uses cross(world_Z, dim_dir) —
|
|
|
|
|
|
unchanged from the original behaviour, so existing stored LinePosition
|
|
|
|
|
|
values continue to work.
|
|
|
|
|
|
|
|
|
|
|
|
For section/elevation views (camera mostly horizontal) uses
|
|
|
|
|
|
cross(camera_dir, dim_dir) so the offset lies in the camera's view plane.
|
|
|
|
|
|
This makes dragging the gizmo move the line visually up/down (or
|
|
|
|
|
|
left/right) rather than in/out of the screen.
|
2026-05-16 19:16:02 -05:00
|
|
|
|
|
|
|
|
|
|
Falls back to cross(face_normal, world_Z) when the dimension line is
|
2026-05-15 20:49:10 -05:00
|
|
|
|
nearly parallel to the reference vector (e.g. vertical elevation dims).
|
2026-05-16 19:16:02 -05:00
|
|
|
|
"""
|
|
|
|
|
|
world_z = (0.0, 0.0, 1.0)
|
|
|
|
|
|
|
2026-05-15 20:49:10 -05:00
|
|
|
|
# In section/elevation (camera mostly horizontal) use camera_dir as the
|
|
|
|
|
|
# reference so the offset axis lies in the view plane.
|
|
|
|
|
|
cam_is_plan = camera_dir is None or abs(camera_dir[2]) > 0.7
|
|
|
|
|
|
ref = world_z if cam_is_plan else camera_dir
|
|
|
|
|
|
|
|
|
|
|
|
# Primary: cross(ref, dim_dir)
|
2026-05-16 19:16:02 -05:00
|
|
|
|
if len(resolved_pts) >= 2:
|
|
|
|
|
|
a, b = resolved_pts[0], resolved_pts[1]
|
|
|
|
|
|
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
|
|
|
|
|
|
dim_mag = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
|
|
|
|
if dim_mag > 1e-10:
|
|
|
|
|
|
dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag)
|
|
|
|
|
|
d = (
|
2026-05-15 20:49:10 -05:00
|
|
|
|
ref[1] * dim_dir[2] - ref[2] * dim_dir[1],
|
|
|
|
|
|
ref[2] * dim_dir[0] - ref[0] * dim_dir[2],
|
|
|
|
|
|
ref[0] * dim_dir[1] - ref[1] * dim_dir[0],
|
2026-05-16 19:16:02 -05:00
|
|
|
|
)
|
|
|
|
|
|
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
|
|
|
|
|
|
if mag > 1e-6:
|
|
|
|
|
|
return (d[0] / mag, d[1] / mag, d[2] / mag)
|
|
|
|
|
|
|
2026-05-15 20:49:10 -05:00
|
|
|
|
# Fallback for dims parallel to ref (e.g. vertical dims in plan):
|
|
|
|
|
|
# cross(face_normal, world_Z)
|
2026-05-16 19:16:02 -05:00
|
|
|
|
if face_normal:
|
|
|
|
|
|
n = face_normal
|
|
|
|
|
|
d = (
|
|
|
|
|
|
n[1] * world_z[2] - n[2] * world_z[1],
|
|
|
|
|
|
n[2] * world_z[0] - n[0] * world_z[2],
|
|
|
|
|
|
n[0] * world_z[1] - n[1] * world_z[0],
|
|
|
|
|
|
)
|
|
|
|
|
|
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
|
|
|
|
|
|
if mag > 1e-6:
|
|
|
|
|
|
return (d[0] / mag, d[1] / mag, d[2] / mag)
|
|
|
|
|
|
|
|
|
|
|
|
return None
|