fix: Route IfcAlignment creation through align_api.create()

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 <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-03-12 18:16:09 -06:00
parent d2ee4f2f97
commit eb1e79ea5c
6 changed files with 92 additions and 101 deletions
@@ -583,6 +583,10 @@ class CIVIL_OT_pick_pi_from_viewport(bpy.types.Operator, PolylineOperator, tool.
recalculate_pi_geometry(props) recalculate_pi_geometry(props)
rebuild_display_rows(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): class CIVIL_OT_recalculate_pis(Operator, tool.Ifc.Operator):
"""Recalculate PI geometry and update IFC/visualization""" """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) 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)]) 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: if not has_real_segments:
# Use existing alignment - add segments to it # Use existing alignment - add segments to it
+78 -32
View File
@@ -540,47 +540,86 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
if ifc_context: if ifc_context:
ifc_context = tool.Ifc.get().by_id(ifc_context) ifc_context = tool.Ifc.get().by_id(ifc_context)
if representation_template in ( alignment_templates = {
"EMPTY",
"ALIGNMENT_HORIZONTAL", "ALIGNMENT_HORIZONTAL",
"ALIGNMENT_GRADIENT", "ALIGNMENT_GRADIENT",
"ALIGNMENT_CANT", "ALIGNMENT_CANT",
"ALIGNMENT_POLYLINE_3D", "ALIGNMENT_POLYLINE_3D",
"ALIGNMENT_POLYLINE_2D", "ALIGNMENT_POLYLINE_2D",
"LAYERSET_AXIS2", }
"LAYERSET_AXIS3",
"PROFILESET", if representation_template in alignment_templates:
) or representation_template.startswith("FLOW_SEGMENT_"): import ifcopenshell.api.alignment as align_api
mesh = None from ifcopenshell.api.alignment._create_polyline_representation import (
elif representation_template == "OBJ" and not props.representation_obj: _create_polyline_representation,
mesh = None )
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: 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 = bpy.data.objects.new(props.ifc_class[3:], mesh)
obj.name = props.name or "Unnamed" obj.name = props.name or "Unnamed"
obj.location = bpy.context.scene.cursor.location obj.location = bpy.context.scene.cursor.location
element = core.assign_class( element = core.assign_class(
tool.Ifc, tool.Ifc,
tool.Collector, tool.Collector,
tool.Root, tool.Root,
obj=obj, obj=obj,
ifc_class=props.ifc_class, ifc_class=props.ifc_class,
predefined_type=predefined_type, predefined_type=predefined_type,
should_add_representation=False, should_add_representation=False,
) )
element.Description = props.description or None element.Description = props.description or None
if representation_template == "EMPTY": if representation_template == "EMPTY" or representation_template in alignment_templates:
pass 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: elif not ifc_context:
pass pass
elif representation_template == "OBJ" and props.representation_obj: 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) bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Blender.set_active_object(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): def draw(self, context):
props = tool.Root.get_root_props() props = tool.Root.get_root_props()
self.layout.use_property_split = True self.layout.use_property_split = True
+2 -62
View File
@@ -388,68 +388,6 @@ class Alignment:
ifc_file = tool.Ifc.get() ifc_file = tool.Ifc.get()
align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii) 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 # Zero-Length Segment Utilities
# ========================================================================= # =========================================================================
@@ -634,6 +572,8 @@ class Alignment:
obj = bpy.data.objects.new(tool.Loader.get_name(curve_segment), mesh) obj = bpy.data.objects.new(tool.Loader.get_name(curve_segment), mesh)
tool.Ifc.link(curve_segment, obj) tool.Ifc.link(curve_segment, obj)
tool.Collector.assign(obj) tool.Collector.assign(obj)
if parent_obj:
obj.parent = parent_obj
return obj return obj
@classmethod @classmethod
@@ -123,8 +123,7 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
if layout.is_a("IfcSegmentedReferenceCurve"): if layout.is_a("IfcSegmentedReferenceCurve"):
ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve) ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve)
elif layout.is_a("IfcGradientCurve"): 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: else:
zero_length_curve_segment = None zero_length_curve_segment = None
@@ -52,6 +52,5 @@ def create_representation(
for layout in layouts: for layout in layouts:
curve = ifcopenshell.api.alignment.get_layout_curve(layout) curve = ifcopenshell.api.alignment.get_layout_curve(layout)
layout_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(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:
for segment in layout_nest.RelatedObjects: _add_segment_to_curve(file, segment, curve)
_add_segment_to_curve(file, segment, curve)
@@ -91,7 +91,9 @@ class IfcHeaderExtractor:
data = HeaderMetadata() data = HeaderMetadata()
max_lines_to_parse = 50 max_lines_to_parse = 50
for _ in range(max_lines_to_parse): 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): if isinstance(line, bytes):
line = line.decode("utf-8") line = line.decode("utf-8")
if line.startswith("FILE_DESCRIPTION"): if line.startswith("FILE_DESCRIPTION"):