mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Add core/model.py constants + core/product.py helpers
core/model.py gains: * Three calibrated dot-product / distance thresholds — PARALLEL_DOT_THRESHOLD (~2° from parallel, cos(2°) ≈ 0.9994), COLLINEAR_LINE_TOLERANCE (50mm perpendicular distance for two parallel wall axes to share a line), BASELINE_OFFSET_TOLERANCE — replacing inline magic numbers that the wall-join classifier, fillet-state machine, and gizmo preview decorator all read from. * Pure wall-join geometry helpers (project_axis_intersection, are_axes_collinear, classify_wall_join_state, wall_join_preview_lines, resolve_extend_walls_target, extrusion_depth_from_vertical_height, length_and_height_from_extrusion). They take primitive tuples + floats, no bpy, no ifcopenshell — testable in the core lane. core/product.py is new — pure-Python aggregate-walk helpers (resolve_host_ of_product, collect_decomposed_products) that downstream tool/spatial and tool/aggregate consumers can call without importing ifcopenshell at module load. Generated with the assistance of an AI coding tool.
This commit is contained in:
+346
-31
@@ -21,7 +21,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
@@ -34,6 +34,24 @@ if TYPE_CHECKING:
|
||||
OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"]
|
||||
|
||||
|
||||
# Arc sample count for fillet preview polylines. 24 samples produces a visually
|
||||
# smooth arc at common viewport scales without bloating the GPU batch.
|
||||
FILLET_DEFAULT_ARC_RESOLUTION = 24
|
||||
# Dot-product floor for treating two wall-axis segments as parallel — below
|
||||
# this the projected intersection is too sensitive to floating-point noise
|
||||
# to be useful as a junction apex. Calibrated to ~2° from parallel.
|
||||
PARALLEL_DOT_THRESHOLD = 0.9994
|
||||
# Perpendicular distance (SI metres) under which two parallel wall axes are
|
||||
# considered to share the same infinite line. Calibrated to absorb sub-50mm
|
||||
# placement drift between authored-joined walls without merging genuinely
|
||||
# offset parallel walls.
|
||||
COLLINEAR_LINE_TOLERANCE = 0.05
|
||||
# Default proximity (SI metres) for classifying a layer offset against the
|
||||
# canonical EXTERIOR / CENTER / INTERIOR baselines. Tight enough that ordinary
|
||||
# millimetre-scale modelling intent always falls into the nearest baseline.
|
||||
BASELINE_OFFSET_TOLERANCE = 0.001
|
||||
|
||||
|
||||
def unjoin_walls(
|
||||
ifc: type[tool.Ifc],
|
||||
blender: type[tool.Blender],
|
||||
@@ -179,16 +197,16 @@ class RequireLayeredElement(Exception):
|
||||
|
||||
|
||||
# --- Wall geometry math (pure) ------------------------------------------------
|
||||
# Tuple in / tuple out so these helpers run under ``pytest test/core/`` without
|
||||
# ``bpy`` or ``mathutils``. Callers convert ``mathutils.Vector`` at the boundary.
|
||||
# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``.
|
||||
# Callers convert ``mathutils.Vector`` at the boundary.
|
||||
|
||||
|
||||
def baseline_from_offset(offset: float, thickness: float, tolerance: float = 0.001) -> str:
|
||||
def baseline_from_offset(offset: float, thickness: float, tolerance: float = BASELINE_OFFSET_TOLERANCE) -> str:
|
||||
"""Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
|
||||
|
||||
Mirrors the math in ``tool.Model.offset_wall`` for both POSITIVE and NEGATIVE
|
||||
direction_sense walls. Returns the closest canonical baseline; falls back to
|
||||
``"CENTER"`` when nothing is within ``tolerance``."""
|
||||
Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the
|
||||
closest canonical baseline; falls back to ``"CENTER"`` when nothing is
|
||||
within ``tolerance``."""
|
||||
candidates = (
|
||||
("EXTERIOR", 0.0),
|
||||
("CENTER", -thickness / 2),
|
||||
@@ -211,7 +229,7 @@ def project_axis_intersection(
|
||||
Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple
|
||||
(Z is the average of the four input Zs, for visual placement) or ``None`` if
|
||||
the segments are parallel within ``parallel_threshold`` (a dot-product magnitude
|
||||
threshold — e.g. ``cos(2°) ≈ 0.9994`` treats walls within 2° of parallel as parallel)."""
|
||||
threshold — see ``PARALLEL_DOT_THRESHOLD`` for the calibrated value)."""
|
||||
p1, p2 = seg_a
|
||||
p3, p4 = seg_b
|
||||
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
|
||||
@@ -233,21 +251,100 @@ def project_axis_intersection(
|
||||
return (ix, iy, iz)
|
||||
|
||||
|
||||
def displacement_from_x_angle(height: float, x_angle: float) -> float:
|
||||
"""Top-edge horizontal displacement for a wall of given vertical ``height`` and
|
||||
slope ``x_angle`` (radians). Drives the slope dimension gizmo's display value.
|
||||
def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool:
|
||||
"""True when the opening's near edge sits past the cut on the t axis.
|
||||
|
||||
Inverse of :func:`x_angle_from_displacement`."""
|
||||
Strict inequality is load-bearing: a boundary touch or NaN keeps the
|
||||
opening on both walls — the safe default when extent resolution fails."""
|
||||
return min_t > cut_percentage
|
||||
|
||||
|
||||
def opening_is_before_cut(max_t: float, cut_percentage: float) -> bool:
|
||||
"""True when the opening's far edge sits before the cut on the t axis."""
|
||||
return max_t < cut_percentage
|
||||
|
||||
|
||||
def opening_straddles_cut(min_t: float, max_t: float, cut_percentage: float) -> bool:
|
||||
"""True when the opening's extent crosses the cut on the t axis."""
|
||||
return min_t < cut_percentage < max_t
|
||||
|
||||
|
||||
WallJoinState = Literal["joined", "collinear", "intersect", "none"]
|
||||
|
||||
|
||||
def classify_wall_join_state(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
are_joined: bool,
|
||||
parallel_threshold: float,
|
||||
collinear_tolerance: float,
|
||||
) -> tuple[WallJoinState, Optional[tuple[float, float, float]]]:
|
||||
"""Classify a wall pair's geometric state — ``(state, intersection)``.
|
||||
|
||||
Priority: ``"joined"`` (caller-supplied flag) → ``"collinear"`` →
|
||||
``"intersect"`` (projected point returned) → ``"none"`` (parallel,
|
||||
non-collinear)."""
|
||||
if are_joined:
|
||||
return "joined", None
|
||||
if are_axes_collinear(seg_a, seg_b, parallel_threshold, collinear_tolerance):
|
||||
return "collinear", None
|
||||
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
|
||||
if intersection is None:
|
||||
return "none", None
|
||||
return "intersect", intersection
|
||||
|
||||
|
||||
def wall_join_preview_lines(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
intersection: tuple[float, float, float],
|
||||
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
|
||||
"""Two segments showing each wall axis extending to ``intersection``.
|
||||
|
||||
Each segment runs from the input axis's nearest endpoint to the
|
||||
intersection, held at that wall's own Z. Returned in input order
|
||||
``[floor_a, floor_b]``."""
|
||||
ix, iy, _ = intersection
|
||||
|
||||
def _nearest(seg: tuple[tuple[float, float, float], tuple[float, float, float]]) -> tuple[float, float, float]:
|
||||
return min(seg, key=lambda p: (p[0] - ix) ** 2 + (p[1] - iy) ** 2)
|
||||
|
||||
near_a = _nearest(seg_a)
|
||||
near_b = _nearest(seg_b)
|
||||
return [
|
||||
(near_a, (ix, iy, near_a[2])),
|
||||
(near_b, (ix, iy, near_b[2])),
|
||||
]
|
||||
|
||||
|
||||
def resolve_extend_walls_target(
|
||||
target_obj: Any,
|
||||
objs: list[Any],
|
||||
reverse: bool,
|
||||
) -> tuple[Any, list[Any]]:
|
||||
"""Pick which object is the extend-target and which are extended.
|
||||
|
||||
Default direction: ``objs`` are extended to meet ``target_obj``.
|
||||
Reversed direction (``reverse=True``) swaps the pair — equivalent to
|
||||
having passed them in the opposite order. The swap is well-defined only
|
||||
for the 1+1 case (one target + one other); for ``n>1`` it would be
|
||||
ambiguous, so the default direction is preserved instead."""
|
||||
if reverse and target_obj is not None and len(objs) == 1:
|
||||
return objs[0], [target_obj]
|
||||
return target_obj, objs
|
||||
|
||||
|
||||
def displacement_from_x_angle(height: float, x_angle: float) -> float:
|
||||
"""Top-edge horizontal displacement for a wall of given vertical ``height``
|
||||
and slope ``x_angle`` (radians). Inverse of ``x_angle_from_displacement``."""
|
||||
return height * math.tan(x_angle)
|
||||
|
||||
|
||||
def x_angle_from_displacement(height: float, displacement: float) -> float:
|
||||
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
|
||||
|
||||
``height`` is clamped to ``max(height, 1e-6)`` so vertical walls of effectively
|
||||
zero height map cleanly to ``±π/2`` via ``atan2`` rather than dividing by zero.
|
||||
|
||||
Inverse of :func:`displacement_from_x_angle`."""
|
||||
``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map
|
||||
cleanly to ``±π/2`` instead of dividing by zero."""
|
||||
return math.atan2(displacement, max(height, 1e-6))
|
||||
|
||||
|
||||
@@ -260,22 +357,38 @@ def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float)
|
||||
return extrusion_depth * abs(math.cos(x_angle))
|
||||
|
||||
|
||||
def extrusion_depth_from_vertical_height(vertical_height: float, x_angle: float) -> float:
|
||||
"""``vertical_height / cos(x_angle)`` with ``cos`` clamped at ``1e-6`` to
|
||||
stay finite near ``±π/2``."""
|
||||
return vertical_height / max(abs(math.cos(x_angle)), 1e-6)
|
||||
|
||||
|
||||
def length_and_height_from_extrusion(
|
||||
extrusion_depth: float,
|
||||
x_angle: float,
|
||||
reference_line_x_extent: float,
|
||||
unit_scale: float,
|
||||
) -> tuple[float, float]:
|
||||
"""SI ``(length, vertical_height)`` of a LAYER2 wall.
|
||||
|
||||
Height is the *vertical* projection of the slanted depth, not the
|
||||
slanted depth itself."""
|
||||
length = reference_line_x_extent * unit_scale
|
||||
height = vertical_height_from_extrusion_depth(extrusion_depth * unit_scale, x_angle)
|
||||
return length, height
|
||||
|
||||
|
||||
def are_axes_collinear(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
parallel_threshold: float = 0.9994,
|
||||
line_tolerance: float = 0.05,
|
||||
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
|
||||
line_tolerance: float = COLLINEAR_LINE_TOLERANCE,
|
||||
) -> bool:
|
||||
"""True if both axis segments lie on the same infinite line in plan.
|
||||
|
||||
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``
|
||||
(``cos(2°) ≈ 0.9994``), AND any endpoint of B must lie on A's infinite line
|
||||
within ``line_tolerance``. Plan-only (Z ignored) — two parallel walls at
|
||||
different elevations are still considered collinear because the merge operator
|
||||
handles Z resolution itself.
|
||||
|
||||
Used by the wall-join gizmo's state machine: collinear pair → Merge icon at the
|
||||
boundary, perpendicular pair → Join icon at the intersection."""
|
||||
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``,
|
||||
AND any endpoint of B must lie on A's infinite line within ``line_tolerance``.
|
||||
Plan-only (Z ignored)."""
|
||||
d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1]
|
||||
d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1]
|
||||
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
|
||||
@@ -300,11 +413,7 @@ def closest_endpoint_midpoint(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
) -> tuple[float, float, float]:
|
||||
"""Midpoint of the closest pair of endpoints between two segments.
|
||||
|
||||
For walls that meet end-to-end this is the shared corner; for walls with a
|
||||
small gap it's the midpoint of the gap. Either way it's the user-meaningful
|
||||
"boundary" where a merge would graft the two segments together."""
|
||||
"""Midpoint of the closest endpoint pair between two segments."""
|
||||
endpoints_a = (seg_a[0], seg_a[1])
|
||||
endpoints_b = (seg_b[0], seg_b[1])
|
||||
|
||||
@@ -314,3 +423,209 @@ def closest_endpoint_midpoint(
|
||||
closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair))
|
||||
a, b = closest_pair
|
||||
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
|
||||
|
||||
|
||||
def compute_path_connection_location(
|
||||
seg_self: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
self_conn_type: str,
|
||||
seg_other: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
other_conn_type: str,
|
||||
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
|
||||
) -> tuple[float, float, float]:
|
||||
"""World-space location of a single ``IfcRelConnectsPathElements`` between
|
||||
two wall axes.
|
||||
|
||||
Priority: ``self``'s ATSTART/ATEND endpoint → ``other``'s ATSTART/ATEND
|
||||
endpoint → axis intersection → closest-endpoint midpoint fallback."""
|
||||
if self_conn_type == "ATSTART":
|
||||
return seg_self[0]
|
||||
if self_conn_type == "ATEND":
|
||||
return seg_self[1]
|
||||
if other_conn_type == "ATSTART":
|
||||
return seg_other[0]
|
||||
if other_conn_type == "ATEND":
|
||||
return seg_other[1]
|
||||
intersection = project_axis_intersection(seg_self, seg_other, parallel_threshold)
|
||||
if intersection is not None:
|
||||
return intersection
|
||||
return closest_endpoint_midpoint(seg_self, seg_other)
|
||||
|
||||
|
||||
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
||||
|
||||
|
||||
def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
|
||||
|
||||
def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def _vec_length(v: tuple[float, float, float]) -> float:
|
||||
return (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) ** 0.5
|
||||
|
||||
|
||||
def _rotate_around_axis(
|
||||
v: tuple[float, float, float],
|
||||
axis: tuple[float, float, float],
|
||||
angle: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Rotate ``v`` around unit-length ``axis`` by ``angle`` radians."""
|
||||
cos_a = math.cos(angle)
|
||||
sin_a = math.sin(angle)
|
||||
dot = _vec_dot(axis, v)
|
||||
cross = _vec_cross(axis, v)
|
||||
k = 1.0 - cos_a
|
||||
return (
|
||||
v[0] * cos_a + cross[0] * sin_a + axis[0] * dot * k,
|
||||
v[1] * cos_a + cross[1] * sin_a + axis[1] * dot * k,
|
||||
v[2] * cos_a + cross[2] * sin_a + axis[2] * dot * k,
|
||||
)
|
||||
|
||||
|
||||
def compute_fillet_polylines(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
radius: float,
|
||||
arc_resolution: int = FILLET_DEFAULT_ARC_RESOLUTION,
|
||||
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
|
||||
) -> dict:
|
||||
"""Preview polylines for a circular fillet at the junction of two axes.
|
||||
|
||||
Returns a dict with ``valid``, ``reason``, ``intersection``, ``tangent_a``
|
||||
/ ``tangent_b``, ``arc`` (``arc_resolution + 1`` samples), ``arc_center``,
|
||||
``arc_radius``, ``sweep_angle``, ``sweep_axis``, ``tangent_offset``,
|
||||
``wall_a_join_side`` / ``wall_b_join_side`` (ATSTART/ATEND/None),
|
||||
``invalid_radius`` (tangent overshoots — arc + tangents still populated
|
||||
for warning rendering), and ``invalid_axes`` (set on parallel)."""
|
||||
blank: dict = {
|
||||
"valid": False,
|
||||
"reason": None,
|
||||
"intersection": None,
|
||||
"tangent_a": None,
|
||||
"tangent_b": None,
|
||||
"arc": [],
|
||||
"arc_center": None,
|
||||
"arc_radius": radius,
|
||||
"sweep_angle": 0.0,
|
||||
"sweep_axis": None,
|
||||
"tangent_offset": 0.0,
|
||||
"wall_a_join_side": None,
|
||||
"wall_b_join_side": None,
|
||||
"invalid_radius": False,
|
||||
"invalid_axes": None,
|
||||
}
|
||||
|
||||
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
|
||||
if intersection is None:
|
||||
return {**blank, "reason": "parallel", "invalid_axes": [seg_a, seg_b]}
|
||||
|
||||
def _classify(seg, ipt):
|
||||
d0 = (seg[0][0] - ipt[0]) ** 2 + (seg[0][1] - ipt[1]) ** 2 + (seg[0][2] - ipt[2]) ** 2
|
||||
d1 = (seg[1][0] - ipt[0]) ** 2 + (seg[1][1] - ipt[1]) ** 2 + (seg[1][2] - ipt[2]) ** 2
|
||||
if d0 <= d1:
|
||||
return seg[0], seg[1], "ATSTART"
|
||||
return seg[1], seg[0], "ATEND"
|
||||
|
||||
near_a, far_a, side_a = _classify(seg_a, intersection)
|
||||
near_b, far_b, side_b = _classify(seg_b, intersection)
|
||||
|
||||
# Direction along each segment AWAY from the corner. ``far - intersection``
|
||||
# handles both the shared-corner and extended-axes cases uniformly.
|
||||
dir_a_raw = _vec_sub(far_a, intersection)
|
||||
dir_b_raw = _vec_sub(far_b, intersection)
|
||||
far_len_a = _vec_length(dir_a_raw)
|
||||
far_len_b = _vec_length(dir_b_raw)
|
||||
if far_len_a < 1e-9 or far_len_b < 1e-9:
|
||||
return {**blank, "reason": "near_collinear", "intersection": intersection}
|
||||
dir_a = (dir_a_raw[0] / far_len_a, dir_a_raw[1] / far_len_a, dir_a_raw[2] / far_len_a)
|
||||
dir_b = (dir_b_raw[0] / far_len_b, dir_b_raw[1] / far_len_b, dir_b_raw[2] / far_len_b)
|
||||
|
||||
cos_angle = max(-1.0, min(1.0, _vec_dot(dir_a, dir_b)))
|
||||
angle = math.acos(cos_angle)
|
||||
sweep_angle = math.pi - angle
|
||||
if sweep_angle < 1e-3 or sweep_angle > math.pi - 1e-3:
|
||||
return {
|
||||
**blank,
|
||||
"reason": "near_collinear",
|
||||
"intersection": intersection,
|
||||
"sweep_angle": sweep_angle,
|
||||
"wall_a_join_side": side_a,
|
||||
"wall_b_join_side": side_b,
|
||||
}
|
||||
|
||||
tangent_offset = radius * math.tan(sweep_angle / 2)
|
||||
tangent_a = (
|
||||
intersection[0] + dir_a[0] * tangent_offset,
|
||||
intersection[1] + dir_a[1] * tangent_offset,
|
||||
intersection[2] + dir_a[2] * tangent_offset,
|
||||
)
|
||||
tangent_b = (
|
||||
intersection[0] + dir_b[0] * tangent_offset,
|
||||
intersection[1] + dir_b[1] * tangent_offset,
|
||||
intersection[2] + dir_b[2] * tangent_offset,
|
||||
)
|
||||
|
||||
plane_normal_raw = _vec_cross(dir_a, dir_b)
|
||||
pn_len = _vec_length(plane_normal_raw)
|
||||
if pn_len < 1e-9:
|
||||
return {**blank, "reason": "near_collinear", "intersection": intersection}
|
||||
plane_normal = (
|
||||
plane_normal_raw[0] / pn_len,
|
||||
plane_normal_raw[1] / pn_len,
|
||||
plane_normal_raw[2] / pn_len,
|
||||
)
|
||||
|
||||
perp_a = _vec_cross(plane_normal, dir_a)
|
||||
if _vec_dot(perp_a, dir_b) < 0:
|
||||
perp_a = (-perp_a[0], -perp_a[1], -perp_a[2])
|
||||
|
||||
arc_center = (
|
||||
tangent_a[0] + perp_a[0] * radius,
|
||||
tangent_a[1] + perp_a[1] * radius,
|
||||
tangent_a[2] + perp_a[2] * radius,
|
||||
)
|
||||
|
||||
v_a = _vec_sub(tangent_a, arc_center)
|
||||
v_b = _vec_sub(tangent_b, arc_center)
|
||||
sweep_axis = plane_normal
|
||||
if _vec_dot(_vec_cross(v_a, v_b), plane_normal) < 0:
|
||||
sweep_axis = (-plane_normal[0], -plane_normal[1], -plane_normal[2])
|
||||
|
||||
arc_points: list[tuple[float, float, float]] = []
|
||||
for i in range(arc_resolution + 1):
|
||||
t = i / arc_resolution
|
||||
rotated = _rotate_around_axis(v_a, sweep_axis, sweep_angle * t)
|
||||
arc_points.append(
|
||||
(
|
||||
arc_center[0] + rotated[0],
|
||||
arc_center[1] + rotated[1],
|
||||
arc_center[2] + rotated[2],
|
||||
)
|
||||
)
|
||||
|
||||
# Overshoot check only for convex fillets (positive ``tangent_offset``);
|
||||
# the inverted-fillet case puts tangents past the intersection.
|
||||
invalid_radius = tangent_offset > 0 and (tangent_offset > far_len_a or tangent_offset > far_len_b)
|
||||
|
||||
return {
|
||||
"valid": not invalid_radius,
|
||||
"reason": "invalid_radius" if invalid_radius else None,
|
||||
"intersection": intersection,
|
||||
"tangent_a": tangent_a,
|
||||
"tangent_b": tangent_b,
|
||||
"arc": arc_points,
|
||||
"arc_center": arc_center,
|
||||
"arc_radius": radius,
|
||||
"sweep_angle": sweep_angle,
|
||||
"sweep_axis": sweep_axis,
|
||||
"tangent_offset": tangent_offset,
|
||||
"wall_a_join_side": side_a,
|
||||
"wall_b_join_side": side_b,
|
||||
"leg_a_available": far_len_a,
|
||||
"leg_b_available": far_len_b,
|
||||
"invalid_radius": invalid_radius,
|
||||
"invalid_axes": None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bonsai.core.geometry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9
|
||||
|
||||
|
||||
def _z_rotation_diff(target_z: float, source_z: float) -> float:
|
||||
"""Signed Z-Euler difference wrapped to [-π, π]."""
|
||||
return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi
|
||||
|
||||
|
||||
def copy_z_rotation_to_selected(
|
||||
ifc: type[tool.Ifc],
|
||||
geometry: type[tool.Geometry],
|
||||
surveyor: type[tool.Surveyor],
|
||||
*,
|
||||
active: bpy.types.Object,
|
||||
targets: Iterable[bpy.types.Object],
|
||||
flip: bool = False,
|
||||
) -> int:
|
||||
"""Apply ``active``'s Z-Euler rotation to each target."""
|
||||
source_z = surveyor.get_z_rotation(active)
|
||||
if flip:
|
||||
source_z += math.pi
|
||||
rotated = 0
|
||||
for obj in targets:
|
||||
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
|
||||
continue
|
||||
surveyor.set_z_rotation(obj, source_z)
|
||||
rotated += 1
|
||||
if ifc.get_entity(obj) is not None:
|
||||
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
|
||||
return rotated
|
||||
Reference in New Issue
Block a user