From d472703b38402c91b074e83e9f945de196223f46 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 12 Jul 2026 08:22:53 +0300 Subject: [PATCH] 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 --- src/bonsai/bonsai/tool/model.py | 46 ++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index f54633c3cc..0b9821dae5 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -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}.")