diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index bf40b000be..1b8fc9f5d1 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -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, ) diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py index 511c253a9d..2cb730ffb4 100644 --- a/src/bonsai/bonsai/bim/module/alignment/decorator.py +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index a92c3fbf54..fabda53fd5 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -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"} diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py index a3691a9da3..6c7772b045 100644 --- a/src/bonsai/bonsai/bim/module/alignment/prop.py +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py index c7090c30c1..44fbd70431 100644 --- a/src/bonsai/bonsai/bim/module/alignment/ui.py +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -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. diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index 3f8674ff1e..065bf183ea 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -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():