mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-21 14:23:53 +00:00
Continuing to advance the infrastructure editing UI
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user