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
The Alignment tab's UI lists the horizontal, vertical, and cant layouts. These listings need to
become editable:
**Implemented.** The Alignment tab's segment tables (`ALIGN_PT_alignment_segments`) now support
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
- Add new segments
- Delete segments
Segment type coverage: horizontal supports LINE, CIRCULARARC, and the full spiral-transition family
(CLOTHOID, CUBIC, HELMERTCURVE, BLOSSCURVE, COSINECURVE, SINECURVE); vertical supports
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
updated representations must automatically refresh in:
**Known, deliberate gap carried over from §4's existing note below:** Apply is a full rebuild, not
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
- the vertical/cant profile view
**Known, deliberate non-validation (per the user, 2026-09-14):** a spiral-family row with equal
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
@@ -76,7 +100,9 @@ improvements:
Replace the PI-grid display with basic information about the alignment layout — PI points
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.
- 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.is_visible = True
# Refit the camera to the new alignment's extent
ve = props.vertical_exaggeration
for window in ctx.window_manager.windows:
for a in window.screen.areas:
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
)
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()
for window in ctx.window_manager.windows:
@@ -108,6 +107,9 @@ classes = (
prop.VerticalAlignmentItem,
prop.CantAlignmentItem,
prop.VerticalPIMarker,
prop.HorizontalSegmentRow,
prop.VerticalSegmentRow,
prop.CantSegmentRow,
prop.CivilAlignmentProperties,
prop.PICurveMarkerProperties,
# UILists and section-toggle operators
@@ -115,6 +117,9 @@ classes = (
ui.ALIGN_OT_toggle_v_segments,
ui.ALIGN_OT_toggle_cant_segments,
ui.ALIGN_UL_vertical_pi_markers,
ui.ALIGN_UL_h_segments,
ui.ALIGN_UL_v_segments,
ui.ALIGN_UL_cant_segments,
operator.ImportAlignmentCSV,
# Operators - Vertical Profile Window
operator.ALIGN_OT_show_vertical_profile,
@@ -136,6 +141,19 @@ classes = (
operator.ALIGN_OT_draw_vertical_alignment,
operator.ALIGN_OT_apply_vertical_pi_curve,
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.ALIGN_PT_alignment_authoring,
ui.ALIGN_PT_vertical_alignment_authoring,
@@ -59,6 +59,13 @@ class AlignmentSegmentDecorator:
label_world_pos: tuple[float, float, float] | 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_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
@@ -87,6 +94,39 @@ class AlignmentSegmentDecorator:
SpaceView3D.draw_handler_add(handler.draw_label, (context,), "WINDOW", "POST_PIXEL")
)
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
def refresh(cls) -> None:
@@ -96,10 +136,18 @@ class AlignmentSegmentDecorator:
segment is already selected and highlighted; without this its PC/PI/PT
station labels would stay stale until the segment was deselected and
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:
return
cls._compute_segment_geometry(cls.segment_id)
cls._capture_selection_baseline(bpy.context)
tool.Blender.update_viewport()
@classmethod
@@ -116,6 +164,18 @@ class AlignmentSegmentDecorator:
cls.segment_label = ""
cls.label_world_pos = 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
def _compute_segment_geometry(cls, segment_id: int) -> None:
@@ -385,6 +445,22 @@ class AlignmentSegmentDecorator:
def draw_segment(self, context):
"""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
pa_ptr = VerticalProfileDecorator.profile_area_ptr
if pa_ptr != 0:
@@ -394,7 +470,7 @@ class AlignmentSegmentDecorator:
except Exception:
pass
verts = self.__class__.segment_verts
verts = cls.segment_verts
if not verts or len(verts) < 2:
return
@@ -491,6 +567,12 @@ class AlignmentSegmentDecorator:
Linear segments (has_pi=False): start and end station + coords, no tag prefix.
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:
if not bpy.context.scene.CivilAlignmentProperties.show_h_segment_labels:
return
@@ -608,6 +690,25 @@ def _nice_interval(span: float, target_count: int = 8) -> float:
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):
"""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):
@@ -682,11 +783,15 @@ class VerticalProfileDecorator:
Opens a dedicated SpaceView3D window in front orthographic mode and draws the
IfcGradientCurve as a distance-along vs. elevation plot with a configurable
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:
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)
"""
@@ -773,7 +878,7 @@ class VerticalProfileDecorator:
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_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_AXIS_TITLE = (0.65, 0.65, 0.65, 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
LINE_GRID = 1.0
LINE_PROFILE = 2.5
LINE_BOUNDARY = 1.2
LINE_TANGENT_VERT = 1.0
# ------------------------------------------------------------------ public
@@ -867,7 +971,7 @@ class VerticalProfileDecorator:
pass
@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.
Zone heights are derived from the area aspect ratio so they remain visible
@@ -1318,7 +1422,6 @@ class VerticalProfileDecorator:
try:
props = bpy.context.scene.CivilAlignmentProperties
ve = props.vertical_exaggeration
except Exception:
return
@@ -1471,21 +1574,24 @@ class VerticalProfileDecorator:
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges)
batch.draw(shader)
# --- Segment boundary ticks ------------------------------------------
tick = max((cls.elev_zone_top - cls.elev_zone_bot) * 0.04, 0.5)
shader.uniform_float("lineWidth", cls.LINE_BOUNDARY)
shader.uniform_float("color", cls.COLOR_BOUNDARY)
for info in cls.segments_info:
if visible_ids is not None and info.get("vertical_id", -1) not in visible_ids:
continue
d = info["dist"]
z = cls._ez(info["height"])
batch = batch_for_shader(
shader, "LINES",
{"pos": [(d, 0.0, z - tick), (d, 0.0, z + tick)]},
indices=[[0, 1]],
)
batch.draw(shader)
# --- Segment boundary dots --------------------------------------------
# Sized in screen pixels (like lineWidth) rather than world units, so
# the dot stays a fixed on-screen size — matching the profile line's
# weight — instead of ballooning at zoomed-in scales.
world_per_px = (vis_d_max - vis_d_min) / max(region.width, 1)
dot_r = cls.LINE_PROFILE * world_per_px
dot_centers = [
(info["dist"], cls._ez(info["height"]))
for info in cls.segments_info
if visible_ids is None or info.get("vertical_id", -1) in visible_ids
]
if dot_centers:
dot_verts, dot_indices = _dot_tris(dot_centers, dot_r)
dot_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
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) --------------
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)]
batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges).draw(shader)
# Cant segment boundary ticks
tick_c = cant_h * 0.04
shader.uniform_float("lineWidth", cls.LINE_BOUNDARY)
shader.uniform_float("color", cls.COLOR_BOUNDARY)
for info in cls.cant_info:
if info.get("rail") == "C":
continue
if visible_cant_ids is not None and info.get("cant_id", -1) not in visible_cant_ids:
continue
d = info["dist"]
gz = _cz(info["start_cant"])
batch_for_shader(shader, "LINES",
{"pos": [(d, 0.0, gz - tick_c), (d, 0.0, gz + tick_c)]},
indices=[[0, 1]]).draw(shader)
# Cant segment boundary dots (same fixed screen-pixel sizing as the
# elevation zone's — world_per_px is shared across the whole view).
dot_r_c = cls.LINE_PROFILE * world_per_px
cant_dot_centers = [
(info["dist"], _cz(info["start_cant"]))
for info in cls.cant_info
if info.get("rail") != "C"
and (visible_cant_ids is None or info.get("cant_id", -1) in visible_cant_ids)
]
if cant_dot_centers:
cant_dot_verts, cant_dot_indices = _dot_tris(cant_dot_centers, dot_r_c)
cant_dot_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
cant_dot_shader.bind()
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)
shader.uniform_float("lineWidth", 1.5)
@@ -1694,7 +1804,6 @@ class VerticalProfileDecorator:
try:
props = bpy.context.scene.CivilAlignmentProperties
ve = props.vertical_exaggeration
except Exception:
return
@@ -1736,7 +1845,7 @@ class VerticalProfileDecorator:
blf.size(font_id, tool.Blender.scale_font_size(13))
blf.color(font_id, *cls.COLOR_HEADER)
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) --------------
BOTTOM_MARGIN = 28 # px from bottom for label baseline
@@ -21,6 +21,7 @@
import bpy
import blf
import math
import time
import bonsai.core.alignment as core
import bonsai.tool as tool
@@ -31,6 +32,7 @@ from bpy_extras.io_utils import ImportHelper
from bpy.types import Operator, SpaceView3D
from bpy.props import StringProperty, FloatProperty, EnumProperty, IntProperty, BoolProperty
from . import decorator as alignment_decorator
from . import prop
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.model.decorator import PolylineDecorator
from bonsai.bim.ifc import IfcStore
@@ -1070,14 +1072,64 @@ def _refresh_vertical_profile_view(context, alignment):
if not dec.is_installed:
return
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)
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)
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):
"""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
# have changed) and re-fit the view.
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)
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)
dec.profile_area.tag_redraw()
return dec.profile_area
@@ -1141,8 +1192,7 @@ def _open_vertical_profile(context, alignment):
space.region_3d.view_perspective = "ORTHO"
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, ve, area_width=profile_area.width, area_height=profile_area.height)
dec.fit_view(space, area_width=profile_area.width, area_height=profile_area.height)
space.overlay.show_floor = False
space.overlay.show_axis_x = False
@@ -1160,6 +1210,7 @@ def _open_vertical_profile(context, alignment):
dec.install(context, profile_area)
profile_area.tag_redraw()
_start_profile_rotation_guard()
return profile_area
@@ -1513,6 +1564,586 @@ class ALIGN_OT_clear_vertical_pi_markers(Operator):
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
# =============================================================================
+137 -17
View File
@@ -133,12 +133,6 @@ def _on_cant_visibility_update(self, context):
VerticalProfileDecorator.tag_redraw()
def _on_ve_update(self, context):
from .decorator import VerticalProfileDecorator
VerticalProfileDecorator.tag_redraw()
class CantAlignmentItem(PropertyGroup):
"""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):
"""Properties for the alignment module"""
@@ -196,17 +301,6 @@ class CivilAlignmentProperties(PropertyGroup):
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_h_segment_id: IntProperty(
name="Selected Horizontal Segment",
@@ -258,6 +352,32 @@ class CivilAlignmentProperties(PropertyGroup):
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):
"""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="")
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
# =============================================================================
@@ -427,7 +513,6 @@ class ALIGN_PT_alignment_segments(Panel):
dec = VerticalProfileDecorator
row = layout.row(align=True)
row.label(text="Vertical Profile:", icon="FCURVE")
row.prop(props, "vertical_exaggeration", text="VE")
row.operator(
"align.show_vertical_profile", text="",
icon="GRAPH", depress=dec.is_installed,
@@ -446,6 +531,30 @@ class ALIGN_PT_alignment_segments(Panel):
if seg.is_a("IfcAlignmentSegment"):
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):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
@@ -453,6 +562,10 @@ class ALIGN_PT_alignment_segments(Panel):
expanded = _H_EXPANDED.get(alignment_id, True)
box = layout.box()
is_editing_this = (
props.editing_segment_kind == "HORIZONTAL" and props.editing_layout_id == layout_entity.id()
)
# Collapsible header with label toggle
row = box.row(align=True)
op = row.operator(
@@ -462,10 +575,21 @@ class ALIGN_PT_alignment_segments(Panel):
op.alignment_id = alignment_id
row.label(text="Horizontal", icon="DRIVER_ROTATIONAL_DIFFERENCE")
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:
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)
header = box.split(factor=0.08)
header.label(text="#")
@@ -540,6 +664,7 @@ class ALIGN_PT_alignment_segments(Panel):
label = label or layout_entity.Name or f"Vertical #{v_id}"
expanded = _V_EXPANDED.get(v_id, True)
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)
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:
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:
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)
header = box.split(factor=0.08)
header.label(text="#")
@@ -632,6 +769,7 @@ class ALIGN_PT_alignment_segments(Panel):
label = layout_entity.Name or f"Cant #{c_id}"
expanded = _C_EXPANDED.get(c_id, True)
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)
@@ -651,9 +789,21 @@ class ALIGN_PT_alignment_segments(Panel):
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:
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)
header = box.split(factor=0.08)
header.label(text="#")
+92
View File
@@ -180,6 +180,12 @@ class Alignment:
while preserving the layout entity and its mandatory zero-length
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:
layout: The IFC layout entity (IfcAlignmentHorizontal/Vertical/Cant)
"""
@@ -247,6 +253,92 @@ class Alignment:
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
# =========================================================================