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