Vertical Alignment

This commit is contained in:
Richard Brice
2026-09-13 14:17:52 -07:00
parent fe01c35dbb
commit f9a8c2e14f
6 changed files with 851 additions and 65 deletions
@@ -107,12 +107,14 @@ classes = (
# Property groups (must be registered before classes that use them)
prop.VerticalAlignmentItem,
prop.CantAlignmentItem,
prop.VerticalPIMarker,
prop.CivilAlignmentProperties,
prop.PICurveMarkerProperties,
# UILists and section-toggle operators
ui.ALIGN_OT_toggle_h_segments,
ui.ALIGN_OT_toggle_v_segments,
ui.ALIGN_OT_toggle_cant_segments,
ui.ALIGN_UL_vertical_pi_markers,
operator.ImportAlignmentCSV,
# Operators - Vertical Profile Window
operator.ALIGN_OT_show_vertical_profile,
@@ -130,8 +132,13 @@ classes = (
operator.ALIGN_OT_apply_pi_curve,
operator.ALIGN_OT_clear_pi_markers,
operator.ALIGN_OT_draw_horizontal_alignment,
# Operators - Vertical alignment authoring (draw-by-PI in the profile view)
operator.ALIGN_OT_draw_vertical_alignment,
operator.ALIGN_OT_apply_vertical_pi_curve,
operator.ALIGN_OT_clear_vertical_pi_markers,
# UI Panels (appear in Properties sidebar under ALIGNMENTS tab)
ui.ALIGN_PT_alignment_authoring,
ui.ALIGN_PT_vertical_alignment_authoring,
ui.ALIGN_PT_alignment_stationing_authoring,
ui.ALIGN_PT_alignment_segments,
)
@@ -34,6 +34,7 @@ import ifcopenshell.util.geolocation
import ifcopenshell.util.shape
import ifcopenshell.util.unit
import bonsai.tool as tool
from typing import Optional, Tuple
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d
from gpu_extras.batch import batch_for_shader
@@ -1009,6 +1010,17 @@ class VerticalProfileDecorator:
cls.dist_max = max(all_dists)
cls.elev_min = min(all_elevs)
cls.elev_max = max(all_elevs)
else:
# No vertical geometry yet — e.g. opening the profile view to draw
# the first one. Frame the canvas to the horizontal alignment's
# own length instead of leaving stale/default 0..1 bounds, so
# there's a sensible drawing surface to click PIs onto.
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
h_length = tool.Alignment.get_horizontal_alignment_length(h_layout) if h_layout else 0.0
cls.dist_min = 0.0
cls.dist_max = max(h_length, 1.0)
cls.elev_min = 0.0
cls.elev_max = max(h_length * 0.1, 10.0)
# Collect cant data (plotted below the elevation profile)
cls._collect_cant_data(alignment)
@@ -1236,6 +1248,51 @@ class VerticalProfileDecorator:
t = (e - cls._e_display_min) / e_span
return cls.elev_zone_bot + t * (cls.elev_zone_top - cls.elev_zone_bot)
@classmethod
def screen_to_data(
cls, region: "bpy.types.Region", rv3d: "bpy.types.RegionView3D", mouse_x: float, mouse_y: float
) -> Optional[Tuple[float, float]]:
"""Convert a region-relative mouse position to (distance_along, elevation).
``region``/``rv3d`` must be looked up explicitly by the caller (e.g.
by matching profile_area_ptr against context.screen.areas) rather
than taken from context.region/context.region_data — during a modal
operator's event handling, those don't reliably track wherever the
mouse currently is when it's over an area *other than* the one the
operator was invoked from, unlike draw handlers (which Blender calls
per-area with correct context). Getting this wrong means every event
looks like it's outside the profile area, so clicks silently do
nothing — see ALIGN_OT_draw_vertical_alignment._locate_profile_view.
Inverse of the data -> world-Z mapping draw_3d uses for its grid/curve
(world X is distance-along directly; world Z goes through _ez's
normalized zone). Re-derives the zone bounds from the view's current
(possibly just panned/zoomed) visible Z span first, exactly like
draw_3d does every frame, so a click lands on the same point the
background grid shows under the cursor.
Returns None if there's no valid region/view to project against.
"""
if not region or not rv3d:
return None
ref = (cls.dist_min, 0.0, (cls.elev_zone_bot + cls.elev_zone_top) * 0.5)
point = region_2d_to_location_3d(region, rv3d, (mouse_x, mouse_y), ref)
if point is None:
return None
bl = region_2d_to_location_3d(region, rv3d, (0, 0), ref)
tr = region_2d_to_location_3d(region, rv3d, (region.width, region.height), ref)
if bl is not None and tr is not None:
cls._recompute_zones((bl.z + tr.z) * 0.5, tr.z - bl.z)
span = cls.elev_zone_top - cls.elev_zone_bot
if abs(span) < 1e-9:
return None
t = (point.z - cls.elev_zone_bot) / span
elevation = cls._e_display_min + t * (cls._e_display_max - cls._e_display_min)
return point.x, elevation
@classmethod
def _cz2(cls, v: float) -> float:
"""Map a cant data value to world-Z within the cant zone."""
@@ -1987,3 +2044,178 @@ class VerticalProfileDecorator:
labeled_cant.add(e_key)
blf.disable(font_id, blf.SHADOW)
class VerticalDrawDecorator:
"""Live preview while ALIGN_OT_draw_vertical_alignment is running.
Draws the in-progress PI polyline and a rubber-band line to the mouse
cursor, using the exact same (dist_along, elevation) -> world-Z mapping
VerticalProfileDecorator._ez uses, so the preview lines up with the
profile grid/curve drawn underneath it by that decorator (which stays
installed and keeps drawing its own grid/background throughout).
"""
is_installed: bool = False
handlers: list = []
points: list = [] # shared reference to the operator's [(dist, elev), ...] list
mouse_data: Optional[Tuple[float, float]] = None # (dist_along, elevation) under the cursor, or None
COLOR_LINE = (1.0, 0.9, 0.2, 1.0)
COLOR_RUBBER = (1.0, 0.9, 0.2, 0.5)
COLOR_HUD_TEXT = (1.0, 1.0, 1.0, 1.0)
LINE_WIDTH = 2.0
@classmethod
def install(cls, context, points: list) -> None:
if cls.is_installed:
cls.uninstall()
cls.points = points
cls.mouse_data = None
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_3d, (context,), "WINDOW", "POST_VIEW"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_hud, (context,), "WINDOW", "POST_PIXEL"))
cls.is_installed = True
@classmethod
def uninstall(cls) -> None:
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.handlers = []
cls.is_installed = False
cls.points = []
cls.mouse_data = None
@classmethod
def tag_redraw(cls) -> None:
VerticalProfileDecorator.tag_redraw()
@classmethod
def update_mouse(cls, point: Optional[Tuple[float, float]]) -> None:
"""Set the (dist_along, elevation) the preview/rubber-band should show.
Takes an already-resolved point rather than raw mouse coordinates —
the caller (ALIGN_OT_draw_vertical_alignment) is what knows about the
station-range clamp and left-to-right PI ordering constraints, so it
computes the constrained point before handing it here. Pass None to
hide the preview (e.g. the mouse left the profile area).
"""
cls.mouse_data = point
cls.tag_redraw()
def _in_profile_area(self) -> bool:
try:
return (
bpy.context.area is not None
and bpy.context.area.as_pointer() == VerticalProfileDecorator.profile_area_ptr
)
except Exception:
return False
def draw_3d(self, context) -> None:
cls = self.__class__
if not self._in_profile_area():
return
if not cls.points and cls.mouse_data is None:
return
ez = VerticalProfileDecorator._ez
verts = [(d, 0.0, ez(e)) for d, e in cls.points]
region = bpy.context.region
if not region:
return
gpu.state.blend_set("ALPHA")
gpu.state.depth_test_set("NONE")
gpu.state.depth_mask_set(False)
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", self.LINE_WIDTH)
if len(verts) >= 2:
indices = [[i, i + 1] for i in range(len(verts) - 1)]
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
shader.uniform_float("color", self.COLOR_LINE)
batch.draw(shader)
if verts and cls.mouse_data is not None:
rubber_end = (cls.mouse_data[0], 0.0, ez(cls.mouse_data[1]))
batch = batch_for_shader(shader, "LINES", {"pos": [verts[-1], rubber_end]}, indices=[[0, 1]])
shader.uniform_float("color", self.COLOR_RUBBER)
batch.draw(shader)
gpu.state.blend_set("NONE")
gpu.state.depth_test_set("NONE")
gpu.state.depth_mask_set(True)
def draw_hud(self, context) -> None:
cls = self.__class__
if not self._in_profile_area():
return
if cls.mouse_data is None:
return
region = context.region
rv3d = context.region_data
if not region:
return
dist_along, elevation = cls.mouse_data
# Crosshair at the candidate-PI position — the only visual feedback
# for the first PI (anchored to the start station) before anything
# has actually been placed yet, since the rubber-band line itself
# needs a previous point to draw from.
if rv3d is not None:
cursor_2d = location_3d_to_region_2d(
region, rv3d, (dist_along, 0.0, VerticalProfileDecorator._ez(elevation))
)
if cursor_2d is not None:
size = 6
shader2d = gpu.shader.from_builtin("UNIFORM_COLOR")
shader2d.bind()
shader2d.uniform_float("color", self.COLOR_RUBBER)
batch = batch_for_shader(
shader2d,
"LINES",
{
"pos": [
(cursor_2d.x - size, cursor_2d.y),
(cursor_2d.x + size, cursor_2d.y),
(cursor_2d.x, cursor_2d.y - size),
(cursor_2d.x, cursor_2d.y + size),
]
},
)
batch.draw(shader2d)
lines = [
f"Dist Along: {dist_along:.2f}",
f"Elevation: {elevation:.3f}",
]
if cls.points:
prev_d, prev_e = cls.points[-1]
dd = dist_along - prev_d
if abs(dd) > 1e-6:
grade = (elevation - prev_e) / dd * 100.0
lines.append(f"Grade: {grade:.2f}%")
font_id = 0
font_size = tool.Blender.scale_font_size(14)
blf.size(font_id, font_size)
blf.enable(font_id, blf.SHADOW)
blf.shadow(font_id, 6, 0, 0, 0, 1)
blf.color(font_id, *self.COLOR_HUD_TEXT)
margin = 20
line_height = font_size * 1.4
y = region.height - margin
for i, line in enumerate(lines):
blf.position(font_id, margin, y - i * line_height, 0)
blf.draw(font_id, line)
blf.disable(font_id, blf.SHADOW)
@@ -1009,6 +1009,129 @@ class ALIGN_OT_draw_horizontal_alignment(bpy.types.Operator, PolylineOperator, t
# =============================================================================
def _sync_profile_visibility_items(context, dec):
"""(Re)populate the per-vertical/per-cant visibility filters for the profile window."""
props = context.scene.CivilAlignmentProperties
props.vertical_items.clear()
for v_id, v_label in dec.available_verticals:
item = props.vertical_items.add()
item.entity_id = v_id
item.label = v_label
item.is_visible = True
props.cant_items.clear()
for c_id, c_label in dec.available_cants:
item = props.cant_items.add()
item.entity_id = c_id
item.label = c_label
item.is_visible = True
def _refresh_vertical_profile_view(context, alignment):
"""Recompute and redraw the docked profile view after the vertical
alignment's geometry changed (drawn or curves applied), so its background
grid/curve reflects the new segments instead of stale/empty data.
A no-op if the profile view isn't currently open.
"""
dec = alignment_decorator.VerticalProfileDecorator
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)
_sync_profile_visibility_items(context, dec)
dec.tag_redraw()
def _open_vertical_profile(context, alignment):
"""Open (or refresh) the docked vertical profile view for ``alignment``.
Shared by ALIGN_OT_show_vertical_profile (toggle button) and
ALIGN_OT_draw_vertical_alignment (which needs the profile view open
before it can start placing PIs in it). Returns the profile
``bpy.types.Area``, or None if it couldn't be opened/refreshed.
"""
import mathutils
dec = alignment_decorator.VerticalProfileDecorator
if dec.is_installed:
# 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)
_sync_profile_visibility_items(context, dec)
dec.profile_area.tag_redraw()
return dec.profile_area
dec._compute_profile(alignment)
# Find the 3D view area and its WINDOW region for the split call
area = context.area
if area is None or area.type != "VIEW_3D":
# Button pressed from a non-3D area — find the first 3D view
area = next((a for a in context.screen.areas if a.type == "VIEW_3D"), None)
if area is None:
return None
win_region = next((r for r in area.regions if r.type == "WINDOW"), None)
if win_region is None:
return None
# Split with a horizontal dividing line so the profile appears below the
# main 3D view. factor=0.7 keeps 70% for the existing area (top) and
# gives 30% to the new profile area (bottom).
# direction="HORIZONTAL" = horizontal split line = top/bottom areas.
# Use as_pointer() (stable C address) rather than id() (Python wrapper ID,
# which can change after Blender reshuffles wrappers following area_close).
ptrs_before = {a.as_pointer() for a in context.screen.areas}
with context.temp_override(area=area, region=win_region):
bpy.ops.screen.area_split(direction="HORIZONTAL", factor=0.7)
new_areas = [a for a in context.screen.areas if a.as_pointer() not in ptrs_before]
if not new_areas:
return None
# Blender's area_split places the NEW area above the original area.
# The original area stays at the bottom — use it as the profile view.
profile_area = area
space = next((s for s in profile_area.spaces if s.type == "VIEW_3D"), None)
if space is None:
return None
# Front orthographic: 90° rotation around X so Z is elevation, X is distance
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)
space.overlay.show_floor = False
space.overlay.show_axis_x = False
space.overlay.show_axis_y = False
space.overlay.show_axis_z = False
space.show_gizmo = False
# Hide tool shelf and N-panel so they don't obscure the profile extents.
# Set directly on the space (absolute, not a toggle) so this works reliably
# regardless of the panel's current visibility state.
space.show_region_toolbar = False
space.show_region_ui = False
_sync_profile_visibility_items(context, dec)
dec.install(context, profile_area)
profile_area.tag_redraw()
return profile_area
class ALIGN_OT_show_vertical_profile(Operator):
"""Toggle the docked vertical profile view below the active 3D viewport"""
@@ -1021,8 +1144,6 @@ class ALIGN_OT_show_vertical_profile(Operator):
bl_options = {"REGISTER"}
def execute(self, context):
import mathutils
dec = alignment_decorator.VerticalProfileDecorator
# --- Toggle off ---
@@ -1037,85 +1158,326 @@ class ALIGN_OT_show_vertical_profile(Operator):
self.report({"WARNING"}, "No alignment selected")
return {"CANCELLED"}
dec._compute_profile(alignment)
if not dec.segments_polylines:
self.report({"WARNING"}, "No vertical alignment data found for this alignment")
profile_area = _open_vertical_profile(context, alignment)
if profile_area is None:
self.report({"WARNING"}, "Could not open the vertical profile view")
return {"CANCELLED"}
ve = context.scene.CivilAlignmentProperties.vertical_exaggeration
return {"FINISHED"}
# Find the 3D view area and its WINDOW region for the split call
area = context.area
if area.type != "VIEW_3D":
# Button pressed from a non-3D area — find the first 3D view
area = next((a for a in context.screen.areas if a.type == "VIEW_3D"), None)
if area is None:
self.report({"WARNING"}, "No 3D Viewport found")
return {"CANCELLED"}
win_region = next((r for r in area.regions if r.type == "WINDOW"), None)
if win_region is None:
# =============================================================================
# Vertical Alignment Drawing (draw-by-PI in the profile view)
# =============================================================================
def _generate_vertical_alignment_segments(context, alignment, vpoints, lengths):
"""Build vertical alignment segments from PI points and per-PI curve lengths.
Mirrors _generate_alignment_segments() for the vertical layout. ``vpoints``
are (distance_along, elevation) pairs already in project length units —
unlike the horizontal case, no unit-scale/georeferencing conversion is
needed, because the profile view's world coordinates already are raw
project-unit distance/elevation values (see
VerticalProfileDecorator.screen_to_data). ``lengths`` has exactly
len(vpoints) - 2 entries, one per interior PI (0.0 = sharp grade break).
"""
ifc = tool.Ifc.get()
v_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
if v_layout is None:
v_layout = ifcopenshell.api.alignment.add_vertical_layout(ifc, alignment)
tool.Alignment.clear_layout_segments(v_layout)
tool.Alignment.safe_layout_vertical_by_pi_method(ifc, v_layout, vpoints, lengths)
ifcopenshell.api.alignment.create_representation(ifc, alignment)
tool.Alignment.refresh_alignment_representation_object(alignment)
n_curved = sum(1 for l in lengths if l)
return True, f"Drew vertical alignment with {len(vpoints)} PIs ({n_curved} curved)"
def _sync_vertical_pi_markers(context, vpoints):
"""(Re)populate props.vertical_pi_markers from the interior PIs of ``vpoints``.
Start/end are excluded — they're derived from the alignment's own
segments when applying curves (get_vertical_alignment_start_end_points),
same convention as the horizontal PI markers.
"""
props = context.scene.CivilAlignmentProperties
props.vertical_pi_markers.clear()
for dist_along, elevation in vpoints[1:-1]:
item = props.vertical_pi_markers.add()
item.dist_along = dist_along
item.elevation = elevation
item.curve_type = "TANGENT"
item.curve_length = 100.0
class ALIGN_OT_draw_vertical_alignment(Operator, tool.Ifc.Operator):
"""Draw the vertical alignment of the active IfcAlignment by PI, in the profile view.
Opens (or reuses) the docked vertical profile view, then click to place
each PI (grade break to grade break). A vertical alignment must span
exactly the horizontal's own distance-along range, so this is enforced
as you draw rather than left to be fixed up afterward: the first PI is
anchored to the start station (only its elevation follows the mouse),
and moving past the last station locks distance-along there too, so
overshooting the end and clicking places the final PI exactly on it.
PIs are also kept left-to-right — the mouse can't drag a candidate PI
behind the previous one.
RMB/Enter finishes and generates the vertical alignment with every PI a
sharp grade break; interior PIs are then listed in the panel below — set
a curve length and click Apply Vertical Curves to regenerate with
parabolic curves at those PIs. ESC cancels without creating anything.
Backspace removes the last PI.
"""
bl_idname = "align.draw_vertical_alignment"
bl_label = "Draw Vertical Alignment"
bl_description = (
"Draw the vertical alignment by PI in the profile view, spanning the "
"horizontal's full station range. Click to place PIs, RMB/Enter to "
"generate it. ESC cancels."
)
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not poll_ifc4x3(cls, context):
return False
alignment = tool.Alignment.get_active_alignment()
if not alignment:
cls.poll_message_set("Add or select an alignment first")
return False
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
has_real_segments = h_layout and any(
not tool.Alignment.is_zero_length_segment(s)
for s in ifcopenshell.api.alignment.get_layout_segments(h_layout)
)
if not has_real_segments:
cls.poll_message_set("Draw the horizontal alignment first")
return False
return True
def __init__(self, *args, **kwargs):
Operator.__init__(self, *args, **kwargs)
self._points: list = [] # [(dist_along, elevation), ...] in project units
self._area_ptr = 0
self._alignment_id = 0
def invoke(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
def _invoke(self, context, event):
alignment = tool.Alignment.get_active_alignment()
if not alignment:
self.report({"ERROR"}, "Add or select an alignment first")
return {"CANCELLED"}
# Split with a horizontal dividing line so the profile appears below the
# main 3D view. factor=0.7 keeps 70% for the existing area (top) and
# gives 30% to the new profile area (bottom).
# direction="HORIZONTAL" = horizontal split line = top/bottom areas.
# Use as_pointer() (stable C address) rather than id() (Python wrapper ID,
# which can change after Blender reshuffles wrappers following area_close).
ptrs_before = {a.as_pointer() for a in context.screen.areas}
with context.temp_override(area=area, region=win_region):
bpy.ops.screen.area_split(direction="HORIZONTAL", factor=0.7)
new_areas = [a for a in context.screen.areas if a.as_pointer() not in ptrs_before]
if not new_areas:
self.report({"WARNING"}, "Could not split the viewport")
profile_area = _open_vertical_profile(context, alignment)
if profile_area is None:
self.report({"ERROR"}, "Could not open the vertical profile view")
return {"CANCELLED"}
# Blender's area_split places the NEW area above the original area.
# The original area stays at the bottom — use it as the profile view.
profile_area = area
self._alignment_id = alignment.id()
self._points = []
self._area_ptr = profile_area.as_pointer()
space = next((s for s in profile_area.spaces if s.type == "VIEW_3D"), None)
if space is None:
alignment_decorator.VerticalDrawDecorator.install(context, self._points)
context.workspace.status_text_set(
text="Click: add PI Backspace: remove last Enter/RMB: finish Esc: cancel"
)
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
def modal(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
def _locate_profile_view(self, context):
"""Find the profile area's WINDOW region and RegionView3D, fresh on
every call, by matching the stable pointer captured at invoke time.
During a modal operator's event handling, context.area/region/
region_data do NOT reliably track wherever the mouse currently is
once it strays outside the area the operator was originally invoked
from — unlike draw handlers, which Blender calls per-area with
correct context (that's why the background grid draws fine here even
when clicks don't land). So this looks the area up explicitly via
context.screen.areas instead of trusting ambient context, and
event.mouse_x/mouse_y (absolute, always correct) get used in place
of event.mouse_region_x/y (relative to whatever context.region
happens to be, which is exactly the unreliable part).
Returns (area, region, rv3d), or None if the profile area is gone.
"""
for area in context.screen.areas:
if area.as_pointer() != self._area_ptr:
continue
region = next((r for r in area.regions if r.type == "WINDOW"), None)
space = next((s for s in area.spaces if s.type == "VIEW_3D"), None)
if region is None or space is None:
return None
return area, region, space.region_3d
return None
def _constrain_point(self, dist_along: float, elevation: float) -> tuple:
"""Clamp a candidate PI to the horizontal alignment's station range
and enforce left-to-right PI ordering.
The vertical alignment must span exactly the horizontal's own
distance-along range, so the very first PI is always anchored to its
start (dist_min) regardless of mouse position — only elevation is
free for it. Every later PI is free between the previous PI's
distance-along and the horizontal's end (dist_max); moving the mouse
past dist_max clamps distance-along there, so overshooting the end
and clicking places the final PI exactly at the last station.
"""
dec = alignment_decorator.VerticalProfileDecorator
if not self._points:
return dec.dist_min, elevation
lower = self._points[-1][0]
upper = max(dec.dist_max, lower)
return min(max(dist_along, lower), upper), elevation
def _modal(self, context, event):
found = self._locate_profile_view(context)
if found is None:
# The profile view got closed out from under us.
context.workspace.status_text_set(text=None)
alignment_decorator.VerticalDrawDecorator.uninstall()
self.report({"WARNING"}, "Vertical profile view was closed")
return {"CANCELLED"}
area, region, rv3d = found
# event.mouse_x/y are absolute (window) coordinates — always correct,
# unlike mouse_region_x/y which is relative to context.region.
mx, my = event.mouse_x, event.mouse_y
over_profile = area.x <= mx < area.x + area.width and area.y <= my < area.y + area.height
if event.type == "MOUSEMOVE":
point = None
if over_profile:
raw = alignment_decorator.VerticalProfileDecorator.screen_to_data(
region, rv3d, mx - region.x, my - region.y
)
if raw is not None:
point = self._constrain_point(*raw)
alignment_decorator.VerticalDrawDecorator.update_mouse(point)
return {"PASS_THROUGH"}
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
return {"PASS_THROUGH"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
if not over_profile:
return {"PASS_THROUGH"}
raw = alignment_decorator.VerticalProfileDecorator.screen_to_data(
region, rv3d, mx - region.x, my - region.y
)
if raw is not None:
self._points.append(self._constrain_point(*raw))
alignment_decorator.VerticalDrawDecorator.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "BACK_SPACE" and event.value == "RELEASE":
if self._points:
self._points.pop()
alignment_decorator.VerticalDrawDecorator.tag_redraw()
return {"RUNNING_MODAL"}
if event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}:
context.workspace.status_text_set(text=None)
alignment_decorator.VerticalDrawDecorator.uninstall()
self._finish(context)
return {"FINISHED"}
if event.type == "ESC" and event.value == "RELEASE":
context.workspace.status_text_set(text=None)
alignment_decorator.VerticalDrawDecorator.uninstall()
return {"CANCELLED"}
# Front orthographic: 90° rotation around X so Z is elevation, X is distance
space.region_3d.view_perspective = "ORTHO"
space.region_3d.view_rotation = mathutils.Quaternion((0.7071068, 0.7071068, 0.0, 0.0))
return {"RUNNING_MODAL"}
dec.fit_view(space, ve, area_width=profile_area.width, area_height=profile_area.height)
def _finish(self, context):
if len(self._points) < 2:
self.report({"WARNING"}, "Need at least 2 PIs to draw a vertical alignment")
return
space.overlay.show_floor = False
space.overlay.show_axis_x = False
space.overlay.show_axis_y = False
space.overlay.show_axis_z = False
space.show_gizmo = False
try:
alignment = tool.Ifc.get().by_id(self._alignment_id)
except RuntimeError:
self.report({"ERROR"}, "Alignment no longer exists")
return
# Hide tool shelf and N-panel so they don't obscure the profile extents.
# Set directly on the space (absolute, not a toggle) so this works reliably
# regardless of the panel's current visibility state.
space.show_region_toolbar = False
space.show_region_ui = False
# Vertical PIs must be strictly ordered by distance-along — a profile
# is a function of distance-along, so out-of-order clicks (easy to do
# by accident) would otherwise fold the curve back on itself.
vpoints = sorted(self._points, key=lambda p: p[0])
lengths = [0.0] * (len(vpoints) - 2)
# Populate per-vertical and per-cant visibility filters (all visible by default)
props = context.scene.CivilAlignmentProperties
props.vertical_items.clear()
for v_id, v_label in dec.available_verticals:
item = props.vertical_items.add()
item.entity_id = v_id
item.label = v_label
item.is_visible = True
ok, message = _generate_vertical_alignment_segments(context, alignment, vpoints, lengths)
if ok:
_sync_vertical_pi_markers(context, vpoints)
_refresh_vertical_profile_view(context, alignment)
self.report({"INFO"} if ok else {"WARNING"}, message)
props.cant_items.clear()
for c_id, c_label in dec.available_cants:
item = props.cant_items.add()
item.entity_id = c_id
item.label = c_label
item.is_visible = True
dec.install(context, profile_area)
profile_area.tag_redraw()
class ALIGN_OT_apply_vertical_pi_curve(Operator, tool.Ifc.Operator):
"""Regenerate the vertical alignment using the PI list's curve settings"""
bl_idname = "align.apply_vertical_pi_curve"
bl_label = "Apply Vertical Curves"
bl_description = "Regenerate the vertical alignment using each PI's curve type/length"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not poll_ifc4x3(cls, context):
return False
if not context.scene.CivilAlignmentProperties.vertical_pi_markers:
cls.poll_message_set("Draw a vertical alignment first")
return False
if not tool.Alignment.get_active_alignment():
cls.poll_message_set("Select the alignment first")
return False
return True
def _execute(self, context):
alignment = tool.Alignment.get_active_alignment()
try:
start, end = tool.Alignment.get_vertical_alignment_start_end_points(alignment)
except ValueError as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
markers = list(context.scene.CivilAlignmentProperties.vertical_pi_markers)
vpoints = [start] + [(m.dist_along, m.elevation) for m in markers] + [end]
lengths = [m.curve_length if m.curve_type == "PARABOLIC" else 0.0 for m in markers]
ok, message = _generate_vertical_alignment_segments(context, alignment, vpoints, lengths)
if ok:
_refresh_vertical_profile_view(context, alignment)
self.report({"INFO"} if ok else {"WARNING"}, message)
return {"FINISHED"}
class ALIGN_OT_clear_vertical_pi_markers(Operator):
"""Clear the vertical PI list without changing the vertical alignment"""
bl_idname = "align.clear_vertical_pi_markers"
bl_label = "Clear Vertical PI List"
bl_description = "Clear the interior-PI list (does not affect the vertical alignment already drawn)"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bool(context.scene.CivilAlignmentProperties.vertical_pi_markers)
def execute(self, context):
context.scene.CivilAlignmentProperties.vertical_pi_markers.clear()
return {"FINISHED"}
@@ -157,6 +157,33 @@ class CantAlignmentItem(PropertyGroup):
)
class VerticalPIMarker(PropertyGroup):
"""One interior PI of a vertical alignment, for post-draw curve editing.
Unlike horizontal PI markers (real Blender Empties positioned at their
actual 3D location — see PICurveMarkerProperties), a vertical PI has no
meaningful position in real 3D space, only in the profile view's own
synthetic (distance-along, elevation) space. So instead of a scene
object, interior vertical PIs are tracked here as a plain list, edited
via a table in the panel (ALIGN_PT_alignment_authoring), and applied by
align.apply_vertical_pi_curve.
"""
dist_along: FloatProperty(name="Distance Along", default=0.0, precision=2)
elevation: FloatProperty(name="Elevation", default=0.0, precision=3, unit="LENGTH")
curve_type: EnumProperty(
name="Curve Type",
items=[
("TANGENT", "None (sharp PI)", "No curve — the two grades meet directly"),
("PARABOLIC", "Parabolic", "A parabolic vertical curve"),
],
default="TANGENT",
)
curve_length: FloatProperty(
name="Curve Length", description="Horizontal length of the parabolic curve", default=100.0, min=0.0001, unit="LENGTH"
)
class CivilAlignmentProperties(PropertyGroup):
"""Properties for the alignment module"""
@@ -207,6 +234,10 @@ class CivilAlignmentProperties(PropertyGroup):
default=True,
)
# Interior PIs of the most recently drawn/edited vertical alignment
vertical_pi_markers: CollectionProperty(type=VerticalPIMarker)
active_vertical_pi_marker_index: IntProperty(name="Active Vertical PI", default=0)
# Per-vertical visibility filter for the profile window
vertical_items: CollectionProperty(type=VerticalAlignmentItem)
+78 -1
View File
@@ -28,7 +28,7 @@ import math
import ifcopenshell.api.alignment
import ifcopenshell.util.geolocation
import bonsai.tool as tool
from bpy.types import Panel, Operator
from bpy.types import Panel, Operator, UIList
from bpy.props import IntProperty, BoolProperty
from .prop import _alignment_enum_items
from .operator import _find_pi_markers, _resolve_alignment_id_for_markers, _is_interior_pi_marker
@@ -104,6 +104,28 @@ class ALIGN_OT_toggle_cant_segments(Operator):
return {"FINISHED"}
class ALIGN_UL_vertical_pi_markers(UIList):
"""UIList for the interior PIs of a just-drawn/edited vertical alignment.
One row per interior PI: distance along, elevation, curve type, and (when
curved) curve length — edited inline, applied all at once via
align.apply_vertical_pi_curve.
"""
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.row(align=True)
row.label(text=str(index + 1))
row.label(text=f"{item.dist_along:.2f}")
row.label(text=f"{item.elevation:.3f}")
row.prop(item, "curve_type", text="")
if item.curve_type == "PARABOLIC":
row.prop(item, "curve_length", text="")
else:
row.label(text="")
# =============================================================================
# Alignments Tab Segment Breakdown Panel
# =============================================================================
@@ -207,6 +229,61 @@ class ALIGN_PT_alignment_authoring(Panel):
box.operator("align.clear_pi_markers", icon="TRASH")
class ALIGN_PT_vertical_alignment_authoring(Panel):
"""Draw a vertical alignment by PI, in the docked profile view — Alignments tab.
Requires the horizontal alignment to already be drawn (a vertical
alignment is defined against the horizontal's distance-along range).
Opens the profile view if it isn't already open, then runs the same
draw-sharp-then-apply-curves workflow as the horizontal tool: draw all
PIs first, then set a curve length per interior PI and Apply.
"""
bl_label = "Vertical Alignment"
bl_idname = "ALIGN_PT_vertical_alignment_authoring"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_alignments"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
if not tool.Blender.should_show_panel(context, "ALIGNMENTS", cls.bl_idname):
return False
return is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
col = layout.column(align=True)
col.operator("align.draw_vertical_alignment", icon="EYEDROPPER")
if props.vertical_pi_markers:
box = layout.box()
box.label(text="Vertical PIs", icon="ANIM_DATA")
header = box.row(align=True)
header.label(text="#")
header.label(text="Dist Along")
header.label(text="Elevation")
header.label(text="Curve")
box.template_list(
"ALIGN_UL_vertical_pi_markers",
"",
props,
"vertical_pi_markers",
props,
"active_vertical_pi_marker_index",
rows=4,
)
row = box.row(align=True)
row.operator("align.apply_vertical_pi_curve", icon="CHECKMARK")
row.operator("align.clear_vertical_pi_markers", text="", icon="TRASH")
class ALIGN_PT_alignment_stationing_authoring(Panel):
"""Start station and station equations — Alignments tab.
+77
View File
@@ -379,6 +379,30 @@ class Alignment:
end = list(segments[-1].DesignParameters.StartPoint.Coordinates)
return start, end
@classmethod
def get_vertical_alignment_start_end_points(
cls, alignment: ifcopenshell.entity_instance
) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""The vertical alignment's start and end (distance_along, elevation) points.
Mirrors get_alignment_start_end_points() for the vertical layout: the
first real segment's (StartDistAlong, StartHeight) is the start. The
end is evaluated at the last real segment's own end — exact for both
CONSTANTGRADIENT and PARABOLICARC, since a parabola's average
gradient over its length is exactly (StartGradient + EndGradient) / 2.
"""
v_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
segments = list(ifcopenshell.api.alignment.get_layout_segments(v_layout)) if v_layout else []
real_segments = [s for s in segments if not cls.is_zero_length_segment(s)]
if not real_segments:
raise ValueError(f"Alignment #{alignment.id()} has no vertical segments yet")
first_dp = real_segments[0].DesignParameters
start = (first_dp.StartDistAlong, first_dp.StartHeight)
last_dp = real_segments[-1].DesignParameters
end_dist = last_dp.StartDistAlong + last_dp.HorizontalLength
end_elev = last_dp.StartHeight + 0.5 * (last_dp.StartGradient + last_dp.EndGradient) * last_dp.HorizontalLength
return start, (end_dist, end_elev)
@classmethod
def remove_layout_and_child_layout_objects(cls, alignment: ifcopenshell.entity_instance) -> int:
"""Remove any layout/segment objects left from the old per-segment
@@ -1075,6 +1099,59 @@ class Alignment:
return True
@classmethod
def safe_layout_vertical_by_pi_method(
cls, ifc_file: "ifcopenshell.file", layout: "ifcopenshell.entity_instance", vpoints: list, lengths: list
) -> bool:
"""Safely add segments to a vertical layout using the PI method.
Mirrors safe_layout_horizontal_by_pi_method — validates the layout has
a valid parent alignment before calling the IfcOpenShell API.
Args:
ifc_file: The IFC file
layout: The IfcAlignmentVertical layout
vpoints: List of (distance_along, elevation) pairs for PIs, including start/end
lengths: Horizontal length of the parabolic curve at each interior PI (0.0 = sharp)
Returns:
True if successful
Raises:
ValueError: If layout has no parent alignment
"""
import ifcopenshell.api.alignment as align_api
alignment = cls.validate_layout_has_parent_alignment(layout)
if alignment is None:
raise ValueError(
f"Layout #{layout.id()} ({layout.is_a()}) has no parent IfcAlignment. "
"This may be an orphan layout from undo/redo. "
"Cannot add segments without a valid parent alignment."
)
align_api.layout_vertical_alignment_by_pi_method(ifc_file, layout, vpoints, lengths)
return True
@classmethod
def get_horizontal_alignment_length(cls, h_layout: "ifcopenshell.entity_instance") -> float:
"""Total plan length of a horizontal layout's real (non-zero-length) segments.
Used to seed the vertical profile view's distance-along range before
any vertical layout exists yet — the vertical PI drawing tool needs a
sensible canvas width spanning the whole horizontal alignment.
"""
import ifcopenshell.api.alignment as align_api
total = 0.0
for segment in align_api.get_layout_segments(h_layout) or []:
dp = segment.DesignParameters
if not dp:
continue
total += abs(getattr(dp, "SegmentLength", 0.0) or 0.0)
return total
@classmethod
def get_active_alignment(cls) -> ifcopenshell.entity_instance | None:
if obj := tool.Blender.get_active_object():