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