Continuing to advance the infrastructure editing UI

This commit is contained in:
Richard Brice
2026-09-15 16:45:03 -07:00
parent 6a95717072
commit bd23f41af9
5 changed files with 806 additions and 153 deletions
@@ -123,6 +123,8 @@ classes = (
operator.ImportAlignmentCSV, operator.ImportAlignmentCSV,
# Operators - Vertical Profile Window # Operators - Vertical Profile Window
operator.ALIGN_OT_show_vertical_profile, operator.ALIGN_OT_show_vertical_profile,
operator.ALIGN_OT_pan_vertical_profile,
operator.ALIGN_OT_reset_vertical_profile_view,
# Operators - Segment Selection # Operators - Segment Selection
operator.ALIGN_OT_select_h_segment, operator.ALIGN_OT_select_h_segment,
operator.ALIGN_OT_select_v_segment, operator.ALIGN_OT_select_v_segment,
@@ -134,11 +136,13 @@ classes = (
operator.ALIGN_OT_add_station_equation, operator.ALIGN_OT_add_station_equation,
operator.ALIGN_OT_edit_station_equation, operator.ALIGN_OT_edit_station_equation,
operator.ALIGN_OT_remove_station_equation, operator.ALIGN_OT_remove_station_equation,
operator.ALIGN_OT_edit_horizontal_pis,
operator.ALIGN_OT_apply_pi_curve, operator.ALIGN_OT_apply_pi_curve,
operator.ALIGN_OT_clear_pi_markers, operator.ALIGN_OT_clear_pi_markers,
operator.ALIGN_OT_draw_horizontal_alignment, operator.ALIGN_OT_draw_horizontal_alignment,
# Operators - Vertical alignment authoring (draw-by-PI in the profile view) # Operators - Vertical alignment authoring (draw-by-PI in the profile view)
operator.ALIGN_OT_draw_vertical_alignment, operator.ALIGN_OT_draw_vertical_alignment,
operator.ALIGN_OT_load_vertical_pis,
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) # Operators - Segment table editing (stage edits, then Apply)
@@ -166,6 +170,9 @@ def menu_func_import(self, context):
self.layout.operator(operator.ImportAlignmentCSV.bl_idname, text="Alignment (.csv)") self.layout.operator(operator.ImportAlignmentCSV.bl_idname, text="Alignment (.csv)")
addon_keymaps = []
def register(): def register():
bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties) bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties) bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties)
@@ -180,6 +187,23 @@ def register():
VerticalProfileDecorator.profile_area = None VerticalProfileDecorator.profile_area = None
VerticalProfileDecorator.profile_area_ptr = 0 VerticalProfileDecorator.profile_area_ptr = 0
# Shift+wheel pans and Home resets the vertical profile view. Registered on
# the generic "3D View" keymap since it needs to fire in any VIEW_3D area,
# but the operators' poll() only allows them in the docked profile area
# (falling through to Blender's defaults, e.g. view3d.view_all on Home,
# everywhere else).
wm = bpy.context.window_manager
if wm.keyconfigs.addon:
km = wm.keyconfigs.addon.keymaps.new(name="3D View", space_type="VIEW_3D")
kmi = km.keymap_items.new(operator.ALIGN_OT_pan_vertical_profile.bl_idname, "WHEELUPMOUSE", "PRESS", shift=True)
kmi.properties.direction = -1
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new(operator.ALIGN_OT_pan_vertical_profile.bl_idname, "WHEELDOWNMOUSE", "PRESS", shift=True)
kmi.properties.direction = 1
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new(operator.ALIGN_OT_reset_vertical_profile_view.bl_idname, "HOME", "PRESS")
addon_keymaps.append((km, kmi))
def unregister(): def unregister():
if _on_active_object_changed in bpy.app.handlers.depsgraph_update_post: if _on_active_object_changed in bpy.app.handlers.depsgraph_update_post:
@@ -196,3 +220,9 @@ def unregister():
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
del bpy.types.Scene.CivilAlignmentProperties del bpy.types.Scene.CivilAlignmentProperties
del bpy.types.Object.bonsai_pi_curve_marker del bpy.types.Object.bonsai_pi_curve_marker
wm = bpy.context.window_manager
if wm.keyconfigs.addon:
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
@@ -38,6 +38,9 @@ from typing import Optional, Tuple
from bpy.types import SpaceView3D from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d
from gpu_extras.batch import batch_for_shader from gpu_extras.batch import batch_for_shader
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
class AlignmentSegmentDecorator: class AlignmentSegmentDecorator:
@@ -280,6 +283,19 @@ class AlignmentSegmentDecorator:
if segment not in segments: if segment not in segments:
return return
seg_idx = segments.index(segment) seg_idx = segments.index(segment)
seg_type = getattr(dp, "PredefinedType", "") or ""
# Key-point labels (P.C., T.S., S.C., P.O.B., ...) — shares
# _get_segment_start_point_label with update_key_point_referents so the
# on-screen labels always agree with the IfcReferents it creates,
# including any jurisdiction-specific naming registered via
# register_referent_name_callback().
prev_segment = segments[seg_idx - 1] if seg_idx > 0 else None
next_segment = segments[seg_idx + 1] if seg_idx + 1 < len(segments) else None
if next_segment is not None and tool.Alignment.is_zero_length_segment(next_segment):
next_segment = None # the mandatory terminator isn't a real transition
start_label = _get_segment_start_point_label(prev_segment, segment)
end_label = _get_segment_start_point_label(segment, next_segment)
# IFC local start coordinate and start tangent direction # IFC local start coordinate and start tangent direction
sx, sy = dp.StartPoint.Coordinates[0], dp.StartPoint.Coordinates[1] sx, sy = dp.StartPoint.Coordinates[0], dp.StartPoint.Coordinates[1]
@@ -289,10 +305,21 @@ class AlignmentSegmentDecorator:
# Next segment data — provides the IFC end-point and end tangent # Next segment data — provides the IFC end-point and end tangent
has_next = seg_idx + 1 < len(segments) has_next = seg_idx + 1 < len(segments)
next_dp = segments[seg_idx + 1].DesignParameters if has_next else None next_dp = segments[seg_idx + 1].DesignParameters if has_next else None
arc_radius = getattr(dp, "StartRadiusOfCurvature", None) or 0.0
if next_dp and getattr(next_dp, "StartPoint", None): if next_dp and getattr(next_dp, "StartPoint", None):
ex = next_dp.StartPoint.Coordinates[0] ex = next_dp.StartPoint.Coordinates[0]
ey = next_dp.StartPoint.Coordinates[1] ey = next_dp.StartPoint.Coordinates[1]
d2x, d2y = math.cos(next_dp.StartDirection), math.sin(next_dp.StartDirection) d2x, d2y = math.cos(next_dp.StartDirection), math.sin(next_dp.StartDirection)
elif seg_type == "CIRCULARARC" and abs(arc_radius) > 1e-6:
# No next segment to read the true end tangent from (last/only
# segment) — the straight-line fallback below would make it
# parallel to the start tangent, degenerating the PI below to "none".
turn = seg_len / arc_radius
cos_t, sin_t = math.cos(turn), math.sin(turn)
d2x, d2y = d1x * cos_t - d1y * sin_t, d1x * sin_t + d1y * cos_t
cix, ciy = sx + arc_radius * -d1y, sy + arc_radius * d1x
rvx, rvy = sx - cix, sy - ciy
ex, ey = cix + rvx * cos_t - rvy * sin_t, ciy + rvx * sin_t + rvy * cos_t
else: else:
# Last segment or next has no StartPoint — approximate end along start tangent # Last segment or next has no StartPoint — approximate end along start tangent
ex = sx + seg_len * d1x ex = sx + seg_len * d1x
@@ -370,13 +397,14 @@ class AlignmentSegmentDecorator:
"end_en": (e_enh[0], e_enh[1]), "end_en": (e_enh[0], e_enh[1]),
"unit_symbol": unit_symbol, "unit_symbol": unit_symbol,
"station_separator": station_separator, "station_separator": station_separator,
"start_label": start_label,
"end_label": end_label,
"has_pi": False, "has_pi": False,
} }
# PI and perpendicular-tick data — only for non-linear segments with a finite PI # PI and perpendicular-tick data — only for non-linear segments with a finite PI
seg_type = getattr(dp, "PredefinedType", "") or ""
denom_ifc = d1x * d2y - d1y * d2x denom_ifc = d1x * d2y - d1y * d2x
if seg_type == "LINESEGMENT" or abs(denom_ifc) < 1e-10: if seg_type == "LINE" or abs(denom_ifc) < 1e-10:
return # Linear or parallel tangents — labels only, no PI geometry return # Linear or parallel tangents — labels only, no PI geometry
dx_ifc, dy_ifc = ex - sx, ey - sy dx_ifc, dy_ifc = ex - sx, ey - sy
@@ -425,6 +453,26 @@ class AlignmentSegmentDecorator:
except Exception: except Exception:
pass pass
if center_world is None:
# Non-circular curves (spirals, etc.) have no single center, but the
# PC/PT normals still meet at a useful reference point — same
# line-intersection as the PI above, using the perpendicular
# directions instead of the tangents.
pc_perp_ifc = (-d1y * sign_turn, d1x * sign_turn)
pt_perp_ifc = (-d2y * sign_turn, d2x * sign_turn)
perp_denom = pc_perp_ifc[0] * pt_perp_ifc[1] - pc_perp_ifc[1] * pt_perp_ifc[0]
if abs(perp_denom) > 1e-10:
t2 = (dx_ifc * pt_perp_ifc[1] - dy_ifc * pt_perp_ifc[0]) / perp_denom
cix = sx + t2 * pc_perp_ifc[0]
ciy = sy + t2 * pc_perp_ifc[1]
cwx, cwy = ifc_to_world_xy(cix, ciy)
center_world = (cwx, cwy, pi_wz)
try:
c_enh = ifcopenshell.util.geolocation.auto_xyz2enh(ifc_file, cix, ciy, 0.0)
center_en = (c_enh[0], c_enh[1])
except Exception:
pass
cls.tangent_data.update({ cls.tangent_data.update({
"has_pi": True, "has_pi": True,
"pi_world": (pi_wx, pi_wy, pi_wz), "pi_world": (pi_wx, pi_wy, pi_wz),
@@ -563,9 +611,10 @@ class AlignmentSegmentDecorator:
def draw_label(self, context): def draw_label(self, context):
"""Draw point labels with station and E/N coordinates in screen space. """Draw point labels with station and E/N coordinates in screen space.
Curve segments (has_pi=True): PC label, PI crosshair + label, PT label. Start/end tags (P.C., T.S., S.C., P.O.B., ...) come from
Linear segments (has_pi=False): start and end station + coords, no tag prefix. _get_segment_start_point_label — see _compute_tangent_data. Curve
Circular arcs: also label the center of curvature. segments (has_pi=True) also get a PI crosshair + label, and — where a
finite curve center or normals intersection exists — a labeled center.
""" """
if not self.__class__.is_installed: if not self.__class__.is_installed:
# draw_segment (POST_VIEW, runs first) may have just auto-cleared # draw_segment (POST_VIEW, runs first) may have just auto-cleared
@@ -615,7 +664,7 @@ class AlignmentSegmentDecorator:
if not screen: if not screen:
return return
sx, sy = screen.x, screen.y sx, sy = screen.x, screen.y
if name == "PI": if name == "P.I.":
self._draw_screen_crosshair(sx, sy, self.COLOR_PI, region) self._draw_screen_crosshair(sx, sy, self.COLOR_PI, region)
blf.size(font_id, font_size) blf.size(font_id, font_size)
blf.color(font_id, *color) blf.color(font_id, *color)
@@ -623,38 +672,48 @@ class AlignmentSegmentDecorator:
if station: if station:
lines.append(f"Sta {station}") lines.append(f"Sta {station}")
lines.append(coords) lines.append(coords)
for i, line in enumerate(reversed(lines)): # Stack upward from the point by default; flip downward near the
blf.position(font_id, sx + 12, sy + 4 + i * line_h, 0) # top edge so a multi-line label can't run off-screen -- a point
blf.draw(font_id, line) # can end up arbitrarily close to any edge once you zoom in far
# enough.
total_h = len(lines) * line_h
if sy > region.height - total_h - 8:
for i, line in enumerate(lines):
blf.position(font_id, sx + 12, sy - 4 - (i + 1) * line_h, 0)
blf.draw(font_id, line)
else:
for i, line in enumerate(reversed(lines)):
blf.position(font_id, sx + 12, sy + 4 + i * line_h, 0)
blf.draw(font_id, line)
if has_pi: if has_pi:
# Curve segment: PC / PI / PT with name tags # Curve segment: start tag / PI / end tag
draw_point_label( draw_point_label(
td["start_world"], "PC", fmt_sta(td["pc_station"]), td["start_world"], td["start_label"], fmt_sta(td["pc_station"]),
fmt_en(*td["start_en"]), self.COLOR_LABEL_PC, fmt_en(*td["start_en"]), self.COLOR_LABEL_PC,
) )
draw_point_label( draw_point_label(
td["pi_world"], "PI", None, td["pi_world"], "P.I.", None,
fmt_en(*td["pi_en"]), self.COLOR_LABEL_PI, fmt_en(*td["pi_en"]), self.COLOR_LABEL_PI,
) )
draw_point_label( draw_point_label(
td["end_world"], "PT", fmt_sta(td["pt_station"]), td["end_world"], td["end_label"], fmt_sta(td["pt_station"]),
fmt_en(*td["end_en"]), self.COLOR_LABEL_PT, fmt_en(*td["end_en"]), self.COLOR_LABEL_PT,
) )
# Center of curvature label (circular arcs only) # Center of curvature / normals-intersection label
if td.get("center_world") and td.get("center_en"): if td.get("center_world") and td.get("center_en"):
draw_point_label( draw_point_label(
td["center_world"], "Center", td["center_world"], "Center",
None, fmt_en(*td["center_en"]), self.COLOR_PI, None, fmt_en(*td["center_en"]), self.COLOR_PI,
) )
else: else:
# Linear segment: start and end without PC/PT tags # Linear segment: start and end tags (e.g. P.O.B., T.S.)
draw_point_label( draw_point_label(
td["start_world"], "", fmt_sta(td["pc_station"]), td["start_world"], td["start_label"], fmt_sta(td["pc_station"]),
fmt_en(*td["start_en"]), self.COLOR_LABEL_PC, fmt_en(*td["start_en"]), self.COLOR_LABEL_PC,
) )
draw_point_label( draw_point_label(
td["end_world"], "", fmt_sta(td["pt_station"]), td["end_world"], td["end_label"], fmt_sta(td["pt_station"]),
fmt_en(*td["end_en"]), self.COLOR_LABEL_PT, fmt_en(*td["end_en"]), self.COLOR_LABEL_PT,
) )
else: else:
@@ -781,17 +840,20 @@ class VerticalProfileDecorator:
"""GPU-drawn 2D vertical profile window. """GPU-drawn 2D vertical profile window.
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 fixed, user-
vertical exaggeration factor. Middle-mouse pan/zoom are handled by Blender's adjustable vertical exaggeration factor (CivilAlignmentProperties.
native orthographic navigation; orbit/rotate is continuously suppressed by vertical_exaggeration). Zoom is Blender's native orthographic zoom (mouse
operator._profile_rotation_guard_tick (a bpy.app.timers poll) so the view wheel or the corner navigate gizmo's magnifier); panning is Shift+wheel
stays locked front-on. (operator.ALIGN_OT_pan_vertical_profile) or the gizmo's pan hand -- same as
the main viewport, and the exaggeration itself doesn't change as you zoom/pan,
exactly like zooming the main viewport doesn't stretch a scene. 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, unscaled
world Z = elevation, normalized into the elevation zone so it always world Z = (elevation - elev_ref) * ve -- a fixed linear scale (see _ez),
fills the viewport proportionally (see fit_view/_ez) -- not a function of the current view/zoom
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)
""" """
@@ -822,9 +884,15 @@ class VerticalProfileDecorator:
station_separator: int = 1000 # value at which station '+' splits station_separator: int = 1000 # value at which station '+' splits
_alignment = None # IfcAlignment entity for station conversion at draw time _alignment = None # IfcAlignment entity for station conversion at draw time
# Normalized world-Z zone boundaries (set by fit_view from area dimensions). # Fixed vertical exaggeration: world-Z = (elevation - elev_ref) * ve. Both are
# These replace the old `elev_min * ve` / `elev_max * ve` approach so that # set once by _refit_zones (from CivilAlignmentProperties.vertical_exaggeration
# both zones always fill the viewport proportionally, regardless of elevation scale. # and the data range), not per-frame -- see _ez.
elev_ref: float = 0.0
ve: float = 10.0
# World-Z zone boundaries, derived from elev_ref/ve/the data range by
# _refit_zones -- fixed until the data or ve changes, not a function of the
# current view/zoom.
elev_zone_bot: float = 0.0 elev_zone_bot: float = 0.0
elev_zone_top: float = 1.0 elev_zone_top: float = 1.0
cant_zone_bot: float = -0.4 cant_zone_bot: float = -0.4
@@ -932,6 +1000,7 @@ class VerticalProfileDecorator:
cls.cant_info = [] cls.cant_info = []
cls.available_cants = [] cls.available_cants = []
cls.has_cant = False cls.has_cant = False
cls.elev_ref = 0.0
cls.elev_zone_bot = 0.0 cls.elev_zone_bot = 0.0
cls.elev_zone_top = 1.0 cls.elev_zone_top = 1.0
cls.cant_zone_bot = -0.4 cls.cant_zone_bot = -0.4
@@ -971,20 +1040,23 @@ class VerticalProfileDecorator:
pass pass
@classmethod @classmethod
def fit_view(cls, space, area_width: int = 1920, area_height: int = 400) -> None: def _refit_zones(cls) -> None:
"""Reposition the profile camera so both zones always fill the viewport proportionally. """(Re)derive the fixed elevation/cant zone bounds from the data, the
current vertical exaggeration, and the cant range.
Zone heights are derived from the area aspect ratio so they remain visible Unlike the old per-frame _recompute_zones this replaces, the result depends
regardless of the elevation data scale (including flat/near-zero alignments). only on the data and ve -- never on the current view/zoom -- so it only
needs to run when either of those changes (_compute_profile, fit_view, a
resize, or the vertical_exaggeration property), not every frame.
""" """
h_span = max(cls.dist_max - cls.dist_min, 1.0) try:
# An ortho VIEW_3D shows ~1.08x its view_distance in world height, so the cls.ve = bpy.context.scene.CivilAlignmentProperties.vertical_exaggeration
# distance that frames h_span across ~86% of the pane width is except Exception:
# vd = h_span * (H/W) / (0.86 * 1.08). This is only the initial guess — pass
# draw_3d refines it against the real projection (and re-fits on resize). if not cls.ve or cls.ve <= 0:
ar = area_height / max(area_width, 1) cls.ve = 10.0
vd = h_span * ar / (0.86 * 1.08)
vis_z = 2 * vd cls.elev_ref = (cls.elev_min + cls.elev_max) * 0.5
# Elevation display range: at least 1 m visible so flat profiles show a usable axis. # Elevation display range: at least 1 m visible so flat profiles show a usable axis.
e_span = max(cls.elev_max - cls.elev_min, 0.0) e_span = max(cls.elev_max - cls.elev_min, 0.0)
@@ -992,61 +1064,56 @@ class VerticalProfileDecorator:
cls._e_display_min = cls.elev_min - e_pad * 0.05 cls._e_display_min = cls.elev_min - e_pad * 0.05
cls._e_display_max = cls._e_display_min + max(e_span, 0.0) + e_pad cls._e_display_max = cls._e_display_min + max(e_span, 0.0) + e_pad
# Zone boundaries: cant at bottom, elevation above, small gap between. cls.elev_zone_bot = (cls._e_display_min - cls.elev_ref) * cls.ve
cls.elev_zone_top = (cls._e_display_max - cls.elev_ref) * cls.ve
if cls.has_cant: if cls.has_cant:
total_content_h = vis_z * 0.92 # Cant panel sits below the elevation panel, sized/gapped as fixed
cant_h = total_content_h * 0.25 # fractions of the (now fixed) elevation zone height.
gap_h = vis_z * 0.02 elev_h = cls.elev_zone_top - cls.elev_zone_bot
elev_h = total_content_h - cant_h - gap_h cant_h = elev_h * cls.CANT_HEIGHT_FRACTION
total = elev_h + gap_h + cant_h gap_h = elev_h * cls.CANT_GAP_FRACTION
cls.cant_zone_bot = -total / 2 cls.cant_zone_top = cls.elev_zone_bot - gap_h
cls.cant_zone_top = cls.cant_zone_bot + cant_h cls.cant_zone_bot = cls.cant_zone_top - cant_h
cls.elev_zone_bot = cls.cant_zone_top + gap_h
cls.elev_zone_top = cls.elev_zone_bot + elev_h
# Cant display range with 5 % padding on each side # Cant display range with 5 % padding on each side
c_span = max(cls.cant_max - cls.cant_min, 0.0) c_span = max(cls.cant_max - cls.cant_min, 0.0)
c_pad = max(c_span * 0.10, 0.001) c_pad = max(c_span * 0.10, 0.001)
cls._c_display_min = cls.cant_min - c_pad * 0.05 cls._c_display_min = cls.cant_min - c_pad * 0.05
cls._c_display_max = cls.cant_max + c_pad * 0.95 cls._c_display_max = cls.cant_max + c_pad * 0.95
else:
elev_h = vis_z * 0.90
cls.elev_zone_bot = -elev_h / 2
cls.elev_zone_top = elev_h / 2
mid_d = (cls.dist_min + cls.dist_max) * 0.5
space.region_3d.view_location = mathutils.Vector((mid_d, 0.0, 0.0))
space.region_3d.view_distance = max(vd, 1.0)
cls._xfit_frames = 6
cls._last_region_wh = (0, 0)
@classmethod @classmethod
def _recompute_zones(cls, center_z: float, span_z: float) -> None: def fit_view(cls, space, area_width: int = 1920, area_height: int = 400) -> None:
"""Derive the elevation / cant zone bands from the LIVE visible Z span. """Reposition the profile camera so the fixed data+VE layout is fully visible.
Called every frame from the draw handlers with the Z range actually Chooses view_distance as a "contain" fit of both axes -- whichever of the
measured from the viewport's screen corners. fit_view can only estimate horizontal (distance) or vertical (elevation*ve, plus cant if present)
this from the area size, which Blender has not finalised at split time extent needs more room at the given area's aspect ratio wins, so neither
(and which changes whenever the user drags the pane border) — its axis is cropped. This is only an initial estimate — draw_3d refines it
estimate is routinely 2-4x off, which pushes the bottom (cant) band against the real projection (and re-fits on resize).
clean off the bottom edge of the viewport. Recomputing here from the
real visible span keeps both panels framed correctly no matter what.
""" """
if not math.isfinite(span_z) or span_z <= 0: cls._refit_zones()
return
if cls.has_cant: h_span = max(cls.dist_max - cls.dist_min, 1.0)
content_h = span_z * 0.92 z_bot = cls.cant_zone_bot if cls.has_cant else cls.elev_zone_bot
cant_h = content_h * 0.25 z_span = max(cls.elev_zone_top - z_bot, 1.0)
gap_h = span_z * 0.02
elev_h = content_h - cant_h - gap_h # An ortho VIEW_3D shows ~1.08x its view_distance in world height, so the
cls.cant_zone_bot = center_z - content_h / 2.0 # distance that frames h_span across ~86% of the pane width is
cls.cant_zone_top = cls.cant_zone_bot + cant_h # vd = h_span * (H/W) / (0.86 * 1.08); framing z_span across ~90% of the
cls.elev_zone_bot = cls.cant_zone_top + gap_h # pane height is vd = z_span / (0.90 * 1.08). Take whichever is larger so
cls.elev_zone_top = cls.elev_zone_bot + elev_h # both axes fit (one may end up with extra margin — expected/correct).
else: ar = area_height / max(area_width, 1)
elev_h = span_z * 0.90 vd_for_x = h_span * ar / (0.86 * 1.08)
cls.elev_zone_bot = center_z - elev_h / 2.0 vd_for_z = z_span / (0.90 * 1.08)
cls.elev_zone_top = center_z + elev_h / 2.0 vd = max(vd_for_x, vd_for_z, 1.0)
mid_d = (cls.dist_min + cls.dist_max) * 0.5
mid_z = (cls.elev_zone_top + z_bot) * 0.5
space.region_3d.view_location = mathutils.Vector((mid_d, 0.0, mid_z))
space.region_3d.view_distance = vd
cls._xfit_frames = 6
cls._last_region_wh = (0, 0)
# --------------------------------------------------------------- geometry # --------------------------------------------------------------- geometry
@@ -1072,6 +1139,8 @@ class VerticalProfileDecorator:
color_idx = len(cls.available_verticals) color_idx = len(cls.available_verticals)
cls.available_verticals.append((v_id, v_label)) cls.available_verticals.append((v_id, v_label))
real_segments = [] # entities, in order, for this vertical only
for seg_rel in getattr(layout_entity, "IsNestedBy", []) or []: for seg_rel in getattr(layout_entity, "IsNestedBy", []) or []:
for seg in seg_rel.RelatedObjects or []: for seg in seg_rel.RelatedObjects or []:
if not seg.is_a("IfcAlignmentSegment"): if not seg.is_a("IfcAlignmentSegment"):
@@ -1090,6 +1159,7 @@ class VerticalProfileDecorator:
if h_len <= 0: if h_len <= 0:
continue continue
real_segments.append(seg)
pts = cls._sample_segment(dist, height, h_len, g_start, g_end, seg_type) pts = cls._sample_segment(dist, height, h_len, g_start, g_end, seg_type)
cls.segments_polylines.append(pts) cls.segments_polylines.append(pts)
cls.segments_info.append( cls.segments_info.append(
@@ -1109,6 +1179,20 @@ class VerticalProfileDecorator:
all_dists.extend(d for d, _ in pts) all_dists.extend(d for d, _ in pts)
all_elevs.extend(e for _, e in pts) all_elevs.extend(e for _, e in pts)
# Key-point labels (P.V.C., P.V.I., P.V.T., V.C.C., V.P.O.B., V.P.O.E.) --
# shares _get_segment_start_point_label with update_key_point_referents and
# AlignmentSegmentDecorator's horizontal labeling, so these always agree with
# the IfcReferents that function creates, including any jurisdiction-specific
# naming registered via register_referent_name_callback().
n = len(real_segments)
start_index = len(cls.segments_info) - n
for k, seg in enumerate(real_segments):
prev_seg = real_segments[k - 1] if k > 0 else None
next_seg = real_segments[k + 1] if k + 1 < n else None
info = cls.segments_info[start_index + k]
info["start_label"] = _get_segment_start_point_label(prev_seg, seg)
info["end_label"] = _get_segment_start_point_label(seg, next_seg)
if all_dists: if all_dists:
cls.dist_min = min(all_dists) cls.dist_min = min(all_dists)
cls.dist_max = max(all_dists) cls.dist_max = max(all_dists)
@@ -1152,6 +1236,10 @@ class VerticalProfileDecorator:
info["pvi"] = pvi info["pvi"] = pvi
info["is_curve"] = is_curve info["is_curve"] = is_curve
# New data means the fixed elevation/cant zones (and the VE they're built
# from) need recomputing -- see _refit_zones.
cls._refit_zones()
@classmethod @classmethod
def _collect_cant_data(cls, alignment) -> None: def _collect_cant_data(cls, alignment) -> None:
"""Collect IfcAlignmentCant segments and build the cant profile arrays.""" """Collect IfcAlignmentCant segments and build the cant profile arrays."""
@@ -1347,10 +1435,8 @@ class VerticalProfileDecorator:
@classmethod @classmethod
def _ez(cls, e: float) -> float: def _ez(cls, e: float) -> float:
"""Map an elevation data value to world-Z within the elevation zone.""" """Map an elevation data value to world-Z via the fixed vertical exaggeration."""
e_span = max(cls._e_display_max - cls._e_display_min, 1e-10) return (e - cls.elev_ref) * cls.ve
t = (e - cls._e_display_min) / e_span
return cls.elev_zone_bot + t * (cls.elev_zone_top - cls.elev_zone_bot)
@classmethod @classmethod
def screen_to_data( def screen_to_data(
@@ -1368,12 +1454,8 @@ class VerticalProfileDecorator:
looks like it's outside the profile area, so clicks silently do looks like it's outside the profile area, so clicks silently do
nothing — see ALIGN_OT_draw_vertical_alignment._locate_profile_view. nothing — see ALIGN_OT_draw_vertical_alignment._locate_profile_view.
Inverse of the data -> world-Z mapping draw_3d uses for its grid/curve Inverse of _ez (world X is distance-along directly; world Z is a fixed
(world X is distance-along directly; world Z goes through _ez's linear function of elevation, so no per-call zone resync is needed).
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. Returns None if there's no valid region/view to project against.
""" """
@@ -1385,16 +1467,7 @@ class VerticalProfileDecorator:
if point is None: if point is None:
return None return None
bl = region_2d_to_location_3d(region, rv3d, (0, 0), ref) elevation = point.z / cls.ve + cls.elev_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 return point.x, elevation
@classmethod @classmethod
@@ -1443,36 +1516,40 @@ class VerticalProfileDecorator:
vis_d_min, vis_d_max = bl.x, tr.x vis_d_min, vis_d_max = bl.x, tr.x
vis_z_min, vis_z_max = bl.z, tr.z vis_z_min, vis_z_max = bl.z, tr.z
# --- Horizontal-zoom self-correction -------------------------------- # --- Zoom self-correction (both axes) --------------------------------
# fit_view can only estimate the ortho projection; nail the X framing # fit_view can only estimate the ortho projection; nail the real framing
# against the real one here so the whole alignment (segment 1 to the # here so the whole alignment (and the fixed elevation/cant zones) are on
# end) is on screen, and re-fit whenever the pane is resized. # screen, and re-fit whenever the pane is resized. Zones themselves are
# fixed (see _refit_zones) — only the camera's view_distance is corrected,
# exactly like the main viewport doesn't restretch a scene as you zoom.
wh = (region.width, region.height) wh = (region.width, region.height)
if wh != cls._last_region_wh: if wh != cls._last_region_wh:
cls._last_region_wh = wh cls._last_region_wh = wh
cls._xfit_frames = 6 cls._xfit_frames = 6
if cls._xfit_frames > 0: if cls._xfit_frames > 0:
cls._xfit_frames -= 1 cls._xfit_frames -= 1
vis_span = vis_d_max - vis_d_min vis_span_x = vis_d_max - vis_d_min
data_span = max(cls.dist_max - cls.dist_min, 1e-6) vis_span_z = vis_z_max - vis_z_min
if vis_span > 1e-6: data_span_x = max(cls.dist_max - cls.dist_min, 1e-6)
ratio = (data_span / 0.88) / vis_span # alignment fills ~88% of width z_bot = cls.cant_zone_bot if cls.has_cant else cls.elev_zone_bot
if abs(ratio - 1.0) > 0.02: data_span_z = max(cls.elev_zone_top - z_bot, 1e-6)
# Adjust and repaint next frame; this frame still draws ratio_x = (data_span_x / 0.88) / vis_span_x if vis_span_x > 1e-6 else 1.0 # fills ~88% of width
# (one slightly-off frame reads better than a blank flash). ratio_z = (data_span_z / 0.92) / vis_span_z if vis_span_z > 1e-6 else 1.0 # fills ~92% of height
try: ratio = max(ratio_x, ratio_z) # whichever axis needs more room wins (contain fit)
rv3d.view_distance = max(rv3d.view_distance * ratio, 1.0) if abs(ratio - 1.0) > 0.02:
rv3d.view_location = mathutils.Vector( # Adjust and repaint next frame; this frame still draws
((cls.dist_min + cls.dist_max) * 0.5, 0.0, 0.0) # (one slightly-off frame reads better than a blank flash).
) try:
bpy.context.area.tag_redraw() rv3d.view_distance = max(rv3d.view_distance * ratio, 1.0)
except Exception: mid_z = (cls.elev_zone_top + z_bot) * 0.5
pass rv3d.view_location = mathutils.Vector(
else: ((cls.dist_min + cls.dist_max) * 0.5, 0.0, mid_z)
cls._xfit_frames = 0 )
bpy.context.area.tag_redraw()
# Frame the zone bands to the Z span actually visible right now. except Exception:
cls._recompute_zones((vis_z_min + vis_z_max) * 0.5, vis_z_max - vis_z_min) pass
else:
cls._xfit_frames = 0
# Small padding so grid lines fully cover the viewport edges # Small padding so grid lines fully cover the viewport edges
d_pad = (vis_d_max - vis_d_min) * 0.02 d_pad = (vis_d_max - vis_d_min) * 0.02
@@ -1829,9 +1906,6 @@ class VerticalProfileDecorator:
vis_d_min, vis_d_max = bl.x, tr.x vis_d_min, vis_d_max = bl.x, tr.x
vis_z_min, vis_z_max = bl.z, tr.z vis_z_min, vis_z_max = bl.z, tr.z
# Keep the label geometry in lock-step with draw_3d's zone framing.
cls._recompute_zones((vis_z_min + vis_z_max) * 0.5, vis_z_max - vis_z_min)
d_interval = _nice_interval(vis_d_max - vis_d_min, 8) d_interval = _nice_interval(vis_d_max - vis_d_min, 8)
vis_e_min_clamp = cls._e_display_min vis_e_min_clamp = cls._e_display_min
vis_e_max_clamp = cls._e_display_max vis_e_max_clamp = cls._e_display_max
@@ -1948,9 +2022,18 @@ class VerticalProfileDecorator:
gpu.state.blend_set("NONE") gpu.state.blend_set("NONE")
blf.size(font_id, pt_font_size) blf.size(font_id, pt_font_size)
blf.color(font_id, *color) blf.color(font_id, *color)
for j, line in enumerate(reversed(stacked_lines)): # Stack upward from the point by default; flip downward near the top
blf.position(font_id, sx + 12, sy + 4 + j * pt_line_h, 0) # edge so a multi-line label can't run off-screen -- a point can end
blf.draw(font_id, line) # up arbitrarily close to any edge once you zoom in far enough.
total_h = len(stacked_lines) * pt_line_h
if sy > region.height - total_h - 8:
for j, line in enumerate(stacked_lines):
blf.position(font_id, sx + 12, sy - 4 - (j + 1) * pt_line_h, 0)
blf.draw(font_id, line)
else:
for j, line in enumerate(reversed(stacked_lines)):
blf.position(font_id, sx + 12, sy + 4 + j * pt_line_h, 0)
blf.draw(font_id, line)
# Track labeled stations per vertical so duplicate-suppression stays # Track labeled stations per vertical so duplicate-suppression stays
# within one vertical (different verticals can share the same station). # within one vertical (different verticals can share the same station).
@@ -1989,11 +2072,17 @@ class VerticalProfileDecorator:
# When multiple verticals are visible, prefix point names with the vertical label # When multiple verticals are visible, prefix point names with the vertical label
pfx = f" [{v_label}]" if show_vertical_prefix and v_label else "" pfx = f" [{v_label}]" if show_vertical_prefix and v_label else ""
# start_label/end_label come from _get_segment_start_point_label (set in
# _compute_profile) -- e.g. "P.V.C."/"P.V.T." for a real curve, "P.V.I."
# for a sharp break, "V.P.O.B."/"V.P.O.E." at the alignment's true ends.
start_label = info.get("start_label", "")
end_label = info.get("end_label", "")
if is_curve: if is_curve:
if bvc_key not in labeled: if bvc_key not in labeled:
_draw_vp_label( _draw_vp_label(
bvc_w, bvc_w,
[f"BVC{pfx}", f"Sta {sta_bvc}", f"Elev {_fmt_elev(bvc_e, e_interval)}"], [f"{start_label}{pfx}", f"Sta {sta_bvc}", f"Elev {_fmt_elev(bvc_e, e_interval)}"],
cls.COLOR_BVC, cls.COLOR_BVC,
) )
labeled.add(bvc_key) labeled.add(bvc_key)
@@ -2003,21 +2092,21 @@ class VerticalProfileDecorator:
sta_pvi = cls._dist_to_station_str(pvi_d) sta_pvi = cls._dist_to_station_str(pvi_d)
_draw_vp_label( _draw_vp_label(
pvi_w, pvi_w,
[f"PVI{pfx}", f"Sta {sta_pvi}", f"Elev {_fmt_elev(pvi_e, e_interval)}"], [f"P.V.I.{pfx}", f"Sta {sta_pvi}", f"Elev {_fmt_elev(pvi_e, e_interval)}"],
cls.COLOR_PVI_VERT, cls.COLOR_PVI_VERT,
draw_cross=True, draw_cross=True,
) )
if evc_key not in labeled: if evc_key not in labeled:
_draw_vp_label( _draw_vp_label(
evc_w, evc_w,
[f"EVC{pfx}", f"Sta {sta_evc}", f"Elev {_fmt_elev(evc_e, e_interval)}"], [f"{end_label}{pfx}", f"Sta {sta_evc}", f"Elev {_fmt_elev(evc_e, e_interval)}"],
cls.COLOR_EVC, cls.COLOR_EVC,
) )
labeled.add(evc_key) labeled.add(evc_key)
else: else:
g_pct = info["g_start"] * 100.0 g_pct = info["g_start"] * 100.0
if bvc_key not in labeled: if bvc_key not in labeled:
lines = [f"Sta {sta_bvc}", f"Elev {_fmt_elev(bvc_e, e_interval)}", f"{g_pct:+.2f}%"] lines = [f"{start_label}{pfx}", f"Sta {sta_bvc}", f"Elev {_fmt_elev(bvc_e, e_interval)}", f"{g_pct:+.2f}%"]
if show_vertical_prefix and v_label: if show_vertical_prefix and v_label:
lines.append(f"[{v_label}]") lines.append(f"[{v_label}]")
_draw_vp_label(bvc_w, lines, cls.COLOR_GRAD) _draw_vp_label(bvc_w, lines, cls.COLOR_GRAD)
@@ -2030,7 +2119,7 @@ class VerticalProfileDecorator:
if is_last_for_vertical and evc_key not in labeled: if is_last_for_vertical and evc_key not in labeled:
_draw_vp_label( _draw_vp_label(
evc_w, evc_w,
[f"Sta {sta_evc}", f"Elev {_fmt_elev(evc_e, e_interval)}"], [f"{end_label}{pfx}", f"Sta {sta_evc}", f"Elev {_fmt_elev(evc_e, e_interval)}"],
cls.COLOR_GRAD, cls.COLOR_GRAD,
) )
labeled.add(evc_key) labeled.add(evc_key)
@@ -22,13 +22,16 @@
import bpy import bpy
import blf import blf
import math import math
import mathutils
import time import time
from typing import TYPE_CHECKING
import bonsai.core.alignment as core import bonsai.core.alignment as core
import bonsai.tool as tool import bonsai.tool as tool
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.util.geolocation import ifcopenshell.util.geolocation
import ifcopenshell.util.unit import ifcopenshell.util.unit
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper
from bpy_extras.view3d_utils import region_2d_to_location_3d
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
@@ -746,6 +749,181 @@ def _pi_curve_marker_label(marker) -> str:
return f"R={marker.radius:.2f}, Lin={marker.spiral_in_length:.2f}, Lout={marker.spiral_out_length:.2f}" return f"R={marker.radius:.2f}, Lin={marker.spiral_in_length:.2f}, Lout={marker.spiral_out_length:.2f}"
def _local_ifc_to_world_point(ifc, unit_scale, xy):
"""Inverse of _world_point_to_local_ifc: local IFC (x, y) -> Blender-world (metres)."""
e, n = ifcopenshell.util.geolocation.auto_xyz2enh(ifc, xy[0], xy[1], 0.0)[:2]
local = tool.Georeference.enh2xyz((e, n, 0.0))
return (local[0] * unit_scale, local[1] * unit_scale, 0.0)
def _tangent_line_intersection(dp_a, dp_b):
"""Where two LINE segments' own tangent lines cross, in local IFC coords.
Same technique as AlignmentSegmentDecorator._compute_tangent_data's PI
computation. Returns None if the two tangents are parallel (no PI).
"""
sx, sy = dp_a.StartPoint.Coordinates[0], dp_a.StartPoint.Coordinates[1]
ex, ey = dp_b.StartPoint.Coordinates[0], dp_b.StartPoint.Coordinates[1]
d1x, d1y = math.cos(dp_a.StartDirection), math.sin(dp_a.StartDirection)
d2x, d2y = math.cos(dp_b.StartDirection), math.sin(dp_b.StartDirection)
denom = d1x * d2y - d1y * d2x
if abs(denom) < 1e-10:
return None
t1 = ((ex - sx) * d2y - (ey - sy) * d2x) / denom
return sx + t1 * d1x, sy + t1 * d1y
def _reconstruct_horizontal_pis(h_layout):
"""Classify every interior PI of h_layout's current real segments.
Only the five shapes PICurveMarkerProperties.curve_type already supports
are recognized: a sharp corner between two LINEs (TANGENT), a lone
CIRCULARARC (CIRCULAR), or a CIRCULARARC with a CLOTHOID on one or both
sides (SPIRAL_CIRCULAR / CIRCULAR_SPIRAL / SPIRAL_CIRCULAR_SPIRAL) --
matching what solve_horizontal_alignment_by_pi_method can (re)generate.
Returns (specs, skipped). ``specs`` is a list of dicts with pi_local
(x, y) plus curve_type/radius/spiral_in_length/spiral_out_length, one per
interior PI, in order. ``skipped`` is a list of (segment, reason) for any
segment that isn't part of one of those five shapes -- callers should
refuse to create markers at all when this is non-empty (regenerating from
a partial marker list would silently drop whatever those segments were).
"""
segments = tool.Alignment.get_real_layout_segments(h_layout)
line_indices = [i for i, s in enumerate(segments) if s.DesignParameters.PredefinedType == "LINE"]
specs = []
skipped = []
if len(line_indices) < 2:
return specs, [(s, "no bounding tangent") for s in segments]
for s in segments[: line_indices[0]]:
skipped.append((s, "before the first tangent"))
for s in segments[line_indices[-1] + 1 :]:
skipped.append((s, "after the last tangent"))
for k in range(len(line_indices) - 1):
a_idx, b_idx = line_indices[k], line_indices[k + 1]
line_a, line_b = segments[a_idx], segments[b_idx]
between = segments[a_idx + 1 : b_idx]
types = [s.DesignParameters.PredefinedType for s in between]
if types == []:
curve_type, arc, spiral_in, spiral_out = "TANGENT", None, None, None
elif types == ["CIRCULARARC"]:
curve_type, arc, spiral_in, spiral_out = "CIRCULAR", between[0], None, None
elif types == ["CLOTHOID", "CIRCULARARC"]:
curve_type, arc, spiral_in, spiral_out = "SPIRAL_CIRCULAR", between[1], between[0], None
elif types == ["CIRCULARARC", "CLOTHOID"]:
curve_type, arc, spiral_in, spiral_out = "CIRCULAR_SPIRAL", between[0], None, between[1]
elif types == ["CLOTHOID", "CIRCULARARC", "CLOTHOID"]:
curve_type, arc, spiral_in, spiral_out = "SPIRAL_CIRCULAR_SPIRAL", between[1], between[0], between[2]
else:
skipped.extend((s, "unsupported curve family/shape") for s in between)
continue
pi_local = _tangent_line_intersection(line_a.DesignParameters, line_b.DesignParameters)
if pi_local is None:
# Degenerate (colinear tangents) -- report whatever's between them, or
# the two LINEs themselves if there's nothing between (sharp-corner case).
skipped.extend((s, "tangents are parallel") for s in (between or [line_a, line_b]))
continue
specs.append(
{
"pi_local": pi_local,
"curve_type": curve_type,
# PICurveMarkerProperties.radius (like the radii[] the solver takes) is
# always an unsigned magnitude -- the solver infers turn direction from
# the PI geometry itself, unlike DesignParameters.StartRadiusOfCurvature
# which is signed (+left/-right).
"radius": abs(arc.DesignParameters.StartRadiusOfCurvature or 0.0) if arc else 0.0,
"spiral_in_length": (spiral_in.DesignParameters.SegmentLength or 0.0) if spiral_in else 0.0,
"spiral_out_length": (spiral_out.DesignParameters.SegmentLength or 0.0) if spiral_out else 0.0,
}
)
return specs, skipped
class ALIGN_OT_edit_horizontal_pis(Operator, tool.Ifc.Operator):
"""Create editable PI markers from this alignment's current real segments.
Lets a previously-drawn (and saved) or IFC-imported alignment be tuned
the same way a freshly-drawn one is: select a marker, adjust its curve
type/radius/spiral lengths (or drag it), click "Apply Curve".
"""
bl_idname = "align.edit_horizontal_pis"
bl_label = "Edit PIs"
bl_description = (
"Create PI markers from this alignment's current segments, pre-filled with their "
"existing curve type/radius/spiral lengths, so they can be adjusted or dragged "
"and re-applied without redrawing from scratch"
)
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not poll_ifc4x3(cls, context):
return False
if context.scene.CivilAlignmentProperties.editing_segment_kind != "NONE":
cls.poll_message_set("Finish or cancel the segment table edit first")
return False
alignment = tool.Alignment.get_active_alignment()
if not alignment:
cls.poll_message_set("Select an alignment first")
return False
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
if not h_layout or not tool.Alignment.get_real_layout_segments(h_layout):
cls.poll_message_set("This alignment has no horizontal segments yet")
return False
return True
def _execute(self, context):
alignment = tool.Alignment.get_active_alignment()
alignment_id = alignment.id()
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
specs, skipped = _reconstruct_horizontal_pis(h_layout)
if skipped:
details = "; ".join(f"{s.DesignParameters.PredefinedType} ({reason})" for s, reason in skipped[:5])
more = f", and {len(skipped) - 5} more" if len(skipped) > 5 else ""
self.report(
{"ERROR"},
f"Can't create PI markers: {len(skipped)} segment(s) couldn't be classified: "
f"{details}{more}.",
)
return {"CANCELLED"}
for m in _find_pi_markers(alignment_id):
bpy.data.objects.remove(m, do_unlink=True)
ifc = tool.Ifc.get()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
for i, spec in enumerate(specs, start=1):
x, y, z = _local_ifc_to_world_point(ifc, unit_scale, spec["pi_local"])
empty = bpy.data.objects.new(f"PI {i}", None)
empty.empty_display_type = "SPHERE"
empty.empty_display_size = 2.0
empty.location = (x, y, z)
marker = empty.bonsai_pi_curve_marker
marker.is_pi_marker = True
marker.alignment_id = alignment_id
marker.pi_index = i
marker.curve_type = spec["curve_type"]
marker.radius = spec["radius"] or 100.0
marker.spiral_in_length = spec["spiral_in_length"] or 100.0
marker.spiral_out_length = spec["spiral_out_length"] or 100.0
empty.name = f"PI {i} ({_pi_curve_marker_label(marker)})"
context.collection.objects.link(empty)
alignment_decorator.AlignmentSegmentDecorator.uninstall()
tool.Blender.update_viewport()
self.report({"INFO"}, f"Created {len(specs)} PI marker(s)")
return {"FINISHED"}
class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator): class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator):
"""Regenerate the alignment using the active PI marker's curve settings. """Regenerate the alignment using the active PI marker's curve settings.
@@ -874,9 +1052,16 @@ class ALIGN_OT_draw_horizontal_alignment(bpy.types.Operator, PolylineOperator, t
def poll(cls, context): def poll(cls, context):
if not poll_ifc4x3(cls, context): if not poll_ifc4x3(cls, context):
return False return False
if not tool.Alignment.get_active_alignment(): if context.scene.CivilAlignmentProperties.editing_segment_kind != "NONE":
cls.poll_message_set("Finish or cancel the segment table edit first")
return False
alignment = tool.Alignment.get_active_alignment()
if not alignment:
cls.poll_message_set("Add or select an alignment first") cls.poll_message_set("Add or select an alignment first")
return False return False
if _find_pi_markers(alignment.id()):
cls.poll_message_set("Finish or clear the PI marker edit first")
return False
return True return True
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1198,7 +1383,13 @@ def _open_vertical_profile(context, alignment):
space.overlay.show_axis_x = False space.overlay.show_axis_x = False
space.overlay.show_axis_y = False space.overlay.show_axis_y = False
space.overlay.show_axis_z = False space.overlay.show_axis_z = False
space.show_gizmo = False # Keep the corner navigate gizmo (pan hand / zoom magnifier) so panning here
# is discoverable the same way it is in the main viewport, but drop the
# tool gizmo inherited from the split-off viewport (e.g. an active Move/
# Rotate tool) since there's nothing meaningful to transform here.
space.show_gizmo = True
space.show_gizmo_navigate = True
space.show_gizmo_tool = False
# Hide tool shelf and N-panel so they don't obscure the profile extents. # 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 # Set directly on the space (absolute, not a toggle) so this works reliably
@@ -1222,7 +1413,8 @@ class ALIGN_OT_show_vertical_profile(Operator):
bl_label = "Toggle Vertical Profile" bl_label = "Toggle Vertical Profile"
bl_description = ( bl_description = (
"Dock a 2D vertical profile view below this viewport (click again to close). " "Dock a 2D vertical profile view below this viewport (click again to close). "
"Elevation is exaggerated by the VE factor. Use middle-mouse to pan/zoom." "Elevation is exaggerated by the VE factor. Mouse wheel to zoom, Shift+wheel to pan "
"left/right, Home to reset the view."
) )
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
@@ -1249,12 +1441,87 @@ class ALIGN_OT_show_vertical_profile(Operator):
return {"FINISHED"} return {"FINISHED"}
class ALIGN_OT_pan_vertical_profile(Operator):
"""Pan the docked vertical profile view left/right (Shift+wheel)"""
bl_idname = "align.pan_vertical_profile"
bl_label = "Pan Vertical Profile"
bl_description = "Pan the vertical profile view left/right"
bl_options = {"INTERNAL"}
# -1 pans toward lower stations (left), 1 toward higher stations (right).
direction: IntProperty(default=1)
if TYPE_CHECKING:
direction: int
@classmethod
def poll(cls, context):
dec = alignment_decorator.VerticalProfileDecorator
return (
dec.is_installed
and context.area is not None
and context.area.as_pointer() == dec.profile_area_ptr
)
def execute(self, context):
dec = alignment_decorator.VerticalProfileDecorator
region = context.region
rv3d = context.region_data
if region is None or rv3d is None:
return {"CANCELLED"}
# Measure the currently visible station span from the screen corners
# (same technique the profile decorator uses to frame its grid), so the
# pan step scales naturally with the current zoom level.
ref = (rv3d.view_location.x, 0.0, rv3d.view_location.z)
bottom_left = region_2d_to_location_3d(region, rv3d, (0, 0), ref)
top_right = region_2d_to_location_3d(region, rv3d, (region.width, region.height), ref)
if bottom_left is None or top_right is None:
return {"CANCELLED"}
visible_span = top_right.x - bottom_left.x
new_x = rv3d.view_location.x + visible_span * 0.2 * self.direction
# Don't let the view center pan past the alignment's own station range.
new_x = max(dec.dist_min, min(dec.dist_max, new_x))
rv3d.view_location = mathutils.Vector((new_x, rv3d.view_location.y, rv3d.view_location.z))
context.area.tag_redraw()
return {"FINISHED"}
class ALIGN_OT_reset_vertical_profile_view(Operator):
"""Reset the docked vertical profile view to fit the full station range (Home)"""
bl_idname = "align.reset_vertical_profile_view"
bl_label = "Reset Vertical Profile View"
bl_description = "Reset the vertical profile view to fit the full station range"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
dec = alignment_decorator.VerticalProfileDecorator
return (
dec.is_installed
and context.area is not None
and context.area.as_pointer() == dec.profile_area_ptr
)
def execute(self, context):
dec = alignment_decorator.VerticalProfileDecorator
space = context.space_data
if space is None or space.type != "VIEW_3D":
return {"CANCELLED"}
dec.fit_view(space, area_width=context.area.width, area_height=context.area.height)
context.area.tag_redraw()
return {"FINISHED"}
# ============================================================================= # =============================================================================
# Vertical Alignment Drawing (draw-by-PI in the profile view) # Vertical Alignment Drawing (draw-by-PI in the profile view)
# ============================================================================= # =============================================================================
def _generate_vertical_alignment_segments(context, alignment, vpoints, lengths): def _generate_vertical_alignment_segments(context, alignment, vpoints, lengths, v_layout=None):
"""Build vertical alignment segments from PI points and per-PI curve lengths. """Build vertical alignment segments from PI points and per-PI curve lengths.
Mirrors _generate_alignment_segments() for the vertical layout. ``vpoints`` Mirrors _generate_alignment_segments() for the vertical layout. ``vpoints``
@@ -1264,12 +1531,28 @@ def _generate_vertical_alignment_segments(context, alignment, vpoints, lengths):
project-unit distance/elevation values (see project-unit distance/elevation values (see
VerticalProfileDecorator.screen_to_data). ``lengths`` has exactly VerticalProfileDecorator.screen_to_data). ``lengths`` has exactly
len(vpoints) - 2 entries, one per interior PI (0.0 = sharp grade break). len(vpoints) - 2 entries, one per interior PI (0.0 = sharp grade break).
``alignment`` is always the top-level alignment — it's only used for the
representation/Blender-object refresh below, which are keyed to the
top-level alignment regardless of which sibling vertical changed (IFC CT
4.1.4.4.1.2). ``v_layout``, when given, is the specific
IfcAlignmentVertical to regenerate (resolved by the caller, e.g. from
props.editing_vertical_pi_layout_id) instead of the one/only vertical
``get_vertical_layout(alignment)`` would find directly on ``alignment``
itself — which is nothing once a second sibling vertical exists, since
add_vertical_layout() moves every vertical onto its own child alignment
at that point.
Returns (ok, message, v_layout) — callers that don't already know which
vertical they're targeting (e.g. drawing a brand new one) can use the
returned entity to remember it for a subsequent apply.
""" """
ifc = tool.Ifc.get() ifc = tool.Ifc.get()
v_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
if v_layout is None: if v_layout is None:
v_layout = ifcopenshell.api.alignment.add_vertical_layout(ifc, alignment) 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.clear_layout_segments(v_layout)
tool.Alignment.safe_layout_vertical_by_pi_method(ifc, v_layout, vpoints, lengths) tool.Alignment.safe_layout_vertical_by_pi_method(ifc, v_layout, vpoints, lengths)
@@ -1278,7 +1561,7 @@ def _generate_vertical_alignment_segments(context, alignment, vpoints, lengths):
tool.Alignment.refresh_alignment_representation_object(alignment) tool.Alignment.refresh_alignment_representation_object(alignment)
n_curved = sum(1 for l in lengths if l) n_curved = sum(1 for l in lengths if l)
return True, f"Drew vertical alignment with {len(vpoints)} PIs ({n_curved} curved)" return True, f"Drew vertical alignment with {len(vpoints)} PIs ({n_curved} curved)", v_layout
def _sync_vertical_pi_markers(context, vpoints): def _sync_vertical_pi_markers(context, vpoints):
@@ -1298,6 +1581,157 @@ def _sync_vertical_pi_markers(context, vpoints):
item.curve_length = 100.0 item.curve_length = 100.0
def _tangent_grade_intersection(dp_a, dp_b):
"""Where two CONSTANTGRADIENT segments' own grade lines cross, as (dist_along, elevation).
Mirrors _tangent_line_intersection for the vertical (1D) case. Returns
None if the two grades are equal (no PI).
"""
d1, d2 = dp_a.StartGradient, dp_b.StartGradient
if abs(d1 - d2) < 1e-10:
return None
t = (dp_b.StartHeight - dp_a.StartHeight - d2 * dp_b.StartDistAlong + d1 * dp_a.StartDistAlong) / (d1 - d2)
elevation = dp_a.StartHeight + d1 * (t - dp_a.StartDistAlong)
return t, elevation
def _reconstruct_vertical_pis(v_layout):
"""Classify every interior PI of v_layout's current real segments.
Mirrors _reconstruct_horizontal_pis for the vertical case: only a sharp
grade break between two CONSTANTGRADIENTs (TANGENT) or a CONSTANTGRADIENT
-PARABOLICARC-CONSTANTGRADIENT run (PARABOLIC) is recognized -- the two
states VerticalPIMarker.curve_type already has. Returns (specs, skipped)
exactly like _reconstruct_horizontal_pis.
A lone CIRCULARARC is a real, valid IfcAlignmentVerticalSegmentTypeEnum
value, but layout_vertical_alignment_by_pi_method (what "Apply Vertical
Curves" regenerates through) only ever produces PARABOLICARC/
CONSTANTGRADIENT -- there's no solver support for it yet. So it's called
out with its own skip reason rather than lumped in as generically
"unsupported", but still skipped (no marker), since creating one anyway
would let a later Apply silently discard it.
"""
segments = tool.Alignment.get_real_layout_segments(v_layout)
grade_indices = [i for i, s in enumerate(segments) if s.DesignParameters.PredefinedType == "CONSTANTGRADIENT"]
specs = []
skipped = []
if len(grade_indices) < 2:
return specs, [(s, "no bounding grade") for s in segments]
for s in segments[: grade_indices[0]]:
skipped.append((s, "before the first grade"))
for s in segments[grade_indices[-1] + 1 :]:
skipped.append((s, "after the last grade"))
for k in range(len(grade_indices) - 1):
a_idx, b_idx = grade_indices[k], grade_indices[k + 1]
grade_a, grade_b = segments[a_idx], segments[b_idx]
between = segments[a_idx + 1 : b_idx]
types = [s.DesignParameters.PredefinedType for s in between]
if types == []:
curve_type, curve_seg = "TANGENT", None
elif types == ["PARABOLICARC"]:
curve_type, curve_seg = "PARABOLIC", between[0]
elif types == ["CIRCULARARC"]:
skipped.append((between[0], "circular vertical curve, not yet editable here"))
continue
else:
skipped.extend((s, "unsupported curve type") for s in between)
continue
pi = _tangent_grade_intersection(grade_a.DesignParameters, grade_b.DesignParameters)
if pi is None:
skipped.extend((s, "equal grades") for s in (between or [grade_a, grade_b]))
continue
specs.append(
{
"dist_along": pi[0],
"elevation": pi[1],
"curve_type": curve_type,
"curve_length": (curve_seg.DesignParameters.HorizontalLength or 0.0) if curve_seg else 0.0,
}
)
return specs, skipped
class ALIGN_OT_load_vertical_pis(Operator, tool.Ifc.Operator):
"""Populate the vertical PI list from this alignment's current real segments.
Lets a previously-drawn (and saved) or IFC-imported vertical alignment be
tuned the same way a freshly-drawn one is: pick a row, adjust its curve
type/length, click "Apply Vertical Curves".
"""
bl_idname = "align.load_vertical_pis"
bl_label = "Edit PIs"
bl_description = (
"Populate the vertical PI list from this alignment's current segments, pre-filled "
"with their existing curve type/length, so they can be adjusted and re-applied "
"without redrawing from scratch"
)
bl_options = {"REGISTER", "UNDO"}
# Explicit target for a specific sibling vertical (IFC CT 4.1.4.4.1.2 — the
# per-vertical button in ALIGN_PT_alignment_segments passes this). 0 falls
# back to get_active_alignment()'s own direct vertical, the common
# single-vertical case.
layout_id: IntProperty(default=0, options={"HIDDEN"})
@classmethod
def poll(cls, context):
if not poll_ifc4x3(cls, context):
return False
if context.scene.CivilAlignmentProperties.editing_segment_kind != "NONE":
cls.poll_message_set("Finish or cancel the segment table edit first")
return False
return True
def _execute(self, context):
ifc = tool.Ifc.get()
if self.layout_id:
v_layout = ifc.by_id(self.layout_id)
else:
alignment = tool.Alignment.get_active_alignment()
if not alignment:
self.report({"ERROR"}, "Select an alignment first")
return {"CANCELLED"}
v_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
if not v_layout or not tool.Alignment.get_real_layout_segments(v_layout):
self.report({"ERROR"}, "This alignment has no vertical segments yet")
return {"CANCELLED"}
specs, skipped = _reconstruct_vertical_pis(v_layout)
if skipped:
details = "; ".join(f"{s.DesignParameters.PredefinedType} ({reason})" for s, reason in skipped[:5])
more = f", and {len(skipped) - 5} more" if len(skipped) > 5 else ""
self.report(
{"ERROR"},
f"Can't load PI list: {len(skipped)} segment(s) couldn't be classified: "
f"{details}{more}.",
)
return {"CANCELLED"}
props = context.scene.CivilAlignmentProperties
props.vertical_pi_markers.clear()
for spec in specs:
item = props.vertical_pi_markers.add()
item.dist_along = spec["dist_along"]
item.elevation = spec["elevation"]
item.curve_type = spec["curve_type"]
item.curve_length = spec["curve_length"] or 100.0
props.editing_vertical_pi_layout_id = v_layout.id()
self.report({"INFO"}, f"Loaded {len(specs)} PI(s)")
return {"FINISHED"}
class ALIGN_OT_draw_vertical_alignment(Operator, tool.Ifc.Operator): class ALIGN_OT_draw_vertical_alignment(Operator, tool.Ifc.Operator):
"""Draw the vertical alignment of the active IfcAlignment by PI, in the profile view. """Draw the vertical alignment of the active IfcAlignment by PI, in the profile view.
@@ -1331,6 +1765,13 @@ class ALIGN_OT_draw_vertical_alignment(Operator, tool.Ifc.Operator):
def poll(cls, context): def poll(cls, context):
if not poll_ifc4x3(cls, context): if not poll_ifc4x3(cls, context):
return False return False
props = context.scene.CivilAlignmentProperties
if props.editing_segment_kind != "NONE":
cls.poll_message_set("Finish or cancel the segment table edit first")
return False
if props.vertical_pi_markers:
cls.poll_message_set("Finish or clear the current PI marker edit first")
return False
alignment = tool.Alignment.get_active_alignment() alignment = tool.Alignment.get_active_alignment()
if not alignment: if not alignment:
cls.poll_message_set("Add or select an alignment first") cls.poll_message_set("Add or select an alignment first")
@@ -1501,9 +1942,14 @@ class ALIGN_OT_draw_vertical_alignment(Operator, tool.Ifc.Operator):
vpoints = sorted(self._points, key=lambda p: p[0]) vpoints = sorted(self._points, key=lambda p: p[0])
lengths = [0.0] * (len(vpoints) - 2) lengths = [0.0] * (len(vpoints) - 2)
ok, message = _generate_vertical_alignment_segments(context, alignment, vpoints, lengths) ok, message, v_layout = _generate_vertical_alignment_segments(context, alignment, vpoints, lengths)
if ok: if ok:
_sync_vertical_pi_markers(context, vpoints) _sync_vertical_pi_markers(context, vpoints)
# Remember which sibling vertical this is so a follow-up "Apply
# Vertical Curves" targets it too, not whatever get_active_alignment()
# would resolve to (nothing, once a second vertical exists — see
# _generate_vertical_alignment_segments).
context.scene.CivilAlignmentProperties.editing_vertical_pi_layout_id = v_layout.id()
_refresh_vertical_profile_view(context, alignment) _refresh_vertical_profile_view(context, alignment)
self.report({"INFO"} if ok else {"WARNING"}, message) self.report({"INFO"} if ok else {"WARNING"}, message)
@@ -1529,19 +1975,39 @@ class ALIGN_OT_apply_vertical_pi_curve(Operator, tool.Ifc.Operator):
return True return True
def _execute(self, context): def _execute(self, context):
props = context.scene.CivilAlignmentProperties
alignment = tool.Alignment.get_active_alignment() alignment = tool.Alignment.get_active_alignment()
# editing_vertical_pi_layout_id, when set, names the specific sibling
# vertical vertical_pi_markers came from (see its own comment) — resolve
# start/end from the alignment that actually owns it, not the top-level
# one, which has no vertical of its own once a second sibling exists.
v_layout = None
if props.editing_vertical_pi_layout_id:
try:
v_layout = tool.Ifc.get().by_id(props.editing_vertical_pi_layout_id)
except RuntimeError:
v_layout = None
try: try:
start, end = tool.Alignment.get_vertical_alignment_start_end_points(alignment) if v_layout is not None:
owning_alignment = ifcopenshell.api.alignment.get_alignment(v_layout)
start, end = tool.Alignment.get_vertical_alignment_start_end_points(owning_alignment)
else:
start, end = tool.Alignment.get_vertical_alignment_start_end_points(alignment)
except ValueError as e: except ValueError as e:
self.report({"ERROR"}, str(e)) self.report({"ERROR"}, str(e))
return {"CANCELLED"} return {"CANCELLED"}
markers = list(context.scene.CivilAlignmentProperties.vertical_pi_markers) markers = list(props.vertical_pi_markers)
vpoints = [start] + [(m.dist_along, m.elevation) for m in markers] + [end] 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] 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) ok, message, v_layout = _generate_vertical_alignment_segments(
context, alignment, vpoints, lengths, v_layout=v_layout
)
if ok: if ok:
props.editing_vertical_pi_layout_id = v_layout.id()
_refresh_vertical_profile_view(context, alignment) _refresh_vertical_profile_view(context, alignment)
self.report({"INFO"} if ok else {"WARNING"}, message) self.report({"INFO"} if ok else {"WARNING"}, message)
return {"FINISHED"} return {"FINISHED"}
@@ -1560,7 +2026,9 @@ class ALIGN_OT_clear_vertical_pi_markers(Operator):
return bool(context.scene.CivilAlignmentProperties.vertical_pi_markers) return bool(context.scene.CivilAlignmentProperties.vertical_pi_markers)
def execute(self, context): def execute(self, context):
context.scene.CivilAlignmentProperties.vertical_pi_markers.clear() props = context.scene.CivilAlignmentProperties
props.vertical_pi_markers.clear()
props.editing_vertical_pi_layout_id = 0
return {"FINISHED"} return {"FINISHED"}
@@ -1693,6 +2161,10 @@ class ALIGN_OT_enable_editing_h_segments(Operator):
if props.editing_segment_kind not in ("NONE", "HORIZONTAL"): if props.editing_segment_kind not in ("NONE", "HORIZONTAL"):
cls.poll_message_set("Finish or cancel the current segment edit first") cls.poll_message_set("Finish or cancel the current segment edit first")
return False return False
alignment = tool.Alignment.get_active_alignment()
if alignment and _find_pi_markers(alignment.id()):
cls.poll_message_set("Finish or clear the PI marker edit first")
return False
return True return True
def execute(self, context): def execute(self, context):
@@ -1863,6 +2335,9 @@ class ALIGN_OT_enable_editing_v_segments(Operator):
if props.editing_segment_kind not in ("NONE", "VERTICAL"): if props.editing_segment_kind not in ("NONE", "VERTICAL"):
cls.poll_message_set("Finish or cancel the current segment edit first") cls.poll_message_set("Finish or cancel the current segment edit first")
return False return False
if props.vertical_pi_markers:
cls.poll_message_set("Finish or clear the PI marker edit first")
return False
return True return True
def execute(self, context): def execute(self, context):
@@ -39,6 +39,24 @@ def _on_vertical_visibility_update(self, context):
VerticalProfileDecorator.tag_redraw() VerticalProfileDecorator.tag_redraw()
def _on_vertical_exaggeration_update(self, context):
"""Re-fit the profile camera to the new fixed elevation zone (see
VerticalProfileDecorator._refit_zones/fit_view) -- mirrors the same
re-fit _on_active_object_changed does when the active alignment changes.
"""
from .decorator import VerticalProfileDecorator as dec
if not dec.is_installed or dec.profile_area_ptr == 0:
return
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.as_pointer() == dec.profile_area_ptr:
space = next((s for s in area.spaces if s.type == "VIEW_3D"), None)
if space:
dec.fit_view(space, area_width=area.width, area_height=area.height)
dec.tag_redraw()
# Blender requires a dynamic EnumProperty callback to keep a reference to the # Blender requires a dynamic EnumProperty callback to keep a reference to the
# items it returns — the strings are read by the C/RNA layer after the Python # items it returns — the strings are read by the C/RNA layer after the Python
# call returns, and if the list is only local to the function it can be # call returns, and if the list is only local to the function it can be
@@ -331,10 +349,31 @@ class CivilAlignmentProperties(PropertyGroup):
# Interior PIs of the most recently drawn/edited vertical alignment # Interior PIs of the most recently drawn/edited vertical alignment
vertical_pi_markers: CollectionProperty(type=VerticalPIMarker) vertical_pi_markers: CollectionProperty(type=VerticalPIMarker)
active_vertical_pi_marker_index: IntProperty(name="Active Vertical PI", default=0) active_vertical_pi_marker_index: IntProperty(name="Active Vertical PI", default=0)
# Which IfcAlignmentVertical vertical_pi_markers belongs to -- a horizontal can be
# reused by several sibling verticals (IFC CT 4.1.4.4.1.2), each on its own child
# IfcAlignment, so "the active alignment" alone can't identify one. Set by
# align.load_vertical_pis / align.draw_vertical_alignment, read by
# align.apply_vertical_pi_curve so it regenerates the right one; 0 falls back to
# resolving a single vertical straight off the active alignment (the common case).
editing_vertical_pi_layout_id: IntProperty(name="Editing Vertical PI Layout ID", default=0)
# Per-vertical visibility filter for the profile window # Per-vertical visibility filter for the profile window
vertical_items: CollectionProperty(type=VerticalAlignmentItem) vertical_items: CollectionProperty(type=VerticalAlignmentItem)
# Fixed vertical exaggeration for the profile view -- world-Z = (elevation -
# elev_ref) * this, unaffected by zoom/pan (see VerticalProfileDecorator._ez).
vertical_exaggeration: FloatProperty(
name="Vertical Exaggeration",
description=(
"How much elevation is exaggerated relative to distance in the profile "
"view (10 draws 1 unit of elevation as 10 units of distance)"
),
default=10.0,
min=0.01,
soft_max=100.0,
update=_on_vertical_exaggeration_update,
)
# Per-cant visibility filter for the profile window # Per-cant visibility filter for the profile window
cant_items: CollectionProperty(type=CantAlignmentItem) cant_items: CollectionProperty(type=CantAlignmentItem)
+21 -1
View File
@@ -291,6 +291,7 @@ class ALIGN_PT_alignment_authoring(Panel):
row = col.row(align=True) row = col.row(align=True)
row.enabled = bool(alignment) row.enabled = bool(alignment)
row.operator("align.draw_horizontal_alignment", icon="EYEDROPPER") row.operator("align.draw_horizontal_alignment", icon="EYEDROPPER")
row.operator("align.edit_horizontal_pis", text="", icon="EMPTY_AXIS")
row.operator("align.remove_alignment", text="", icon="TRASH") row.operator("align.remove_alignment", text="", icon="TRASH")
if not alignment: if not alignment:
col.label(text="Add or select an alignment first", icon="INFO") col.label(text="Add or select an alignment first", icon="INFO")
@@ -348,7 +349,9 @@ class ALIGN_PT_vertical_alignment_authoring(Panel):
props = context.scene.CivilAlignmentProperties props = context.scene.CivilAlignmentProperties
col = layout.column(align=True) col = layout.column(align=True)
col.operator("align.draw_vertical_alignment", icon="EYEDROPPER") row = col.row(align=True)
row.operator("align.draw_vertical_alignment", icon="EYEDROPPER")
row.operator("align.load_vertical_pis", text="", icon="EMPTY_AXIS")
if props.vertical_pi_markers: if props.vertical_pi_markers:
box = layout.box() box = layout.box()
@@ -517,6 +520,7 @@ class ALIGN_PT_alignment_segments(Panel):
"align.show_vertical_profile", text="", "align.show_vertical_profile", text="",
icon="GRAPH", depress=dec.is_installed, icon="GRAPH", depress=dec.is_installed,
) )
row.prop(props, "vertical_exaggeration", text="VE")
for layout_entity in all_verticals: for layout_entity in all_verticals:
self._draw_vertical(layout, context, layout_entity) self._draw_vertical(layout, context, layout_entity)
@@ -695,6 +699,18 @@ class ALIGN_PT_alignment_segments(Panel):
) )
edit_op.layout_id = v_id edit_op.layout_id = v_id
is_editing_pi = props.editing_vertical_pi_layout_id == v_id and bool(props.vertical_pi_markers)
pi_op = row.operator(
"align.load_vertical_pis", text="", icon="EMPTY_AXIS", depress=is_editing_pi,
)
pi_op.layout_id = v_id
# Only shown once this vertical's PIs are loaded -- the button that ends the
# edit lives right next to the one that started it, rather than only in the
# (separate, collapsed-by-default) Vertical Alignment panel below.
if is_editing_pi:
row.operator("align.clear_vertical_pi_markers", text="", icon="X")
if not expanded: if not expanded:
return return
@@ -723,6 +739,8 @@ class ALIGN_PT_alignment_segments(Panel):
continue continue
seg_type = dp.PredefinedType or "?" seg_type = dp.PredefinedType or "?"
h_len = getattr(dp, "HorizontalLength", 0.0) or 0.0 h_len = getattr(dp, "HorizontalLength", 0.0) or 0.0
if h_len == 0.0:
continue # zero-length terminators are invisible to users
g_start = getattr(dp, "StartGradient", 0.0) or 0.0 g_start = getattr(dp, "StartGradient", 0.0) or 0.0
g_end = getattr(dp, "EndGradient", 0.0) or 0.0 g_end = getattr(dp, "EndGradient", 0.0) or 0.0
dist_along = getattr(dp, "StartDistAlong", None) dist_along = getattr(dp, "StartDistAlong", None)
@@ -828,6 +846,8 @@ class ALIGN_PT_alignment_segments(Panel):
h_len = getattr(dp, "HorizontalLength", None) h_len = getattr(dp, "HorizontalLength", None)
if h_len is None: if h_len is None:
h_len = getattr(dp, "Length", 0.0) or 0.0 h_len = getattr(dp, "Length", 0.0) or 0.0
if h_len == 0.0:
continue # zero-length terminators are invisible to users
start_l = getattr(dp, "StartCantLeft", None) or 0.0 start_l = getattr(dp, "StartCantLeft", None) or 0.0
start_r = getattr(dp, "StartCantRight", None) or 0.0 start_r = getattr(dp, "StartCantRight", None) or 0.0
end_l = getattr(dp, "EndCantLeft", None) end_l = getattr(dp, "EndCantLeft", None)