Bonsai: support IfcTrimmedCurve arc segments in profile edit (#6929)

Tabbing into an extrusion to edit its profile crashed on Revit-exported
geometry. `tool.Model.convert_curve_to_mesh` handled IfcPolyline,
IfcCompositeCurve, IfcIndexedPolyCurve and IfcCircle, but not
IfcTrimmedCurve. Revit exports a wall/opening profile as an
IfcCompositeCurve mixing polyline segments with trimmed-circle arc
segments, so the recursion hit an IfcTrimmedCurve, fell through to the
else branch, and raised UnsupportedCurveForConversion, aborting the whole
tab-into-edit.

Add an IfcTrimmedCurve branch that reconstructs a circular arc as a
3-point (start, mid, end) arc into an IFCARCINDEX vertex group, matching
how IfcArcIndex segments already round-trip. Trim parameters are read in
the project plane-angle unit and converted to radians; both
IfcParameterValue and IfcCartesianPoint trims are supported; SenseAgreement
is used to unwrap the end angle so the midpoint lands on the swept arc.
Non-circular basis curves still raise UnsupportedCurveForConversion rather
than emit silently wrong geometry.

Verified live in headless Blender on the issue's file: profile #41622 now
imports (27 verts, 5 arc groups) with no exception, and each reconstructed
arc's endpoints coincide (~1e-6) with the endpoints of the adjacent
polyline segments in the composite curve.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-12 08:22:53 +03:00
parent 0b7e25a3ef
commit d472703b38
+45 -1
View File
@@ -24,7 +24,7 @@ import collections.abc
import json
from collections.abc import Callable, Iterable, Sequence
from copy import deepcopy
from math import atan, cos, degrees, pi, radians
from math import atan, atan2, cos, degrees, pi, radians, sin
from typing import (
TYPE_CHECKING,
Any,
@@ -660,6 +660,50 @@ class Model(bonsai.core.tool.Model):
)
cls.circles.append([offset, offset + 1])
cls.edges.append((offset, offset + 1))
elif curve.is_a("IfcTrimmedCurve"):
# A trimmed circular arc, e.g. a fillet segment inside an IfcCompositeCurve
# exported by Revit. Reconstruct it as a 3-point (start, mid, end) arc so it
# round-trips through the IFCARCINDEX vertex group like IfcArcIndex segments do.
basis_curve = curve.BasisCurve
if not basis_curve.is_a("IfcCircle"):
raise cls.UnsupportedCurveForConversion(f"Profile has unsupported curve type: {curve}.")
circle_position = Matrix(ifcopenshell.util.placement.get_axis2placement(basis_curve.Position).tolist())
circle_position.translation *= cls.unit_scale
radius = cls.convert_unit_to_si(basis_curve.Radius)
# Trim parameters on a circle are angles expressed in the project plane angle unit.
angle_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get(), "PLANEANGLEUNIT")
def _trim_angle(trim: tuple[ifcopenshell.entity_instance, ...]) -> Union[float, None]:
# Prefer a parameter (angle) trim, else derive the angle from a cartesian point.
for select in trim:
if select.is_a("IfcParameterValue"):
return float(select.wrappedValue) * angle_scale
for select in trim:
if select.is_a("IfcCartesianPoint"):
local = circle_position.inverted() @ Vector(cls.convert_unit_to_si(select.Coordinates)).to_3d()
return atan2(local.y, local.x)
return None
angle_1 = _trim_angle(curve.Trim1)
angle_2 = _trim_angle(curve.Trim2)
if angle_1 is None or angle_2 is None:
raise cls.UnsupportedCurveForConversion(f"Profile has unsupported curve type: {curve}.")
# SenseAgreement tells us whether the arc runs with (CCW) or against (CW) the
# basis curve direction; unwrap the end angle accordingly so the midpoint lands
# on the actual swept arc rather than its complement.
if curve.SenseAgreement:
while angle_2 < angle_1:
angle_2 += 2 * pi
else:
while angle_2 > angle_1:
angle_2 -= 2 * pi
angle_mid = (angle_1 + angle_2) / 2
for angle in (angle_1, angle_mid, angle_2):
local_point = Vector((radius * cos(angle), radius * sin(angle), 0.0))
cls.vertices.append(position @ circle_position @ local_point)
cls.arcs.append([offset, offset + 1, offset + 2])
cls.edges.append((offset, offset + 1))
cls.edges.append((offset + 1, offset + 2))
else:
raise cls.UnsupportedCurveForConversion(f"Profile has unsupported curve type: {curve}.")