From eb1e79ea5c6f2dfbc3eea5f1c87c80e5301f25f6 Mon Sep 17 00:00:00 2001 From: DesertSpringsCivil Date: Thu, 12 Mar 2026 18:16:09 -0600 Subject: [PATCH] fix: Route IfcAlignment creation through align_api.create() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Rick Brice's PR review feedback on #7785. Root cause: the AddElement dialog was manually stitching together IfcAlignment layout containers and calling create_representation(), bypassing the validated construction sequence in align_api.create(). This left IfcGradientCurve.BaseCurve potentially None and skipped zero-length segment and stationing referent setup. Changes: - root/operator.py: alignment templates now call align_api.create() directly (HORIZONTAL, GRADIENT, CANT) or _create_polyline_representation (POLYLINE_2D/3D), then manually link the result to the Blender object via tool.Ifc.link + tool.Collector.assign. The old generic core.assign_class path is retained for all non-alignment templates unchanged. - tool/alignment.py: remove create_representation_structure() — superseded by the operator changes above. - create_representation.py: revert the if layout_nest: guard; with align_api.create() as the entry point the zero-length segment always exists before create_representation is called. - add_zero_length_segment.py: revert the BaseCurve None guard; the root cause (gradient curve created without a base curve) no longer occurs. - alignment/operator.py: guard _create_geometric_representation call so it only runs when no curve representation exists yet; auto-invoke create_alignment_by_pi after PI picker finishes if >=2 PIs are defined. - util/file.py: fix StopIteration on short IFC template files (next(ifc_file) → next(ifc_file, None) with break). Co-Authored-By: Claude Sonnet 4.6 --- .../bonsai/bim/module/alignment/operator.py | 7 +- src/bonsai/bonsai/bim/module/root/operator.py | 110 +++++++++++++----- src/bonsai/bonsai/tool/alignment.py | 64 +--------- .../api/alignment/add_zero_length_segment.py | 3 +- .../api/alignment/create_representation.py | 5 +- .../ifcopenshell/util/file.py | 4 +- 6 files changed, 92 insertions(+), 101 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 20833f6fe2..4c71572784 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -583,6 +583,10 @@ class CIVIL_OT_pick_pi_from_viewport(bpy.types.Operator, PolylineOperator, tool. recalculate_pi_geometry(props) rebuild_display_rows(props) + # If invoked from alignment creation flow, auto-create IFC segments + if props.active_alignment_id != 0 and len(props.pis) >= 2: + bpy.ops.civil.create_alignment_by_pi() + class CIVIL_OT_recalculate_pis(Operator, tool.Ifc.Operator): """Recalculate PI geometry and update IFC/visualization""" @@ -738,7 +742,8 @@ class CIVIL_OT_create_alignment_by_pi(Operator, tool.Ifc.Operator): segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) has_real_segments = bool([s for s in segments if not tool.Alignment.is_zero_length_segment(s)]) - ifcopenshell.api.alignment._create_geometric_representation(tool.Ifc.get(), existing_alignment) + if not ifcopenshell.api.alignment.get_curve(existing_alignment): + ifcopenshell.api.alignment._create_geometric_representation(tool.Ifc.get(), existing_alignment) if not has_real_segments: # Use existing alignment - add segments to it diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 86ab6c93a1..0de8b7eba3 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -540,47 +540,86 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): if ifc_context: ifc_context = tool.Ifc.get().by_id(ifc_context) - if representation_template in ( - "EMPTY", + alignment_templates = { "ALIGNMENT_HORIZONTAL", "ALIGNMENT_GRADIENT", "ALIGNMENT_CANT", "ALIGNMENT_POLYLINE_3D", "ALIGNMENT_POLYLINE_2D", - "LAYERSET_AXIS2", - "LAYERSET_AXIS3", - "PROFILESET", - ) or representation_template.startswith("FLOW_SEGMENT_"): - mesh = None - elif representation_template == "OBJ" and not props.representation_obj: - mesh = None + } + + if representation_template in alignment_templates: + import ifcopenshell.api.alignment as align_api + from ifcopenshell.api.alignment._create_polyline_representation import ( + _create_polyline_representation, + ) + + ifc_file = tool.Ifc.get() + alignment_name = props.name or "Unnamed" + + if representation_template == "ALIGNMENT_HORIZONTAL": + element = align_api.create(ifc_file, alignment_name, include_vertical=False, include_cant=False) + elif representation_template == "ALIGNMENT_GRADIENT": + element = align_api.create(ifc_file, alignment_name, include_vertical=True, include_cant=False) + elif representation_template == "ALIGNMENT_CANT": + element = align_api.create(ifc_file, alignment_name, include_vertical=True, include_cant=True) + elif representation_template == "ALIGNMENT_POLYLINE_3D": + element = ifc_file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name=alignment_name) + pts = [ + ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0, 0.0)), + ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0, 0.0)), + ] + _create_polyline_representation(ifc_file, element, pts) + project = ifc_file.by_type("IfcProject") + if project: + ifcopenshell.api.aggregate.assign_object(ifc_file, products=[element], relating_object=project[0]) + elif representation_template == "ALIGNMENT_POLYLINE_2D": + element = ifc_file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name=alignment_name) + pts = [ + ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)), + ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0)), + ] + _create_polyline_representation(ifc_file, element, pts) + project = ifc_file.by_type("IfcProject") + if project: + ifcopenshell.api.aggregate.assign_object(ifc_file, products=[element], relating_object=project[0]) + + element.Description = props.description or None + obj = bpy.data.objects.new(props.ifc_class[3:], None) + obj.name = alignment_name + obj.location = bpy.context.scene.cursor.location + tool.Root.set_object_name(obj, element) + tool.Ifc.link(element, obj) + tool.Collector.assign(obj) else: - mesh = bpy.data.meshes.new("Mesh") + if representation_template in ( + "EMPTY", + "LAYERSET_AXIS2", + "LAYERSET_AXIS3", + "PROFILESET", + ) or representation_template.startswith("FLOW_SEGMENT_"): + mesh = None + elif representation_template == "OBJ" and not props.representation_obj: + mesh = None + else: + mesh = bpy.data.meshes.new("Mesh") - obj = bpy.data.objects.new(props.ifc_class[3:], mesh) - obj.name = props.name or "Unnamed" - obj.location = bpy.context.scene.cursor.location - element = core.assign_class( - tool.Ifc, - tool.Collector, - tool.Root, - obj=obj, - ifc_class=props.ifc_class, - predefined_type=predefined_type, - should_add_representation=False, - ) - element.Description = props.description or None + obj = bpy.data.objects.new(props.ifc_class[3:], mesh) + obj.name = props.name or "Unnamed" + obj.location = bpy.context.scene.cursor.location + element = core.assign_class( + tool.Ifc, + tool.Collector, + tool.Root, + obj=obj, + ifc_class=props.ifc_class, + predefined_type=predefined_type, + should_add_representation=False, + ) + element.Description = props.description or None - if representation_template == "EMPTY": + if representation_template == "EMPTY" or representation_template in alignment_templates: pass - elif representation_template in ( - "ALIGNMENT_HORIZONTAL", - "ALIGNMENT_GRADIENT", - "ALIGNMENT_CANT", - "ALIGNMENT_POLYLINE_3D", - "ALIGNMENT_POLYLINE_2D", - ): - tool.Alignment.create_representation_structure(element, representation_template) elif not ifc_context: pass elif representation_template == "OBJ" and props.representation_obj: @@ -849,6 +888,13 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) tool.Blender.set_active_object(obj) + # After alignment creation, auto-invoke PI picker for layout-based templates + if props.ifc_class == "IfcAlignment" and representation_template.startswith("ALIGNMENT_") and representation_template not in ("ALIGNMENT_POLYLINE_2D", "ALIGNMENT_POLYLINE_3D"): + civil_props = context.scene.CivilAlignmentProperties + civil_props.active_alignment_id = element.id() + civil_props.active_alignment_name = element.Name or "Unnamed" + bpy.ops.civil.pick_pi_from_viewport("INVOKE_DEFAULT") + def draw(self, context): props = tool.Root.get_root_props() self.layout.use_property_split = True diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index 60d8d623df..614d017ccc 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -388,68 +388,6 @@ class Alignment: ifc_file = tool.Ifc.get() align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii) - @classmethod - def create_representation_structure(cls, alignment: "ifcopenshell.entity_instance", template: str) -> None: - """Create semantic layout containers and geometric representation for an IfcAlignment. - - Called from the AddElement operator when the user selects a representation type - for a newly-created IfcAlignment. For layout-based templates (HORIZONTAL, GRADIENT, - CANT), the appropriate IfcAlignmentHorizontal / Vertical / Cant entities are nested to - the alignment and ifcopenshell.api.alignment.create_representation is invoked to build - the matching geometric curves. For polyline templates, an IfcPolyLine with two - placeholder origin points is created directly. - - Layout containers are IFC entities only — no Blender objects are created for them. - - Args: - alignment: The IfcAlignment entity that was just created by AddElement. - template: One of ALIGNMENT_HORIZONTAL, ALIGNMENT_GRADIENT, ALIGNMENT_CANT, - ALIGNMENT_POLYLINE_3D, or ALIGNMENT_POLYLINE_2D. - """ - import ifcopenshell.api.alignment as align_api - import ifcopenshell.api.nest - import ifcopenshell.guid - from ifcopenshell.api.alignment._create_polyline_representation import ( - _create_polyline_representation, - ) - - ifc_file = tool.Ifc.get() - - if template == "ALIGNMENT_HORIZONTAL": - h_layout = ifc_file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new()) - ifcopenshell.api.nest.assign_object(ifc_file, related_objects=[h_layout], relating_object=alignment) - align_api.create_representation(ifc_file, alignment) - - elif template == "ALIGNMENT_GRADIENT": - h_layout = ifc_file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new()) - ifcopenshell.api.nest.assign_object(ifc_file, related_objects=[h_layout], relating_object=alignment) - v_layout = ifc_file.createIfcAlignmentVertical(GlobalId=ifcopenshell.guid.new()) - ifcopenshell.api.nest.assign_object(ifc_file, related_objects=[v_layout], relating_object=alignment) - align_api.create_representation(ifc_file, alignment) - - elif template == "ALIGNMENT_CANT": - h_layout = ifc_file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new()) - ifcopenshell.api.nest.assign_object(ifc_file, related_objects=[h_layout], relating_object=alignment) - v_layout = ifc_file.createIfcAlignmentVertical(GlobalId=ifcopenshell.guid.new()) - ifcopenshell.api.nest.assign_object(ifc_file, related_objects=[v_layout], relating_object=alignment) - c_layout = ifc_file.createIfcAlignmentCant(GlobalId=ifcopenshell.guid.new()) - ifcopenshell.api.nest.assign_object(ifc_file, related_objects=[c_layout], relating_object=alignment) - align_api.create_representation(ifc_file, alignment) - - elif template == "ALIGNMENT_POLYLINE_3D": - pts = [ - ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0, 0.0)), - ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0, 0.0)), - ] - _create_polyline_representation(ifc_file, alignment, pts) - - elif template == "ALIGNMENT_POLYLINE_2D": - pts = [ - ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)), - ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0)), - ] - _create_polyline_representation(ifc_file, alignment, pts) - # ========================================================================= # Zero-Length Segment Utilities # ========================================================================= @@ -634,6 +572,8 @@ class Alignment: obj = bpy.data.objects.new(tool.Loader.get_name(curve_segment), mesh) tool.Ifc.link(curve_segment, obj) tool.Collector.assign(obj) + if parent_obj: + obj.parent = parent_obj return obj @classmethod diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py index a64eee9437..96c3a9c1f2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py @@ -123,8 +123,7 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in if layout.is_a("IfcSegmentedReferenceCurve"): ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve) elif layout.is_a("IfcGradientCurve"): - if layout.BaseCurve is not None: - ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve) + ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve) else: zero_length_curve_segment = None diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py index 67e911bfbc..896108367f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py @@ -52,6 +52,5 @@ def create_representation( for layout in layouts: curve = ifcopenshell.api.alignment.get_layout_curve(layout) layout_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout) - if layout_nest: # None when no segments exist yet (by design of get_alignment_segment_nest) - for segment in layout_nest.RelatedObjects: - _add_segment_to_curve(file, segment, curve) + for segment in layout_nest.RelatedObjects: + _add_segment_to_curve(file, segment, curve) diff --git a/src/ifcopenshell-python/ifcopenshell/util/file.py b/src/ifcopenshell-python/ifcopenshell/util/file.py index a874e1ba4d..f4698fe8d6 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/file.py +++ b/src/ifcopenshell-python/ifcopenshell/util/file.py @@ -91,7 +91,9 @@ class IfcHeaderExtractor: data = HeaderMetadata() max_lines_to_parse = 50 for _ in range(max_lines_to_parse): - line = next(ifc_file) + line = next(ifc_file, None) + if line is None: + break if isinstance(line, bytes): line = line.decode("utf-8") if line.startswith("FILE_DESCRIPTION"):