Working on various input and editing for alignments

This commit is contained in:
Richard Brice
2026-09-14 16:32:24 -07:00
parent a2914eeb91
commit 6a95717072
7 changed files with 1220 additions and 74 deletions
@@ -7,18 +7,42 @@ resolving them.
## 1. Table-based editing ## 1. Table-based editing
The Alignment tab's UI lists the horizontal, vertical, and cant layouts. These listings need to **Implemented.** The Alignment tab's segment tables (`ALIGN_PT_alignment_segments`) now support
become editable: edit/add/delete/reorder, one section (horizontal, a given vertical layout, or cant) at a time via a
pencil icon that swaps the read-only rows for an editable `UIList`
(`ALIGN_UL_h_segments`/`_v_segments`/`_cant_segments`, staged in `HorizontalSegmentRow`/
`VerticalSegmentRow`/`CantSegmentRow` on `CivilAlignmentProperties`). Interaction model is
"stage edits, then Apply" — add/remove/reorder rows and edit values freely with nothing touching
IFC until Apply, mirroring the existing `VerticalPIMarker` + `ALIGN_OT_apply_vertical_pi_curve`
precedent rather than writing per-cell. Apply (`ALIGN_OT_apply_h_segments`/`_v_segments`/
`_cant_segments`) rebuilds the whole layout in one pass (`ifcopenshell.api.alignment.
clear_layout_segments` + a `create_layout_segment` loop over the staged rows), then refreshes the
`IfcAlignment` representation, the 3D viewport mesh (`refresh_alignment_representation_object`),
and the vertical/cant profile view (`_refresh_vertical_profile_view`) — all three refresh targets
from the original ask are covered. Cancel discards the staged rows without touching IFC.
- Edit existing segments Segment type coverage: horizontal supports LINE, CIRCULARARC, and the full spiral-transition family
- Add new segments (CLOTHOID, CUBIC, HELMERTCURVE, BLOSSCURVE, COSINECURVE, SINECURVE); vertical supports
- Delete segments CONSTANTGRADIENT, PARABOLICARC, and CIRCULARARC. Excluded, each for a specific documented reason
(see `prop.py`'s `HorizontalSegmentRow`/`VerticalSegmentRow` docstrings): horizontal VIENNESEBEND
(its geometry also depends on the CANT segment at the same station — rail cant angle, gravity
centerline height — which this table has no place for) and vertical CLOTHOID (`ifcopenshell`'s own
mapper raises `NotImplementedError` for it). A row whose real IFC type is something else entirely
(e.g. a file authored outside Bonsai) shows as "Unsupported" and blocks Apply rather than silently
mis-editing it.
When editing finishes, the `IfcAlignment` model and its representations must be updated, and the **Known, deliberate gap carried over from §4's existing note below:** Apply is a full rebuild, not
updated representations must automatically refresh in: a partial/in-place regenerate — every segment in that layout gets a fresh GUID each time, same
limitation §4 already documents for the interactive draw tools. Not fixed here; a future "regenerate
only the affected subset" pass would benefit both this and §4 together.
- the 3D viewport **Known, deliberate non-validation (per the user, 2026-09-14):** a spiral-family row with equal
- the vertical/cant profile view start/end radius, or a vertical CIRCULARARC row with equal start/end gradient, is degenerate input
that reliably crashes the geometry kernel (divides by a curvature-change factor that's exactly
zero) rather than erroring gracefully. This is intentionally left unguarded in
`tool.Alignment.validate_horizontal_segment_rows`/`validate_vertical_segment_rows` — the crash is
meant to stay visible as a reminder that the kernel itself needs the fix, not papered over with a
UI-side check.
## 2. Interactive creation of a horizontal alignment ## 2. Interactive creation of a horizontal alignment
@@ -76,7 +100,9 @@ improvements:
Replace the PI-grid display with basic information about the alignment layout — PI points Replace the PI-grid display with basic information about the alignment layout — PI points
themselves are no longer needed in that grid. themselves are no longer needed in that grid.
With each alignment segment represented in the Scene Collection: Segments do **not** need to be represented as individual objects in the Scene Collection
(decided — the existing panel-list + on-the-fly viewport decorator approach is sufficient;
see `AlignmentSegmentDecorator`, which already covers the three items below):
- Selecting a segment highlights it. - Selecting a segment highlights it.
- Display segment information in the 3D viewport: Start Point, End Point, Length, Radius, PI, - Display segment information in the 3D viewport: Start Point, End Point, Length, Radius, PI,
@@ -84,7 +84,6 @@ def _on_active_object_changed(scene, depsgraph):
item.label = c_label item.label = c_label
item.is_visible = True item.is_visible = True
# Refit the camera to the new alignment's extent # Refit the camera to the new alignment's extent
ve = props.vertical_exaggeration
for window in ctx.window_manager.windows: for window in ctx.window_manager.windows:
for a in window.screen.areas: for a in window.screen.areas:
if a.as_pointer() == dec.profile_area_ptr: if a.as_pointer() == dec.profile_area_ptr:
@@ -92,7 +91,7 @@ def _on_active_object_changed(scene, depsgraph):
(s for s in a.spaces if s.type == "VIEW_3D"), None (s for s in a.spaces if s.type == "VIEW_3D"), None
) )
if space: if space:
dec.fit_view(space, ve, area_width=a.width, area_height=a.height) dec.fit_view(space, area_width=a.width, area_height=a.height)
dec.tag_redraw() dec.tag_redraw()
for window in ctx.window_manager.windows: for window in ctx.window_manager.windows:
@@ -108,6 +107,9 @@ classes = (
prop.VerticalAlignmentItem, prop.VerticalAlignmentItem,
prop.CantAlignmentItem, prop.CantAlignmentItem,
prop.VerticalPIMarker, prop.VerticalPIMarker,
prop.HorizontalSegmentRow,
prop.VerticalSegmentRow,
prop.CantSegmentRow,
prop.CivilAlignmentProperties, prop.CivilAlignmentProperties,
prop.PICurveMarkerProperties, prop.PICurveMarkerProperties,
# UILists and section-toggle operators # UILists and section-toggle operators
@@ -115,6 +117,9 @@ classes = (
ui.ALIGN_OT_toggle_v_segments, ui.ALIGN_OT_toggle_v_segments,
ui.ALIGN_OT_toggle_cant_segments, ui.ALIGN_OT_toggle_cant_segments,
ui.ALIGN_UL_vertical_pi_markers, ui.ALIGN_UL_vertical_pi_markers,
ui.ALIGN_UL_h_segments,
ui.ALIGN_UL_v_segments,
ui.ALIGN_UL_cant_segments,
operator.ImportAlignmentCSV, operator.ImportAlignmentCSV,
# Operators - Vertical Profile Window # Operators - Vertical Profile Window
operator.ALIGN_OT_show_vertical_profile, operator.ALIGN_OT_show_vertical_profile,
@@ -136,6 +141,19 @@ classes = (
operator.ALIGN_OT_draw_vertical_alignment, operator.ALIGN_OT_draw_vertical_alignment,
operator.ALIGN_OT_apply_vertical_pi_curve, operator.ALIGN_OT_apply_vertical_pi_curve,
operator.ALIGN_OT_clear_vertical_pi_markers, operator.ALIGN_OT_clear_vertical_pi_markers,
# Operators - Segment table editing (stage edits, then Apply)
operator.ALIGN_OT_add_segment_row,
operator.ALIGN_OT_remove_segment_row,
operator.ALIGN_OT_move_segment_row,
operator.ALIGN_OT_enable_editing_h_segments,
operator.ALIGN_OT_disable_editing_h_segments,
operator.ALIGN_OT_apply_h_segments,
operator.ALIGN_OT_enable_editing_v_segments,
operator.ALIGN_OT_disable_editing_v_segments,
operator.ALIGN_OT_apply_v_segments,
operator.ALIGN_OT_enable_editing_cant_segments,
operator.ALIGN_OT_disable_editing_cant_segments,
operator.ALIGN_OT_apply_cant_segments,
# UI Panels (appear in Properties sidebar under ALIGNMENTS tab) # UI Panels (appear in Properties sidebar under ALIGNMENTS tab)
ui.ALIGN_PT_alignment_authoring, ui.ALIGN_PT_alignment_authoring,
ui.ALIGN_PT_vertical_alignment_authoring, ui.ALIGN_PT_vertical_alignment_authoring,
@@ -59,6 +59,13 @@ class AlignmentSegmentDecorator:
label_world_pos: tuple[float, float, float] | None = None label_world_pos: tuple[float, float, float] | None = None
tangent_data: dict | None = None tangent_data: dict | None = None
# Blender selection state at the moment this segment was highlighted
# (see _has_selection_changed) -- used to auto-clear the highlight the
# moment the user picks something else, instead of leaving it stuck
# until the same side-panel row is clicked again.
_baseline_active_ptr: int = 0
_baseline_selected_ptrs: frozenset = frozenset()
COLOR_HIGHLIGHT = (1.0, 0.55, 0.0, 1.0) # Orange - segment polyline COLOR_HIGHLIGHT = (1.0, 0.55, 0.0, 1.0) # Orange - segment polyline
COLOR_TANGENT = (0.75, 0.75, 0.75, 0.85) # Light gray - tangent extension lines COLOR_TANGENT = (0.75, 0.75, 0.75, 0.85) # Light gray - tangent extension lines
COLOR_PI = (1.0, 0.85, 0.25, 1.0) # Yellow - PI crosshair COLOR_PI = (1.0, 0.85, 0.25, 1.0) # Yellow - PI crosshair
@@ -87,6 +94,39 @@ class AlignmentSegmentDecorator:
SpaceView3D.draw_handler_add(handler.draw_label, (context,), "WINDOW", "POST_PIXEL") SpaceView3D.draw_handler_add(handler.draw_label, (context,), "WINDOW", "POST_PIXEL")
) )
cls.is_installed = True cls.is_installed = True
cls._capture_selection_baseline(context)
@classmethod
def _capture_selection_baseline(cls, context) -> None:
try:
active_obj = context.view_layer.objects.active
cls._baseline_active_ptr = active_obj.as_pointer() if active_obj else 0
cls._baseline_selected_ptrs = frozenset(o.as_pointer() for o in context.selected_objects)
except Exception:
cls._baseline_active_ptr = 0
cls._baseline_selected_ptrs = frozenset()
@classmethod
def _has_selection_changed(cls) -> bool:
"""True if Blender's active object or selection set differs from
what it was when this segment was highlighted.
Selecting a segment in the side panel doesn't itself touch Blender's
object selection, so any subsequent change to it -- clicking on
nothing or on something else in the 3D viewport, picking a different
object in the Outliner, or switching the active alignment via the
dropdown (which reselects that alignment's own object, see
_on_active_alignment_update in prop.py) -- means the user has moved
on and the highlight/label should clear rather than stick around
referencing a segment that's no longer the focus.
"""
try:
active_obj = bpy.context.view_layer.objects.active
active_ptr = active_obj.as_pointer() if active_obj else 0
selected_ptrs = frozenset(o.as_pointer() for o in bpy.context.selected_objects)
except Exception:
return False
return active_ptr != cls._baseline_active_ptr or selected_ptrs != cls._baseline_selected_ptrs
@classmethod @classmethod
def refresh(cls) -> None: def refresh(cls) -> None:
@@ -96,10 +136,18 @@ class AlignmentSegmentDecorator:
segment is already selected and highlighted; without this its PC/PI/PT segment is already selected and highlighted; without this its PC/PI/PT
station labels would stay stale until the segment was deselected and station labels would stay stale until the segment was deselected and
reselected. Called by the stationing operators after they succeed. reselected. Called by the stationing operators after they succeed.
Also re-captures the selection baseline (see _has_selection_changed):
the triggering operator ran with the segment's highlight still valid,
so whatever Blender's active object/selection happens to be right now
is the new "nothing has changed yet" baseline -- protects against a
false-positive auto-clear on the next redraw if the operator itself
touched object selection along the way.
""" """
if not cls.is_installed or cls.segment_id is None: if not cls.is_installed or cls.segment_id is None:
return return
cls._compute_segment_geometry(cls.segment_id) cls._compute_segment_geometry(cls.segment_id)
cls._capture_selection_baseline(bpy.context)
tool.Blender.update_viewport() tool.Blender.update_viewport()
@classmethod @classmethod
@@ -116,6 +164,18 @@ class AlignmentSegmentDecorator:
cls.segment_label = "" cls.segment_label = ""
cls.label_world_pos = None cls.label_world_pos = None
cls.tangent_data = None cls.tangent_data = None
cls._baseline_active_ptr = 0
cls._baseline_selected_ptrs = frozenset()
# Keep the side panel's row depress-state in sync -- uninstall() can
# now be triggered autonomously (see _has_selection_changed), not
# just from the toggle operator, which already clears this itself.
try:
props = bpy.context.scene.CivilAlignmentProperties
if props.selected_h_segment_id:
props.selected_h_segment_id = 0
except Exception:
pass
@classmethod @classmethod
def _compute_segment_geometry(cls, segment_id: int) -> None: def _compute_segment_geometry(cls, segment_id: int) -> None:
@@ -385,6 +445,22 @@ class AlignmentSegmentDecorator:
def draw_segment(self, context): def draw_segment(self, context):
"""Draw the orange highlight polyline and gray tangent extension lines to PI.""" """Draw the orange highlight polyline and gray tangent extension lines to PI."""
cls = self.__class__
if not cls.is_installed:
return
# Selection moved on elsewhere (viewport pick, Outliner, alignment
# dropdown) -- clear rather than keep showing a stale highlight.
if cls._has_selection_changed():
cls.uninstall()
try:
for area in bpy.context.screen.areas:
if area.type == "VIEW_3D":
area.tag_redraw()
except Exception:
pass
return
# Never draw segment overlays inside the vertical profile area # Never draw segment overlays inside the vertical profile area
pa_ptr = VerticalProfileDecorator.profile_area_ptr pa_ptr = VerticalProfileDecorator.profile_area_ptr
if pa_ptr != 0: if pa_ptr != 0:
@@ -394,7 +470,7 @@ class AlignmentSegmentDecorator:
except Exception: except Exception:
pass pass
verts = self.__class__.segment_verts verts = cls.segment_verts
if not verts or len(verts) < 2: if not verts or len(verts) < 2:
return return
@@ -491,6 +567,12 @@ class AlignmentSegmentDecorator:
Linear segments (has_pi=False): start and end station + coords, no tag prefix. Linear segments (has_pi=False): start and end station + coords, no tag prefix.
Circular arcs: also label the center of curvature. Circular arcs: also label the center of curvature.
""" """
if not self.__class__.is_installed:
# draw_segment (POST_VIEW, runs first) may have just auto-cleared
# the highlight this same frame because the selection moved on --
# don't draw a label for data that's already gone.
return
try: try:
if not bpy.context.scene.CivilAlignmentProperties.show_h_segment_labels: if not bpy.context.scene.CivilAlignmentProperties.show_h_segment_labels:
return return
@@ -608,6 +690,25 @@ def _nice_interval(span: float, target_count: int = 8) -> float:
return factor * magnitude return factor * magnitude
def _dot_tris(centers: list, radius: float, segments: int = 12):
"""Build a triangle-fan mesh for filled circular dots, one per (x, z) in
``centers`` (world X/Z plane, Y=0), combined into a single TRIS batch.
Returns (verts, indices) ready for batch_for_shader(shader, "TRIS", ...).
"""
verts = []
indices = []
for cx, cz in centers:
base = len(verts)
verts.append((cx, 0.0, cz))
for i in range(segments):
ang = 2.0 * math.pi * i / segments
verts.append((cx + radius * math.cos(ang), 0.0, cz + radius * math.sin(ang)))
for i in range(segments):
indices.append((base, base + 1 + i, base + 1 + (i + 1) % segments))
return verts, indices
def _frange(start: float, stop: float, step: float): def _frange(start: float, stop: float, step: float):
"""Yield evenly-spaced floats aligned to step boundaries, from start to stop.""" """Yield evenly-spaced floats aligned to step boundaries, from start to stop."""
if step <= 0 or not math.isfinite(start) or not math.isfinite(stop): if step <= 0 or not math.isfinite(start) or not math.isfinite(stop):
@@ -682,11 +783,15 @@ class VerticalProfileDecorator:
Opens a dedicated SpaceView3D window in front orthographic mode and draws the Opens a dedicated SpaceView3D window in front orthographic mode and draws the
IfcGradientCurve as a distance-along vs. elevation plot with a configurable IfcGradientCurve as a distance-along vs. elevation plot with a configurable
vertical exaggeration factor. Middle-mouse pan/zoom are handled by Blender's vertical exaggeration factor. Middle-mouse pan/zoom are handled by Blender's
native orthographic navigation. native orthographic navigation; orbit/rotate is continuously suppressed by
operator._profile_rotation_guard_tick (a bpy.app.timers poll) so the view
stays locked front-on.
Coordinate mapping inside the 3D viewport: Coordinate mapping inside the 3D viewport:
world X = distance along alignment world X = distance along alignment
world Z = elevation × vertical_exaggeration world Z = elevation, normalized into the elevation zone so it always
fills the viewport proportionally (see fit_view/_ez) --
there is no user-facing vertical exaggeration factor
world Y = 0 (orthographic front view collapses the depth axis) world Y = 0 (orthographic front view collapses the depth axis)
""" """
@@ -773,7 +878,7 @@ class VerticalProfileDecorator:
COLOR_GRID = (0.27, 0.27, 0.27, 1.0) # Subtle dark-gray grid COLOR_GRID = (0.27, 0.27, 0.27, 1.0) # Subtle dark-gray grid
COLOR_PROFILE = (0.25, 0.85, 0.45, 1.0) # Green profile curve COLOR_PROFILE = (0.25, 0.85, 0.45, 1.0) # Green profile curve
COLOR_BOUNDARY = (0.90, 0.85, 0.25, 1.0) # Yellow segment ticks COLOR_BOUNDARY = (0.90, 0.85, 0.25, 1.0) # Yellow segment boundary dots
COLOR_LABEL = (0.80, 0.80, 0.80, 1.0) # Light-gray axis labels COLOR_LABEL = (0.80, 0.80, 0.80, 1.0) # Light-gray axis labels
COLOR_AXIS_TITLE = (0.65, 0.65, 0.65, 1.0) COLOR_AXIS_TITLE = (0.65, 0.65, 0.65, 1.0)
COLOR_HEADER = (0.95, 0.95, 0.95, 1.0) COLOR_HEADER = (0.95, 0.95, 0.95, 1.0)
@@ -784,7 +889,6 @@ class VerticalProfileDecorator:
COLOR_GRAD = (0.75, 0.95, 0.75, 1.0) # Light green — gradient endpoints COLOR_GRAD = (0.75, 0.95, 0.75, 1.0) # Light green — gradient endpoints
LINE_GRID = 1.0 LINE_GRID = 1.0
LINE_PROFILE = 2.5 LINE_PROFILE = 2.5
LINE_BOUNDARY = 1.2
LINE_TANGENT_VERT = 1.0 LINE_TANGENT_VERT = 1.0
# ------------------------------------------------------------------ public # ------------------------------------------------------------------ public
@@ -867,7 +971,7 @@ class VerticalProfileDecorator:
pass pass
@classmethod @classmethod
def fit_view(cls, space, ve: float, area_width: int = 1920, area_height: int = 400) -> None: def fit_view(cls, space, area_width: int = 1920, area_height: int = 400) -> None:
"""Reposition the profile camera so both zones always fill the viewport proportionally. """Reposition the profile camera so both zones always fill the viewport proportionally.
Zone heights are derived from the area aspect ratio so they remain visible Zone heights are derived from the area aspect ratio so they remain visible
@@ -1318,7 +1422,6 @@ class VerticalProfileDecorator:
try: try:
props = bpy.context.scene.CivilAlignmentProperties props = bpy.context.scene.CivilAlignmentProperties
ve = props.vertical_exaggeration
except Exception: except Exception:
return return
@@ -1471,21 +1574,24 @@ class VerticalProfileDecorator:
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges) batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges)
batch.draw(shader) batch.draw(shader)
# --- Segment boundary ticks ------------------------------------------ # --- Segment boundary dots --------------------------------------------
tick = max((cls.elev_zone_top - cls.elev_zone_bot) * 0.04, 0.5) # Sized in screen pixels (like lineWidth) rather than world units, so
shader.uniform_float("lineWidth", cls.LINE_BOUNDARY) # the dot stays a fixed on-screen size — matching the profile line's
shader.uniform_float("color", cls.COLOR_BOUNDARY) # weight — instead of ballooning at zoomed-in scales.
for info in cls.segments_info: world_per_px = (vis_d_max - vis_d_min) / max(region.width, 1)
if visible_ids is not None and info.get("vertical_id", -1) not in visible_ids: dot_r = cls.LINE_PROFILE * world_per_px
continue dot_centers = [
d = info["dist"] (info["dist"], cls._ez(info["height"]))
z = cls._ez(info["height"]) for info in cls.segments_info
batch = batch_for_shader( if visible_ids is None or info.get("vertical_id", -1) in visible_ids
shader, "LINES", ]
{"pos": [(d, 0.0, z - tick), (d, 0.0, z + tick)]}, if dot_centers:
indices=[[0, 1]], dot_verts, dot_indices = _dot_tris(dot_centers, dot_r)
) dot_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch.draw(shader) dot_shader.bind()
dot_shader.uniform_float("color", cls.COLOR_BOUNDARY)
batch_for_shader(dot_shader, "TRIS", {"pos": dot_verts}, indices=dot_indices).draw(dot_shader)
shader.bind()
# --- Vertical curve tangent lines (BVC→PVI and EVC→PVI) -------------- # --- Vertical curve tangent lines (BVC→PVI and EVC→PVI) --------------
shader.uniform_float("lineWidth", cls.LINE_TANGENT_VERT) shader.uniform_float("lineWidth", cls.LINE_TANGENT_VERT)
@@ -1636,20 +1742,24 @@ class VerticalProfileDecorator:
edges = [[i, i + 1] for i in range(len(verts) - 1)] edges = [[i, i + 1] for i in range(len(verts) - 1)]
batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges).draw(shader) batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges).draw(shader)
# Cant segment boundary ticks # Cant segment boundary dots (same fixed screen-pixel sizing as the
tick_c = cant_h * 0.04 # elevation zone's — world_per_px is shared across the whole view).
shader.uniform_float("lineWidth", cls.LINE_BOUNDARY) dot_r_c = cls.LINE_PROFILE * world_per_px
shader.uniform_float("color", cls.COLOR_BOUNDARY) cant_dot_centers = [
for info in cls.cant_info: (info["dist"], _cz(info["start_cant"]))
if info.get("rail") == "C": for info in cls.cant_info
continue if info.get("rail") != "C"
if visible_cant_ids is not None and info.get("cant_id", -1) not in visible_cant_ids: and (visible_cant_ids is None or info.get("cant_id", -1) in visible_cant_ids)
continue ]
d = info["dist"] if cant_dot_centers:
gz = _cz(info["start_cant"]) cant_dot_verts, cant_dot_indices = _dot_tris(cant_dot_centers, dot_r_c)
batch_for_shader(shader, "LINES", cant_dot_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
{"pos": [(d, 0.0, gz - tick_c), (d, 0.0, gz + tick_c)]}, cant_dot_shader.bind()
indices=[[0, 1]]).draw(shader) cant_dot_shader.uniform_float("color", cls.COLOR_BOUNDARY)
batch_for_shader(
cant_dot_shader, "TRIS", {"pos": cant_dot_verts}, indices=cant_dot_indices
).draw(cant_dot_shader)
shader.bind()
# Cant subplot — full 4-sided border box (right side = cant Y-axis) # Cant subplot — full 4-sided border box (right side = cant Y-axis)
shader.uniform_float("lineWidth", 1.5) shader.uniform_float("lineWidth", 1.5)
@@ -1694,7 +1804,6 @@ class VerticalProfileDecorator:
try: try:
props = bpy.context.scene.CivilAlignmentProperties props = bpy.context.scene.CivilAlignmentProperties
ve = props.vertical_exaggeration
except Exception: except Exception:
return return
@@ -1736,7 +1845,7 @@ class VerticalProfileDecorator:
blf.size(font_id, tool.Blender.scale_font_size(13)) blf.size(font_id, tool.Blender.scale_font_size(13))
blf.color(font_id, *cls.COLOR_HEADER) blf.color(font_id, *cls.COLOR_HEADER)
blf.position(font_id, 16, region.height - 28, 0) blf.position(font_id, 16, region.height - 28, 0)
blf.draw(font_id, f"Vertical Profile — {cls.alignment_name} VE = {ve:.0f}×") blf.draw(font_id, f"Vertical Profile — {cls.alignment_name}")
# --- Station axis labels (horizontal, along the bottom) -------------- # --- Station axis labels (horizontal, along the bottom) --------------
BOTTOM_MARGIN = 28 # px from bottom for label baseline BOTTOM_MARGIN = 28 # px from bottom for label baseline
@@ -21,6 +21,7 @@
import bpy import bpy
import blf import blf
import math
import time import time
import bonsai.core.alignment as core import bonsai.core.alignment as core
import bonsai.tool as tool import bonsai.tool as tool
@@ -31,6 +32,7 @@ from bpy_extras.io_utils import ImportHelper
from bpy.types import Operator, SpaceView3D from bpy.types import Operator, SpaceView3D
from bpy.props import StringProperty, FloatProperty, EnumProperty, IntProperty, BoolProperty from bpy.props import StringProperty, FloatProperty, EnumProperty, IntProperty, BoolProperty
from . import decorator as alignment_decorator from . import decorator as alignment_decorator
from . import prop
from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.model.decorator import PolylineDecorator from bonsai.bim.module.model.decorator import PolylineDecorator
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
@@ -1070,14 +1072,64 @@ def _refresh_vertical_profile_view(context, alignment):
if not dec.is_installed: if not dec.is_installed:
return return
dec._compute_profile(alignment) dec._compute_profile(alignment)
ve = context.scene.CivilAlignmentProperties.vertical_exaggeration
space = next((s for s in dec.profile_area.spaces if s.type == "VIEW_3D"), None) space = next((s for s in dec.profile_area.spaces if s.type == "VIEW_3D"), None)
if space is not None: if space is not None:
dec.fit_view(space, ve, area_width=dec.profile_area.width, area_height=dec.profile_area.height) dec.fit_view(space, area_width=dec.profile_area.width, area_height=dec.profile_area.height)
_sync_profile_visibility_items(context, dec) _sync_profile_visibility_items(context, dec)
dec.tag_redraw() dec.tag_redraw()
_PROFILE_ROTATION_GUARD_INTERVAL = 0.05
def _profile_rotation_guard_tick():
"""bpy.app.timers callback that keeps the docked vertical profile view
locked to front-orthographic.
Blender's native MMB-drag orbit still runs — there's no per-area way to
disable it without hijacking the global 3D-view keymap, which would also
affect the main viewport — so instead this polls at a short interval and
snaps view_rotation/view_perspective back the moment they drift away
from the locked front-ortho orientation. Pan and zoom (view_location/
view_distance) are left untouched. Returning None stops the timer;
returning a float reschedules it after that many seconds.
"""
import mathutils
dec = alignment_decorator.VerticalProfileDecorator
if not dec.is_installed or dec.profile_area_ptr == 0:
return None
area = next((a for a in bpy.context.screen.areas if a.as_pointer() == dec.profile_area_ptr), None)
if area is None:
# Profile area vanished outside of ALIGN_OT_show_vertical_profile
# (e.g. dragged/merged away) — stop polling rather than leak a timer.
return None
space = next((s for s in area.spaces if s.type == "VIEW_3D"), None)
if space is None:
return _PROFILE_ROTATION_GUARD_INTERVAL
rv3d = space.region_3d
locked_rotation = mathutils.Quaternion((0.7071068, 0.7071068, 0.0, 0.0))
changed = False
if rv3d.view_perspective != "ORTHO":
rv3d.view_perspective = "ORTHO"
changed = True
if rv3d.view_rotation.rotation_difference(locked_rotation).angle > 1e-4:
rv3d.view_rotation = locked_rotation
changed = True
if changed:
area.tag_redraw()
return _PROFILE_ROTATION_GUARD_INTERVAL
def _start_profile_rotation_guard():
if not bpy.app.timers.is_registered(_profile_rotation_guard_tick):
bpy.app.timers.register(_profile_rotation_guard_tick, first_interval=_PROFILE_ROTATION_GUARD_INTERVAL)
def _open_vertical_profile(context, alignment): def _open_vertical_profile(context, alignment):
"""Open (or refresh) the docked vertical profile view for ``alignment``. """Open (or refresh) the docked vertical profile view for ``alignment``.
@@ -1094,10 +1146,9 @@ def _open_vertical_profile(context, alignment):
# Already open — just refresh the data (the active alignment may # Already open — just refresh the data (the active alignment may
# have changed) and re-fit the view. # have changed) and re-fit the view.
dec._compute_profile(alignment) dec._compute_profile(alignment)
ve = context.scene.CivilAlignmentProperties.vertical_exaggeration
space = next((s for s in dec.profile_area.spaces if s.type == "VIEW_3D"), None) space = next((s for s in dec.profile_area.spaces if s.type == "VIEW_3D"), None)
if space is not None: if space is not None:
dec.fit_view(space, ve, area_width=dec.profile_area.width, area_height=dec.profile_area.height) dec.fit_view(space, area_width=dec.profile_area.width, area_height=dec.profile_area.height)
_sync_profile_visibility_items(context, dec) _sync_profile_visibility_items(context, dec)
dec.profile_area.tag_redraw() dec.profile_area.tag_redraw()
return dec.profile_area return dec.profile_area
@@ -1141,8 +1192,7 @@ def _open_vertical_profile(context, alignment):
space.region_3d.view_perspective = "ORTHO" space.region_3d.view_perspective = "ORTHO"
space.region_3d.view_rotation = mathutils.Quaternion((0.7071068, 0.7071068, 0.0, 0.0)) space.region_3d.view_rotation = mathutils.Quaternion((0.7071068, 0.7071068, 0.0, 0.0))
ve = context.scene.CivilAlignmentProperties.vertical_exaggeration dec.fit_view(space, area_width=profile_area.width, area_height=profile_area.height)
dec.fit_view(space, ve, area_width=profile_area.width, area_height=profile_area.height)
space.overlay.show_floor = False space.overlay.show_floor = False
space.overlay.show_axis_x = False space.overlay.show_axis_x = False
@@ -1160,6 +1210,7 @@ def _open_vertical_profile(context, alignment):
dec.install(context, profile_area) dec.install(context, profile_area)
profile_area.tag_redraw() profile_area.tag_redraw()
_start_profile_rotation_guard()
return profile_area return profile_area
@@ -1513,6 +1564,586 @@ class ALIGN_OT_clear_vertical_pi_markers(Operator):
return {"FINISHED"} return {"FINISHED"}
# =============================================================================
# Segment Table Editing ("stage edits, then Apply")
# =============================================================================
_SEGMENT_KIND_ITEMS = [
("HORIZONTAL", "Horizontal", ""),
("VERTICAL", "Vertical", ""),
("CANT", "Cant", ""),
]
_SEGMENT_ROW_FIELD = {
"HORIZONTAL": "h_segment_rows",
"VERTICAL": "v_segment_rows",
"CANT": "cant_segment_rows",
}
_SEGMENT_ROW_ACTIVE_INDEX_FIELD = {
"HORIZONTAL": "active_h_segment_row_index",
"VERTICAL": "active_v_segment_row_index",
"CANT": "active_cant_segment_row_index",
}
class ALIGN_OT_add_segment_row(Operator):
"""Add a new segment row to the currently-staged edit table"""
bl_idname = "align.add_segment_row"
bl_label = "Add Segment"
bl_description = "Insert a new segment after the selected row"
bl_options = {"REGISTER", "UNDO"}
kind: EnumProperty(items=_SEGMENT_KIND_ITEMS, options={"HIDDEN"})
def execute(self, context):
props = context.scene.CivilAlignmentProperties
rows = getattr(props, _SEGMENT_ROW_FIELD[self.kind])
index_attr = _SEGMENT_ROW_ACTIVE_INDEX_FIELD[self.kind]
active_index = getattr(props, index_attr)
insert_at = active_index + 1 if len(rows) else 0
prev_row = rows[active_index] if 0 <= active_index < len(rows) else None
rows.add()
rows.move(len(rows) - 1, insert_at)
new_row = rows[insert_at]
if self.kind == "VERTICAL":
new_row.predefined_type = "CONSTANTGRADIENT"
new_row.h_length = 10.0
seed = prev_row.end_gradient if prev_row else 0.0
new_row.start_gradient = seed
new_row.end_gradient = seed
elif self.kind == "CANT":
new_row.predefined_type = "CONSTANTCANT"
new_row.h_length = 10.0
seed_left = prev_row.end_cant_left if prev_row else 0.0
seed_right = prev_row.end_cant_right if prev_row else 0.0
new_row.start_cant_left = seed_left
new_row.start_cant_right = seed_right
new_row.end_cant_left = seed_left
new_row.end_cant_right = seed_right
else:
new_row.predefined_type = "LINE"
new_row.length = 10.0
setattr(props, index_attr, insert_at)
return {"FINISHED"}
class ALIGN_OT_remove_segment_row(Operator):
"""Remove the selected row from the currently-staged edit table"""
bl_idname = "align.remove_segment_row"
bl_label = "Remove Segment"
bl_description = "Remove the selected row"
bl_options = {"REGISTER", "UNDO"}
kind: EnumProperty(items=_SEGMENT_KIND_ITEMS, options={"HIDDEN"})
def execute(self, context):
props = context.scene.CivilAlignmentProperties
rows = getattr(props, _SEGMENT_ROW_FIELD[self.kind])
index_attr = _SEGMENT_ROW_ACTIVE_INDEX_FIELD[self.kind]
active_index = getattr(props, index_attr)
if not (0 <= active_index < len(rows)):
return {"CANCELLED"}
rows.remove(active_index)
setattr(props, index_attr, max(0, min(active_index, len(rows) - 1)))
return {"FINISHED"}
class ALIGN_OT_move_segment_row(Operator):
"""Reorder the selected row in the currently-staged edit table"""
bl_idname = "align.move_segment_row"
bl_label = "Move Segment"
bl_description = "Move the selected row up or down"
bl_options = {"REGISTER", "UNDO"}
kind: EnumProperty(items=_SEGMENT_KIND_ITEMS, options={"HIDDEN"})
direction: EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")], options={"HIDDEN"})
def execute(self, context):
props = context.scene.CivilAlignmentProperties
rows = getattr(props, _SEGMENT_ROW_FIELD[self.kind])
index_attr = _SEGMENT_ROW_ACTIVE_INDEX_FIELD[self.kind]
active_index = getattr(props, index_attr)
target = active_index - 1 if self.direction == "UP" else active_index + 1
if not (0 <= target < len(rows)):
return {"CANCELLED"}
rows.move(active_index, target)
setattr(props, index_attr, target)
return {"FINISHED"}
class ALIGN_OT_enable_editing_h_segments(Operator):
"""Stage a horizontal layout's segments for table editing"""
bl_idname = "align.enable_editing_h_segments"
bl_label = "Edit Horizontal Segments"
bl_description = "Edit this layout's segments as a table (add/remove/reorder/edit, then Apply)"
bl_options = {"REGISTER", "UNDO"}
layout_id: IntProperty(options={"HIDDEN"})
@classmethod
def poll(cls, context):
props = context.scene.CivilAlignmentProperties
if props.editing_segment_kind not in ("NONE", "HORIZONTAL"):
cls.poll_message_set("Finish or cancel the current segment edit first")
return False
return True
def execute(self, context):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
h_layout = ifc_file.by_id(self.layout_id)
# IfcAlignmentHorizontalSegment's length/radius fields are in the
# project's own length unit (e.g. feet), but a Blender FloatProperty
# tagged unit="LENGTH" always treats its raw stored value as being in
# Blender's internal unit (metres, scale_length=1.0) and converts
# from there for display -- so the IFC value must be scaled into
# that space first, or a foot-based project shows numbers inflated
# by ~3.28x (1/0.3048). Reversed on write-back in apply_h_segments.
length_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "LENGTHUNIT")
props.h_segment_rows.clear()
for seg in tool.Alignment.get_real_layout_segments(h_layout):
dp = seg.DesignParameters
row = props.h_segment_rows.add()
row.segment_id = seg.id()
seg_type = dp.PredefinedType
if seg_type in prop.SUPPORTED_HORIZONTAL_TYPES:
row.predefined_type = seg_type
else:
row.predefined_type = "UNSUPPORTED"
row.original_predefined_type = seg_type or "?"
row.length = abs(dp.SegmentLength) * length_scale
row.start_radius = (dp.StartRadiusOfCurvature or 0.0) * length_scale
row.end_radius = (dp.EndRadiusOfCurvature or 0.0) * length_scale
props.active_h_segment_row_index = 0
props.editing_segment_kind = "HORIZONTAL"
props.editing_layout_id = self.layout_id
# The read-only "#" toggle disappears once the table replaces it, so
# clear any stale highlight now -- nothing else could turn it off
# while the table is showing.
props.selected_h_segment_id = 0
alignment_decorator.AlignmentSegmentDecorator.uninstall()
tool.Blender.update_viewport()
return {"FINISHED"}
class ALIGN_OT_disable_editing_h_segments(Operator):
"""Discard the staged horizontal segment edits without touching IFC"""
bl_idname = "align.disable_editing_h_segments"
bl_label = "Cancel"
bl_description = "Discard these changes"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = context.scene.CivilAlignmentProperties
props.h_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
return {"FINISHED"}
class ALIGN_OT_apply_h_segments(Operator, tool.Ifc.Operator):
"""Rebuild a horizontal layout from the staged segment table"""
bl_idname = "align.apply_h_segments"
bl_label = "Apply"
bl_description = "Rebuild this layout's segments from the table above"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
h_layout = ifc_file.by_id(props.editing_layout_id)
if h_layout is None:
self.report({"ERROR"}, "The layout being edited no longer exists")
props.h_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
return {"CANCELLED"}
rows = props.h_segment_rows
errors = tool.Alignment.validate_horizontal_segment_rows(rows)
if errors:
self.report({"ERROR"}, "; ".join(errors))
return {"CANCELLED"}
alignment = tool.Alignment._get_top_level_alignment(ifcopenshell.api.alignment.get_alignment(h_layout))
# The overall start point/direction is preserved as-is (moving it is
# a separate feature, REQUIREMENTS §4) -- read it straight off the
# current first real segment before wiping anything.
existing = tool.Alignment.get_real_layout_segments(h_layout)
if existing:
first_dp = existing[0].DesignParameters
x, y = first_dp.StartPoint.Coordinates
direction = first_dp.StartDirection
else:
x, y, direction = 0.0, 0.0, 0.0
length_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "LENGTHUNIT")
angle_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "PLANEANGLEUNIT")
ifcopenshell.api.alignment.clear_layout_segments(ifc_file, h_layout)
for row in rows:
# row.length/start_radius/end_radius are in Blender's internal
# unit="LENGTH" space (metres) -- see enable_editing_h_segments'
# own comment -- so they're converted back to the project's
# length unit here before going into IFC.
row_length = row.length / length_scale
if row.predefined_type == "LINE":
start_radius, end_radius = 0.0, 0.0
elif row.predefined_type == "CIRCULARARC":
start_radius, end_radius = row.start_radius / length_scale, row.start_radius / length_scale
else: # spiral family (CLOTHOID/CUBIC/HELMERTCURVE/BLOSSCURVE/COSINECURVE/SINECURVE)
start_radius, end_radius = row.start_radius / length_scale, row.end_radius / length_scale
design_parameters = ifc_file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=ifc_file.createIfcCartesianPoint((x, y)),
StartDirection=direction,
StartRadiusOfCurvature=start_radius,
EndRadiusOfCurvature=end_radius,
SegmentLength=row_length,
GravityCenterLineHeight=None,
PredefinedType=row.predefined_type,
)
placement = ifcopenshell.api.alignment.create_layout_segment(ifc_file, h_layout, design_parameters)
x = float(placement[0, 3]) / length_scale
y = float(placement[1, 3]) / length_scale
# atan2, not atan(Rdy/Rdx) (what ifcopenshell's own internal
# _update_zero_length_segment_placement uses) -- atan can't tell
# a segment pointing north from one pointing south when Rdx≈0.
direction = math.atan2(float(placement[1, 0]), float(placement[0, 0])) / angle_scale
ifcopenshell.api.alignment.create_representation(ifc_file, alignment)
tool.Alignment.refresh_alignment_representation_object(alignment)
_refresh_vertical_profile_view(context, alignment)
# Every segment id in this layout just changed.
props.selected_h_segment_id = 0
alignment_decorator.AlignmentSegmentDecorator.uninstall()
n = len(rows)
props.h_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
tool.Blender.update_viewport()
self.report({"INFO"}, f"Rebuilt {n} horizontal segment(s)")
return {"FINISHED"}
class ALIGN_OT_enable_editing_v_segments(Operator):
"""Stage a vertical layout's segments for table editing"""
bl_idname = "align.enable_editing_v_segments"
bl_label = "Edit Vertical Segments"
bl_description = "Edit this layout's segments as a table (add/remove/reorder/edit, then Apply)"
bl_options = {"REGISTER", "UNDO"}
layout_id: IntProperty(options={"HIDDEN"})
@classmethod
def poll(cls, context):
props = context.scene.CivilAlignmentProperties
if props.editing_segment_kind not in ("NONE", "VERTICAL"):
cls.poll_message_set("Finish or cancel the current segment edit first")
return False
return True
def execute(self, context):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
v_layout = ifc_file.by_id(self.layout_id)
# See enable_editing_h_segments' comment: a unit="LENGTH" FloatProperty
# always treats its raw value as Blender-internal metres, so the
# project-unit IFC value must be scaled into that space here.
length_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "LENGTHUNIT")
props.v_segment_rows.clear()
for seg in tool.Alignment.get_real_layout_segments(v_layout):
dp = seg.DesignParameters
row = props.v_segment_rows.add()
row.segment_id = seg.id()
seg_type = dp.PredefinedType
if seg_type in prop.SUPPORTED_VERTICAL_TYPES:
row.predefined_type = seg_type
else:
row.predefined_type = "UNSUPPORTED"
row.original_predefined_type = seg_type or "?"
row.h_length = dp.HorizontalLength * length_scale
row.start_gradient = (dp.StartGradient or 0.0) * 100.0
row.end_gradient = (dp.EndGradient or 0.0) * 100.0
props.active_v_segment_row_index = 0
props.editing_segment_kind = "VERTICAL"
props.editing_layout_id = self.layout_id
props.selected_v_segment_id = 0
tool.Blender.update_viewport()
return {"FINISHED"}
class ALIGN_OT_disable_editing_v_segments(Operator):
"""Discard the staged vertical segment edits without touching IFC"""
bl_idname = "align.disable_editing_v_segments"
bl_label = "Cancel"
bl_description = "Discard these changes"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = context.scene.CivilAlignmentProperties
props.v_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
return {"FINISHED"}
class ALIGN_OT_apply_v_segments(Operator, tool.Ifc.Operator):
"""Rebuild a vertical layout from the staged segment table"""
bl_idname = "align.apply_v_segments"
bl_label = "Apply"
bl_description = "Rebuild this layout's segments from the table above"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
v_layout = ifc_file.by_id(props.editing_layout_id)
if v_layout is None:
self.report({"ERROR"}, "The layout being edited no longer exists")
props.v_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
return {"CANCELLED"}
rows = props.v_segment_rows
errors = tool.Alignment.validate_vertical_segment_rows(rows)
if errors:
self.report({"ERROR"}, "; ".join(errors))
return {"CANCELLED"}
alignment = tool.Alignment._get_top_level_alignment(ifcopenshell.api.alignment.get_alignment(v_layout))
existing = tool.Alignment.get_real_layout_segments(v_layout)
if existing:
first_dp = existing[0].DesignParameters
dist_along, height = first_dp.StartDistAlong, first_dp.StartHeight
else:
dist_along, height = 0.0, 0.0
# row.h_length is in Blender's internal unit="LENGTH" space (metres) --
# see enable_editing_v_segments -- convert back to the project's
# length unit before mixing it with dist_along/height (already in
# project units, read straight from IFC above).
length_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "LENGTHUNIT")
ifcopenshell.api.alignment.clear_layout_segments(ifc_file, v_layout)
for row in rows:
h_length = row.h_length / length_scale
start_gradient = row.start_gradient / 100.0
end_gradient = (
row.end_gradient / 100.0 if row.predefined_type in prop.VERTICAL_TWO_GRADIENT_TYPES else start_gradient
)
design_parameters = ifc_file.createIfcAlignmentVerticalSegment(
StartTag=None,
EndTag=None,
StartDistAlong=dist_along,
HorizontalLength=h_length,
StartHeight=height,
StartGradient=start_gradient,
EndGradient=end_gradient,
RadiusOfCurvature=None,
PredefinedType=row.predefined_type,
)
placement = ifcopenshell.api.alignment.create_layout_segment(ifc_file, v_layout, design_parameters)
# Read the next segment's start state back off the kernel-evaluated
# end placement, rather than the closed-form "average gradient"
# shortcut -- that's only exact for CONSTANTGRADIENT/PARABOLICARC.
# A CIRCULARARC's height doesn't vary linearly enough for it to
# hold (confirmed by direct comparison against this same
# evaluation): using the real placement keeps every type exact.
dist_along = float(placement[0, 3]) / length_scale
height = float(placement[1, 3]) / length_scale
ifcopenshell.api.alignment.create_representation(ifc_file, alignment)
tool.Alignment.refresh_alignment_representation_object(alignment)
_refresh_vertical_profile_view(context, alignment)
props.selected_v_segment_id = 0
n = len(rows)
props.v_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
tool.Blender.update_viewport()
self.report({"INFO"}, f"Rebuilt {n} vertical segment(s)")
return {"FINISHED"}
class ALIGN_OT_enable_editing_cant_segments(Operator):
"""Stage a cant layout's segments for table editing"""
bl_idname = "align.enable_editing_cant_segments"
bl_label = "Edit Cant Segments"
bl_description = "Edit this layout's segments as a table (add/remove/reorder/edit, then Apply)"
bl_options = {"REGISTER", "UNDO"}
layout_id: IntProperty(options={"HIDDEN"})
@classmethod
def poll(cls, context):
props = context.scene.CivilAlignmentProperties
if props.editing_segment_kind not in ("NONE", "CANT"):
cls.poll_message_set("Finish or cancel the current segment edit first")
return False
return True
def execute(self, context):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
c_layout = ifc_file.by_id(self.layout_id)
# See enable_editing_h_segments' comment: a unit="LENGTH" FloatProperty
# always treats its raw value as Blender-internal metres, so the
# project-unit IFC value must be scaled into that space here.
length_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "LENGTHUNIT")
props.cant_segment_rows.clear()
for seg in tool.Alignment.get_real_layout_segments(c_layout):
dp = seg.DesignParameters
row = props.cant_segment_rows.add()
row.segment_id = seg.id()
seg_type = dp.PredefinedType
if seg_type in ("CONSTANTCANT", "LINEARTRANSITION"):
row.predefined_type = seg_type
else:
row.predefined_type = "UNSUPPORTED"
row.original_predefined_type = seg_type or "?"
row.h_length = dp.HorizontalLength * length_scale
start_l = dp.StartCantLeft or 0.0
start_r = dp.StartCantRight or 0.0
row.start_cant_left = start_l
row.start_cant_right = start_r
row.end_cant_left = dp.EndCantLeft if dp.EndCantLeft is not None else start_l
row.end_cant_right = dp.EndCantRight if dp.EndCantRight is not None else start_r
props.active_cant_segment_row_index = 0
props.editing_segment_kind = "CANT"
props.editing_layout_id = self.layout_id
props.selected_cant_segment_id = 0
tool.Blender.update_viewport()
return {"FINISHED"}
class ALIGN_OT_disable_editing_cant_segments(Operator):
"""Discard the staged cant segment edits without touching IFC"""
bl_idname = "align.disable_editing_cant_segments"
bl_label = "Cancel"
bl_description = "Discard these changes"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = context.scene.CivilAlignmentProperties
props.cant_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
return {"FINISHED"}
class ALIGN_OT_apply_cant_segments(Operator, tool.Ifc.Operator):
"""Rebuild a cant layout from the staged segment table"""
bl_idname = "align.apply_cant_segments"
bl_label = "Apply"
bl_description = "Rebuild this layout's segments from the table above"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
c_layout = ifc_file.by_id(props.editing_layout_id)
if c_layout is None:
self.report({"ERROR"}, "The layout being edited no longer exists")
props.cant_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
return {"CANCELLED"}
rows = props.cant_segment_rows
errors = tool.Alignment.validate_cant_segment_rows(rows)
if errors:
self.report({"ERROR"}, "; ".join(errors))
return {"CANCELLED"}
alignment = tool.Alignment._get_top_level_alignment(ifcopenshell.api.alignment.get_alignment(c_layout))
existing = tool.Alignment.get_real_layout_segments(c_layout)
dist_along = existing[0].DesignParameters.StartDistAlong if existing else 0.0
# row.h_length is in Blender's internal unit="LENGTH" space (metres) --
# see enable_editing_cant_segments -- convert back to the project's
# length unit before mixing it with dist_along (already in project
# units, read straight from IFC above).
length_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "LENGTHUNIT")
ifcopenshell.api.alignment.clear_layout_segments(ifc_file, c_layout)
for row in rows:
h_length = row.h_length / length_scale
is_transition = row.predefined_type == "LINEARTRANSITION"
design_parameters = ifc_file.createIfcAlignmentCantSegment(
StartTag=None,
EndTag=None,
StartDistAlong=dist_along,
HorizontalLength=h_length,
StartCantLeft=row.start_cant_left,
EndCantLeft=row.end_cant_left if is_transition else None,
StartCantRight=row.start_cant_right,
EndCantRight=row.end_cant_right if is_transition else None,
PredefinedType=row.predefined_type,
)
ifcopenshell.api.alignment.create_layout_segment(ifc_file, c_layout, design_parameters)
dist_along += h_length
ifcopenshell.api.alignment.create_representation(ifc_file, alignment)
tool.Alignment.refresh_alignment_representation_object(alignment)
_refresh_vertical_profile_view(context, alignment)
props.selected_cant_segment_id = 0
n = len(rows)
props.cant_segment_rows.clear()
props.editing_segment_kind = "NONE"
props.editing_layout_id = 0
tool.Blender.update_viewport()
self.report({"INFO"}, f"Rebuilt {n} cant segment(s)")
return {"FINISHED"}
# ============================================================================= # =============================================================================
# Segment Selection Operator # Segment Selection Operator
# ============================================================================= # =============================================================================
+137 -17
View File
@@ -133,12 +133,6 @@ def _on_cant_visibility_update(self, context):
VerticalProfileDecorator.tag_redraw() VerticalProfileDecorator.tag_redraw()
def _on_ve_update(self, context):
from .decorator import VerticalProfileDecorator
VerticalProfileDecorator.tag_redraw()
class CantAlignmentItem(PropertyGroup): class CantAlignmentItem(PropertyGroup):
"""Tracks one IfcAlignmentCant available in the profile view.""" """Tracks one IfcAlignmentCant available in the profile view."""
@@ -184,6 +178,117 @@ class VerticalPIMarker(PropertyGroup):
) )
# Horizontal spiral transition curve families that _map_alignment_horizontal_segment
# (ifcopenshell.api.alignment) maps to real geometry, all sharing the exact same
# DesignParameters shape as CLOTHOID (StartPoint/StartDirection/StartRadiusOfCurvature/
# EndRadiusOfCurvature/SegmentLength -- no extra fields), so the table can treat every
# one of them identically to CLOTHOID. VIENNESEBEND is intentionally excluded: its
# geometry additionally depends on the alignment's CANT segment at the same station
# (rail cant angle, gravity centerline height) and a GravityCenterLineHeight field this
# table has no place for -- editing it here could silently desync it from its cant data.
HORIZONTAL_SPIRAL_TYPES = ("CLOTHOID", "CUBIC", "HELMERTCURVE", "BLOSSCURVE", "COSINECURVE", "SINECURVE")
SUPPORTED_HORIZONTAL_TYPES = ("LINE", "CIRCULARARC") + HORIZONTAL_SPIRAL_TYPES
# Vertical types whose EndGradient can genuinely differ from StartGradient
# (a CONSTANTGRADIENT segment always has EndGradient == StartGradient by
# definition, so it gets no separate "G Out" field).
SUPPORTED_VERTICAL_TYPES = ("CONSTANTGRADIENT", "PARABOLICARC", "CIRCULARARC")
VERTICAL_TWO_GRADIENT_TYPES = ("PARABOLICARC", "CIRCULARARC")
class HorizontalSegmentRow(PropertyGroup):
"""One staged edit to a horizontal alignment segment, for the
Alignment Segments table's "stage edits, then Apply" editing flow (see
ALIGN_OT_enable_editing_h_segments / ALIGN_OT_apply_h_segments).
segment_id is the originating IfcAlignmentSegment's entity id (0 for a
row added during this edit session, with no IFC counterpart yet) — used
only for provenance/debugging, not read by the Apply operator, which
always rebuilds every segment from scratch in row order.
"""
segment_id: IntProperty(name="Source Segment ID", default=0)
predefined_type: EnumProperty(
name="Type",
items=[
("LINE", "Line", "A straight tangent run"),
("CIRCULARARC", "Circular Arc", "A constant-radius curve"),
("CLOTHOID", "Clothoid", "A spiral transition curve (linear curvature change)"),
("CUBIC", "Cubic", "A spiral transition curve (cubic parabola)"),
("HELMERTCURVE", "Helmert Curve", "A spiral transition curve (sine-based curvature change)"),
("BLOSSCURVE", "Bloss Curve", "A spiral transition curve (S-shaped curvature change)"),
("COSINECURVE", "Cosine Curve", "A spiral transition curve (cosine-based curvature change)"),
("SINECURVE", "Sine Curve", "A spiral transition curve (sine-based curvature change)"),
("UNSUPPORTED", "Unsupported", "A segment type this table can't edit — remove it or fix it in IFC directly"),
],
default="LINE",
)
original_predefined_type: StringProperty(
name="Original Type", description="The real IFC PredefinedType, when it's not one this table supports editing"
)
length: FloatProperty(name="Length", default=10.0, min=0.0001, unit="LENGTH")
start_radius: FloatProperty(name="Radius", default=0.0, unit="LENGTH")
end_radius: FloatProperty(name="End Radius", default=0.0, unit="LENGTH")
class VerticalSegmentRow(PropertyGroup):
"""One staged edit to a vertical alignment segment (see
HorizontalSegmentRow for the general pattern this mirrors).
start_gradient/end_gradient are stored as PERCENT (matching the existing
read-only panel's display, e.g. 2.5 for 2.5%) — the Apply operator must
divide by 100 before writing IfcAlignmentVerticalSegment.StartGradient/
EndGradient, which are unitless ratios.
CIRCULARARC is supported (_map_alignment_vertical_segment implements it,
deriving the true radius from StartGradient/EndGradient/HorizontalLength
rather than reading RadiusOfCurvature) -- CLOTHOID is not (that mapper
raises NotImplementedError), so it's intentionally left off this list.
"""
segment_id: IntProperty(name="Source Segment ID", default=0)
predefined_type: EnumProperty(
name="Type",
items=[
("CONSTANTGRADIENT", "Constant Grade", "A straight tangent grade"),
("PARABOLICARC", "Parabolic", "A parabolic vertical curve"),
("CIRCULARARC", "Circular Arc", "A constant-radius vertical curve"),
("UNSUPPORTED", "Unsupported", "A segment type this table can't edit — remove it or fix it in IFC directly"),
],
default="CONSTANTGRADIENT",
)
original_predefined_type: StringProperty(
name="Original Type", description="The real IFC PredefinedType, when it's not one this table supports editing"
)
h_length: FloatProperty(name="Length", default=10.0, min=0.0001, unit="LENGTH")
start_gradient: FloatProperty(name="G In %", default=0.0, precision=3)
end_gradient: FloatProperty(name="G Out %", default=0.0, precision=3)
class CantSegmentRow(PropertyGroup):
"""One staged edit to a cant alignment segment (see HorizontalSegmentRow
for the general pattern this mirrors)."""
segment_id: IntProperty(name="Source Segment ID", default=0)
predefined_type: EnumProperty(
name="Type",
items=[
("CONSTANTCANT", "Constant Cant", "A constant left/right cant"),
("LINEARTRANSITION", "Linear Transition", "Cant that changes linearly over the segment"),
("UNSUPPORTED", "Unsupported", "A segment type this table can't edit — remove it or fix it in IFC directly"),
],
default="CONSTANTCANT",
)
original_predefined_type: StringProperty(
name="Original Type", description="The real IFC PredefinedType, when it's not one this table supports editing"
)
h_length: FloatProperty(name="Length", default=10.0, min=0.0001, unit="LENGTH")
start_cant_left: FloatProperty(name="Start L", default=0.0)
start_cant_right: FloatProperty(name="Start R", default=0.0)
end_cant_left: FloatProperty(name="End L", default=0.0)
end_cant_right: FloatProperty(name="End R", default=0.0)
class CivilAlignmentProperties(PropertyGroup): class CivilAlignmentProperties(PropertyGroup):
"""Properties for the alignment module""" """Properties for the alignment module"""
@@ -196,17 +301,6 @@ class CivilAlignmentProperties(PropertyGroup):
default=0, default=0,
) )
# Vertical profile window settings
vertical_exaggeration: FloatProperty(
name="Vertical Exaggeration",
description="Multiply elevation differences by this factor for the profile view",
default=10.0,
min=1.0,
max=1000.0,
precision=1,
update=_on_ve_update,
)
# Selected horizontal segment (for viewport highlight) # Selected horizontal segment (for viewport highlight)
selected_h_segment_id: IntProperty( selected_h_segment_id: IntProperty(
name="Selected Horizontal Segment", name="Selected Horizontal Segment",
@@ -258,6 +352,32 @@ class CivilAlignmentProperties(PropertyGroup):
default=True, default=True,
) )
# Segment table editing ("stage edits, then Apply") -- only one of
# horizontal/vertical/cant can be mid-edit at a time; editing_layout_id
# names the specific IfcAlignmentHorizontal/Vertical/Cant entity being
# staged (vertical/cant layouts can have several sibling layouts, e.g.
# "Road Profile" vs "Existing Ground", so the kind alone isn't enough).
editing_segment_kind: EnumProperty(
name="Editing Segments",
items=[
("NONE", "None", ""),
("HORIZONTAL", "Horizontal", ""),
("VERTICAL", "Vertical", ""),
("CANT", "Cant", ""),
],
default="NONE",
)
editing_layout_id: IntProperty(name="Editing Layout ID", default=0)
h_segment_rows: CollectionProperty(type=HorizontalSegmentRow)
active_h_segment_row_index: IntProperty(default=0)
v_segment_rows: CollectionProperty(type=VerticalSegmentRow)
active_v_segment_row_index: IntProperty(default=0)
cant_segment_rows: CollectionProperty(type=CantSegmentRow)
active_cant_segment_row_index: IntProperty(default=0)
class PICurveMarkerProperties(PropertyGroup): class PICurveMarkerProperties(PropertyGroup):
"""Tags a transient Empty object placed at an interior PI while its """Tags a transient Empty object placed at an interior PI while its
+151 -1
View File
@@ -126,6 +126,92 @@ class ALIGN_UL_vertical_pi_markers(UIList):
row.label(text="") row.label(text="")
class ALIGN_UL_h_segments(UIList):
"""Editable table of a horizontal layout's staged segment edits (see
align.enable_editing_h_segments / align.apply_h_segments).
Columns are built via chained split(factor=...) calls -- one field per
step -- matching the read-only segment tables' own technique (see
ALIGN_PT_alignment_segments._draw_horizontal), rather than a plain
row.prop() sequence: a bare row gives every widget an equal share of the
available width, which stretches short numeric fields across the whole
list and looks scattered instead of left-packed.
"""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
row = layout.split(factor=0.08)
row.label(text=str(index + 1))
if item.predefined_type == "UNSUPPORTED":
row.label(text=item.original_predefined_type, icon="ERROR")
return
r2 = row.split(factor=0.35)
r2.prop(item, "predefined_type", text="")
r3 = r2.split(factor=0.30)
r3.prop(item, "length", text="")
if item.predefined_type == "CIRCULARARC":
r4 = r3.split(factor=0.5)
r4.prop(item, "start_radius", text="R")
elif item.predefined_type != "LINE":
r4 = r3.split(factor=0.5)
r4.prop(item, "start_radius", text="R1")
r5 = r4.split(factor=0.5)
r5.prop(item, "end_radius", text="R2")
class ALIGN_UL_v_segments(UIList):
"""Editable table of a vertical layout's staged segment edits (see
align.enable_editing_v_segments / align.apply_v_segments). See
ALIGN_UL_h_segments for why chained split() is used instead of a plain
row.prop() sequence."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
row = layout.split(factor=0.08)
row.label(text=str(index + 1))
if item.predefined_type == "UNSUPPORTED":
row.label(text=item.original_predefined_type, icon="ERROR")
return
r2 = row.split(factor=0.35)
r2.prop(item, "predefined_type", text="")
r3 = r2.split(factor=0.30)
r3.prop(item, "h_length", text="")
r4 = r3.split(factor=0.5)
r4.prop(item, "start_gradient", text="G In")
if item.predefined_type in ("PARABOLICARC", "CIRCULARARC"):
r4.prop(item, "end_gradient", text="G Out")
class ALIGN_UL_cant_segments(UIList):
"""Editable table of a cant layout's staged segment edits (see
align.enable_editing_cant_segments / align.apply_cant_segments). See
ALIGN_UL_h_segments for why chained split() is used instead of a plain
row.prop() sequence."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
row = layout.split(factor=0.08)
row.label(text=str(index + 1))
if item.predefined_type == "UNSUPPORTED":
row.label(text=item.original_predefined_type, icon="ERROR")
return
r2 = row.split(factor=0.30)
r2.prop(item, "predefined_type", text="")
r3 = r2.split(factor=0.25)
r3.prop(item, "h_length", text="")
r4 = r3.split(factor=0.5)
start_pair = r4.row(align=True)
start_pair.prop(item, "start_cant_left", text="SL")
start_pair.prop(item, "start_cant_right", text="SR")
if item.predefined_type == "LINEARTRANSITION":
end_pair = r4.row(align=True)
end_pair.prop(item, "end_cant_left", text="EL")
end_pair.prop(item, "end_cant_right", text="ER")
# ============================================================================= # =============================================================================
# Alignments Tab – Segment Breakdown Panel # Alignments Tab – Segment Breakdown Panel
# ============================================================================= # =============================================================================
@@ -427,7 +513,6 @@ class ALIGN_PT_alignment_segments(Panel):
dec = VerticalProfileDecorator dec = VerticalProfileDecorator
row = layout.row(align=True) row = layout.row(align=True)
row.label(text="Vertical Profile:", icon="FCURVE") row.label(text="Vertical Profile:", icon="FCURVE")
row.prop(props, "vertical_exaggeration", text="VE")
row.operator( row.operator(
"align.show_vertical_profile", text="", "align.show_vertical_profile", text="",
icon="GRAPH", depress=dec.is_installed, icon="GRAPH", depress=dec.is_installed,
@@ -446,6 +531,30 @@ class ALIGN_PT_alignment_segments(Panel):
if seg.is_a("IfcAlignmentSegment"): if seg.is_a("IfcAlignmentSegment"):
yield seg yield seg
def _draw_segment_editor(
self, box, props, uilist_idname, rows_propname, active_index_propname,
kind, apply_idname, cancel_idname,
):
"""Shared "stage edits, then Apply" table UI for one layout's segments
-- an editable UIList + Add/Remove/Move-Up/Move-Down row toolbar +
Apply/Cancel, used identically by the horizontal/vertical/cant
sections while that section is the active edit session (see
align.enable_editing_*_segments)."""
box.template_list(uilist_idname, "", props, rows_propname, props, active_index_propname, rows=5)
toolbar = box.row(align=True)
op = toolbar.operator("align.add_segment_row", text="", icon="ADD")
op.kind = kind
op = toolbar.operator("align.remove_segment_row", text="", icon="REMOVE")
op.kind = kind
op = toolbar.operator("align.move_segment_row", text="", icon="TRIA_UP")
op.kind, op.direction = kind, "UP"
op = toolbar.operator("align.move_segment_row", text="", icon="TRIA_DOWN")
op.kind, op.direction = kind, "DOWN"
apply_row = box.row(align=True)
apply_row.operator(apply_idname, icon="CHECKMARK")
apply_row.operator(cancel_idname, text="", icon="X")
def _draw_horizontal(self, layout, context, layout_entity, alignment_id=0): def _draw_horizontal(self, layout, context, layout_entity, alignment_id=0):
ifc_file = tool.Ifc.get() ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties props = context.scene.CivilAlignmentProperties
@@ -453,6 +562,10 @@ class ALIGN_PT_alignment_segments(Panel):
expanded = _H_EXPANDED.get(alignment_id, True) expanded = _H_EXPANDED.get(alignment_id, True)
box = layout.box() box = layout.box()
is_editing_this = (
props.editing_segment_kind == "HORIZONTAL" and props.editing_layout_id == layout_entity.id()
)
# Collapsible header with label toggle # Collapsible header with label toggle
row = box.row(align=True) row = box.row(align=True)
op = row.operator( op = row.operator(
@@ -462,10 +575,21 @@ class ALIGN_PT_alignment_segments(Panel):
op.alignment_id = alignment_id op.alignment_id = alignment_id
row.label(text="Horizontal", icon="DRIVER_ROTATIONAL_DIFFERENCE") row.label(text="Horizontal", icon="DRIVER_ROTATIONAL_DIFFERENCE")
row.prop(props, "show_h_segment_labels", text="", icon="FONT_DATA") row.prop(props, "show_h_segment_labels", text="", icon="FONT_DATA")
edit_op = row.operator(
"align.enable_editing_h_segments", text="", icon="GREASEPENCIL", depress=is_editing_this
)
edit_op.layout_id = layout_entity.id()
if not expanded: if not expanded:
return return
if is_editing_this:
self._draw_segment_editor(
box, props, "ALIGN_UL_h_segments", "h_segment_rows", "active_h_segment_row_index",
"HORIZONTAL", "align.apply_h_segments", "align.disable_editing_h_segments",
)
return
# Column headers (no separate select column — index cell is the select button) # Column headers (no separate select column — index cell is the select button)
header = box.split(factor=0.08) header = box.split(factor=0.08)
header.label(text="#") header.label(text="#")
@@ -540,6 +664,7 @@ class ALIGN_PT_alignment_segments(Panel):
label = label or layout_entity.Name or f"Vertical #{v_id}" label = label or layout_entity.Name or f"Vertical #{v_id}"
expanded = _V_EXPANDED.get(v_id, True) expanded = _V_EXPANDED.get(v_id, True)
selected_v_id = props.selected_v_segment_id selected_v_id = props.selected_v_segment_id
is_editing_this = props.editing_segment_kind == "VERTICAL" and props.editing_layout_id == v_id
# Eye-icon uses vertical_items (populated when the profile window is open) # Eye-icon uses vertical_items (populated when the profile window is open)
v_item = next((it for it in props.vertical_items if it.entity_id == v_id), None) v_item = next((it for it in props.vertical_items if it.entity_id == v_id), None)
@@ -565,9 +690,21 @@ class ALIGN_PT_alignment_segments(Panel):
if dec.is_installed and v_item is not None: if dec.is_installed and v_item is not None:
row.prop(v_item, "show_labels", text="", icon="FONT_DATA") row.prop(v_item, "show_labels", text="", icon="FONT_DATA")
edit_op = row.operator(
"align.enable_editing_v_segments", text="", icon="GREASEPENCIL", depress=is_editing_this
)
edit_op.layout_id = v_id
if not expanded: if not expanded:
return return
if is_editing_this:
self._draw_segment_editor(
box, props, "ALIGN_UL_v_segments", "v_segment_rows", "active_v_segment_row_index",
"VERTICAL", "align.apply_v_segments", "align.disable_editing_v_segments",
)
return
# Column headers (index cell is the select button) # Column headers (index cell is the select button)
header = box.split(factor=0.08) header = box.split(factor=0.08)
header.label(text="#") header.label(text="#")
@@ -632,6 +769,7 @@ class ALIGN_PT_alignment_segments(Panel):
label = layout_entity.Name or f"Cant #{c_id}" label = layout_entity.Name or f"Cant #{c_id}"
expanded = _C_EXPANDED.get(c_id, True) expanded = _C_EXPANDED.get(c_id, True)
selected_c_id = props.selected_cant_segment_id selected_c_id = props.selected_cant_segment_id
is_editing_this = props.editing_segment_kind == "CANT" and props.editing_layout_id == c_id
c_item = next((it for it in props.cant_items if it.entity_id == c_id), None) c_item = next((it for it in props.cant_items if it.entity_id == c_id), None)
@@ -651,9 +789,21 @@ class ALIGN_PT_alignment_segments(Panel):
row.prop(props, "show_cant_segment_labels", text="", icon="FONT_DATA") row.prop(props, "show_cant_segment_labels", text="", icon="FONT_DATA")
edit_op = row.operator(
"align.enable_editing_cant_segments", text="", icon="GREASEPENCIL", depress=is_editing_this
)
edit_op.layout_id = c_id
if not expanded: if not expanded:
return return
if is_editing_this:
self._draw_segment_editor(
box, props, "ALIGN_UL_cant_segments", "cant_segment_rows", "active_cant_segment_row_index",
"CANT", "align.apply_cant_segments", "align.disable_editing_cant_segments",
)
return
# Column headers (# is the select button) # Column headers (# is the select button)
header = box.split(factor=0.08) header = box.split(factor=0.08)
header.label(text="#") header.label(text="#")
+92
View File
@@ -180,6 +180,12 @@ class Alignment:
while preserving the layout entity and its mandatory zero-length while preserving the layout entity and its mandatory zero-length
terminator (which the layout functions then update in place). terminator (which the layout functions then update in place).
NOTE: this duplicates `ifcopenshell.api.alignment.clear_layout_segments`,
which does the same thing natively. New call sites (e.g. the segment
table's Apply operators) should prefer the native function directly;
this copy is kept only because the existing PI/PVI draw-tool call
sites already depend on its exact behavior and haven't been migrated.
Args: Args:
layout: The IFC layout entity (IfcAlignmentHorizontal/Vertical/Cant) layout: The IFC layout entity (IfcAlignmentHorizontal/Vertical/Cant)
""" """
@@ -247,6 +253,92 @@ class Alignment:
return False return False
@classmethod
def get_real_layout_segments(cls, layout: "ifcopenshell.entity_instance") -> list:
"""All of `layout`'s real (non-terminator) IfcAlignmentSegments, in order.
Centralizes the "skip the mandatory zero-length terminator" filter
(via is_zero_length_segment) that both the read-only segment panel and
the segment-table editing feature's "populate from IFC" step need to
agree on identically.
Args:
layout: The IFC layout entity (IfcAlignmentHorizontal/Vertical/Cant)
"""
segments = []
for rel in getattr(layout, "IsNestedBy", []) or []:
for obj in rel.RelatedObjects or []:
if obj.is_a("IfcAlignmentSegment") and not cls.is_zero_length_segment(obj):
segments.append(obj)
return segments
# =========================================================================
# Segment Table Editing — Validation
# =========================================================================
@classmethod
def validate_horizontal_segment_rows(cls, rows) -> list[str]:
"""Checks staged HorizontalSegmentRow entries before an Apply commits
them to IFC. Returns a list of human-readable error strings; empty
means the rows are safe to rebuild from.
Deliberately does NOT guard against a spiral-family row (CLOTHOID/
CUBIC/HELMERTCURVE/BLOSSCURVE/COSINECURVE/SINECURVE) with
start_radius == end_radius, even though that reliably crashes the
geometry kernel ("Only finite values are allowed" -- it divides by a
curvature-change factor that's exactly zero in that case). Per the
user (2026-09-14): the kernel bug should be left to crash rather than
silently avoided here, so it stays visible as a reminder to fix it at
the source instead of being masked by a UI-side workaround.
"""
errors = []
if len(rows) == 0:
errors.append("Add at least one segment before applying.")
for i, row in enumerate(rows):
label = f"Segment {i + 1}"
if row.predefined_type == "UNSUPPORTED":
errors.append(f"{label}: unsupported type ({row.original_predefined_type}) — remove or fix it.")
continue
if row.length <= 0.0:
errors.append(f"{label}: length must be greater than zero.")
if row.predefined_type == "CIRCULARARC" and row.start_radius == 0.0:
errors.append(f"{label}: a circular arc needs a non-zero radius.")
return errors
@classmethod
def validate_vertical_segment_rows(cls, rows) -> list[str]:
"""Checks staged VerticalSegmentRow entries before an Apply commits
them to IFC. Returns a list of human-readable error strings; empty
means the rows are safe to rebuild from."""
errors = []
if len(rows) == 0:
errors.append("Add at least one segment before applying.")
for i, row in enumerate(rows):
label = f"Segment {i + 1}"
if row.predefined_type == "UNSUPPORTED":
errors.append(f"{label}: unsupported type ({row.original_predefined_type}) — remove or fix it.")
continue
if row.h_length <= 0.0:
errors.append(f"{label}: length must be greater than zero.")
return errors
@classmethod
def validate_cant_segment_rows(cls, rows) -> list[str]:
"""Checks staged CantSegmentRow entries before an Apply commits them
to IFC. Returns a list of human-readable error strings; empty means
the rows are safe to rebuild from."""
errors = []
if len(rows) == 0:
errors.append("Add at least one segment before applying.")
for i, row in enumerate(rows):
label = f"Segment {i + 1}"
if row.predefined_type == "UNSUPPORTED":
errors.append(f"{label}: unsupported type ({row.original_predefined_type}) — remove or fix it.")
continue
if row.h_length <= 0.0:
errors.append(f"{label}: length must be greater than zero.")
return errors
# ========================================================================= # =========================================================================
# Blender Object Creation # Blender Object Creation
# ========================================================================= # =========================================================================