From 6a95717072b588e3560afba6c9d796923169afd7 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:24 -0700 Subject: [PATCH] Working on various input and editing for alignments --- .../bim/module/alignment/REQUIREMENTS.md | 46 +- .../bonsai/bim/module/alignment/__init__.py | 22 +- .../bonsai/bim/module/alignment/decorator.py | 185 +++-- .../bonsai/bim/module/alignment/operator.py | 643 +++++++++++++++++- .../bonsai/bim/module/alignment/prop.py | 154 ++++- src/bonsai/bonsai/bim/module/alignment/ui.py | 152 ++++- src/bonsai/bonsai/tool/alignment.py | 92 +++ 7 files changed, 1220 insertions(+), 74 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md b/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md index d35623c16f..6076de5338 100644 --- a/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md +++ b/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md @@ -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, diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index 1b8fc9f5d1..4b01c62f0b 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -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, diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py index 2cb730ffb4..4b2d367ae2 100644 --- a/src/bonsai/bonsai/bim/module/alignment/decorator.py +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 6847a98a13..88ea184324 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -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 # ============================================================================= diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py index 6085bb972d..96c378dd54 100644 --- a/src/bonsai/bonsai/bim/module/alignment/prop.py +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py index 67558c2a70..56e9f0ab79 100644 --- a/src/bonsai/bonsai/bim/module/alignment/ui.py +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -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="#") diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index 065bf183ea..459907ed8f 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -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 # =========================================================================