mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-21 12:13:43 +00:00
Working on various input and editing for alignments
This commit is contained in:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="#")
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user