diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index 4b01c62f0b..ff6275e4e2 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -123,6 +123,8 @@ classes = ( operator.ImportAlignmentCSV, # Operators - Vertical Profile Window operator.ALIGN_OT_show_vertical_profile, + operator.ALIGN_OT_pan_vertical_profile, + operator.ALIGN_OT_reset_vertical_profile_view, # Operators - Segment Selection operator.ALIGN_OT_select_h_segment, operator.ALIGN_OT_select_v_segment, @@ -134,11 +136,13 @@ classes = ( operator.ALIGN_OT_add_station_equation, operator.ALIGN_OT_edit_station_equation, operator.ALIGN_OT_remove_station_equation, + operator.ALIGN_OT_edit_horizontal_pis, 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_load_vertical_pis, operator.ALIGN_OT_apply_vertical_pi_curve, operator.ALIGN_OT_clear_vertical_pi_markers, # 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)") +addon_keymaps = [] + + def register(): bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties) 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_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(): 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) del bpy.types.Scene.CivilAlignmentProperties 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() diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py index 4b2d367ae2..f1db6e5fcf 100644 --- a/src/bonsai/bonsai/bim/module/alignment/decorator.py +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -38,6 +38,9 @@ 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 +from ifcopenshell.api.alignment._get_segment_start_point_label import ( + _get_segment_start_point_label, +) class AlignmentSegmentDecorator: @@ -280,6 +283,19 @@ class AlignmentSegmentDecorator: if segment not in segments: return 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 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 has_next = seg_idx + 1 < len(segments) 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): ex = next_dp.StartPoint.Coordinates[0] ey = next_dp.StartPoint.Coordinates[1] 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: # Last segment or next has no StartPoint — approximate end along start tangent ex = sx + seg_len * d1x @@ -370,13 +397,14 @@ class AlignmentSegmentDecorator: "end_en": (e_enh[0], e_enh[1]), "unit_symbol": unit_symbol, "station_separator": station_separator, + "start_label": start_label, + "end_label": end_label, "has_pi": False, } # 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 - 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 dx_ifc, dy_ifc = ex - sx, ey - sy @@ -425,6 +453,26 @@ class AlignmentSegmentDecorator: except Exception: 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({ "has_pi": True, "pi_world": (pi_wx, pi_wy, pi_wz), @@ -563,9 +611,10 @@ class AlignmentSegmentDecorator: def draw_label(self, context): """Draw point labels with station and E/N coordinates in screen space. - Curve segments (has_pi=True): PC label, PI crosshair + label, PT label. - Linear segments (has_pi=False): start and end station + coords, no tag prefix. - Circular arcs: also label the center of curvature. + Start/end tags (P.C., T.S., S.C., P.O.B., ...) come from + _get_segment_start_point_label — see _compute_tangent_data. Curve + 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: # draw_segment (POST_VIEW, runs first) may have just auto-cleared @@ -615,7 +664,7 @@ class AlignmentSegmentDecorator: if not screen: return sx, sy = screen.x, screen.y - if name == "PI": + if name == "P.I.": self._draw_screen_crosshair(sx, sy, self.COLOR_PI, region) blf.size(font_id, font_size) blf.color(font_id, *color) @@ -623,38 +672,48 @@ class AlignmentSegmentDecorator: if station: lines.append(f"Sta {station}") lines.append(coords) - for i, line in enumerate(reversed(lines)): - blf.position(font_id, sx + 12, sy + 4 + i * line_h, 0) - blf.draw(font_id, line) + # Stack upward from the point by default; flip downward near the + # top edge so a multi-line label can't run off-screen -- a point + # 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: - # Curve segment: PC / PI / PT with name tags + # Curve segment: start tag / PI / end tag 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, ) draw_point_label( - td["pi_world"], "PI", None, + td["pi_world"], "P.I.", None, fmt_en(*td["pi_en"]), self.COLOR_LABEL_PI, ) 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, ) - # Center of curvature label (circular arcs only) + # Center of curvature / normals-intersection label if td.get("center_world") and td.get("center_en"): draw_point_label( td["center_world"], "Center", None, fmt_en(*td["center_en"]), self.COLOR_PI, ) 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( - 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, ) 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, ) else: @@ -781,17 +840,20 @@ class VerticalProfileDecorator: """GPU-drawn 2D vertical profile window. Opens a dedicated SpaceView3D window in front orthographic mode and draws the - IfcGradientCurve as a distance-along vs. elevation plot with a configurable - vertical exaggeration factor. Middle-mouse pan/zoom are handled by Blender's - native orthographic navigation; orbit/rotate is continuously suppressed by - operator._profile_rotation_guard_tick (a bpy.app.timers poll) so the view - stays locked front-on. + IfcGradientCurve as a distance-along vs. elevation plot with a fixed, user- + adjustable vertical exaggeration factor (CivilAlignmentProperties. + vertical_exaggeration). Zoom is Blender's native orthographic zoom (mouse + wheel or the corner navigate gizmo's magnifier); panning is Shift+wheel + (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: - world X = distance along alignment - world Z = elevation, normalized into the elevation zone so it always - fills the viewport proportionally (see fit_view/_ez) -- - there is no user-facing vertical exaggeration factor + world X = distance along alignment, unscaled + world Z = (elevation - elev_ref) * ve -- a fixed linear scale (see _ez), + not a function of the current view/zoom 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 _alignment = None # IfcAlignment entity for station conversion at draw time - # Normalized world-Z zone boundaries (set by fit_view from area dimensions). - # These replace the old `elev_min * ve` / `elev_max * ve` approach so that - # both zones always fill the viewport proportionally, regardless of elevation scale. + # Fixed vertical exaggeration: world-Z = (elevation - elev_ref) * ve. Both are + # set once by _refit_zones (from CivilAlignmentProperties.vertical_exaggeration + # 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_top: float = 1.0 cant_zone_bot: float = -0.4 @@ -932,6 +1000,7 @@ class VerticalProfileDecorator: cls.cant_info = [] cls.available_cants = [] cls.has_cant = False + cls.elev_ref = 0.0 cls.elev_zone_bot = 0.0 cls.elev_zone_top = 1.0 cls.cant_zone_bot = -0.4 @@ -971,20 +1040,23 @@ class VerticalProfileDecorator: pass @classmethod - def fit_view(cls, space, area_width: int = 1920, area_height: int = 400) -> None: - """Reposition the profile camera so both zones always fill the viewport proportionally. + def _refit_zones(cls) -> None: + """(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 - regardless of the elevation data scale (including flat/near-zero alignments). + Unlike the old per-frame _recompute_zones this replaces, the result depends + 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) - # An ortho VIEW_3D shows ~1.08x its view_distance in world height, so the - # distance that frames h_span across ~86% of the pane width is - # vd = h_span * (H/W) / (0.86 * 1.08). This is only the initial guess — - # draw_3d refines it against the real projection (and re-fits on resize). - ar = area_height / max(area_width, 1) - vd = h_span * ar / (0.86 * 1.08) - vis_z = 2 * vd + try: + cls.ve = bpy.context.scene.CivilAlignmentProperties.vertical_exaggeration + except Exception: + pass + if not cls.ve or cls.ve <= 0: + cls.ve = 10.0 + + 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. 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_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: - total_content_h = vis_z * 0.92 - cant_h = total_content_h * 0.25 - gap_h = vis_z * 0.02 - elev_h = total_content_h - cant_h - gap_h - total = elev_h + gap_h + cant_h - cls.cant_zone_bot = -total / 2 - cls.cant_zone_top = cls.cant_zone_bot + cant_h - cls.elev_zone_bot = cls.cant_zone_top + gap_h - cls.elev_zone_top = cls.elev_zone_bot + elev_h + # Cant panel sits below the elevation panel, sized/gapped as fixed + # fractions of the (now fixed) elevation zone height. + elev_h = cls.elev_zone_top - cls.elev_zone_bot + cant_h = elev_h * cls.CANT_HEIGHT_FRACTION + gap_h = elev_h * cls.CANT_GAP_FRACTION + cls.cant_zone_top = cls.elev_zone_bot - gap_h + cls.cant_zone_bot = cls.cant_zone_top - cant_h # Cant display range with 5 % padding on each side c_span = max(cls.cant_max - cls.cant_min, 0.0) c_pad = max(c_span * 0.10, 0.001) cls._c_display_min = cls.cant_min - c_pad * 0.05 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 - def _recompute_zones(cls, center_z: float, span_z: float) -> None: - """Derive the elevation / cant zone bands from the LIVE visible Z span. + def fit_view(cls, space, area_width: int = 1920, area_height: int = 400) -> None: + """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 - measured from the viewport's screen corners. fit_view can only estimate - this from the area size, which Blender has not finalised at split time - (and which changes whenever the user drags the pane border) — its - estimate is routinely 2-4x off, which pushes the bottom (cant) band - clean off the bottom edge of the viewport. Recomputing here from the - real visible span keeps both panels framed correctly no matter what. + Chooses view_distance as a "contain" fit of both axes -- whichever of the + horizontal (distance) or vertical (elevation*ve, plus cant if present) + extent needs more room at the given area's aspect ratio wins, so neither + axis is cropped. This is only an initial estimate — draw_3d refines it + against the real projection (and re-fits on resize). """ - if not math.isfinite(span_z) or span_z <= 0: - return - if cls.has_cant: - content_h = span_z * 0.92 - cant_h = content_h * 0.25 - gap_h = span_z * 0.02 - elev_h = content_h - cant_h - gap_h - cls.cant_zone_bot = center_z - content_h / 2.0 - cls.cant_zone_top = cls.cant_zone_bot + cant_h - cls.elev_zone_bot = cls.cant_zone_top + gap_h - cls.elev_zone_top = cls.elev_zone_bot + elev_h - else: - elev_h = span_z * 0.90 - cls.elev_zone_bot = center_z - elev_h / 2.0 - cls.elev_zone_top = center_z + elev_h / 2.0 + cls._refit_zones() + + h_span = max(cls.dist_max - cls.dist_min, 1.0) + z_bot = cls.cant_zone_bot if cls.has_cant else cls.elev_zone_bot + z_span = max(cls.elev_zone_top - z_bot, 1.0) + + # An ortho VIEW_3D shows ~1.08x its view_distance in world height, so the + # distance that frames h_span across ~86% of the pane width is + # vd = h_span * (H/W) / (0.86 * 1.08); framing z_span across ~90% of the + # pane height is vd = z_span / (0.90 * 1.08). Take whichever is larger so + # both axes fit (one may end up with extra margin — expected/correct). + ar = area_height / max(area_width, 1) + vd_for_x = h_span * ar / (0.86 * 1.08) + vd_for_z = z_span / (0.90 * 1.08) + 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 @@ -1072,6 +1139,8 @@ class VerticalProfileDecorator: color_idx = len(cls.available_verticals) 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 in seg_rel.RelatedObjects or []: if not seg.is_a("IfcAlignmentSegment"): @@ -1090,6 +1159,7 @@ class VerticalProfileDecorator: if h_len <= 0: continue + real_segments.append(seg) pts = cls._sample_segment(dist, height, h_len, g_start, g_end, seg_type) cls.segments_polylines.append(pts) cls.segments_info.append( @@ -1109,6 +1179,20 @@ class VerticalProfileDecorator: all_dists.extend(d for d, _ 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: cls.dist_min = min(all_dists) cls.dist_max = max(all_dists) @@ -1152,6 +1236,10 @@ class VerticalProfileDecorator: info["pvi"] = pvi 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 def _collect_cant_data(cls, alignment) -> None: """Collect IfcAlignmentCant segments and build the cant profile arrays.""" @@ -1347,10 +1435,8 @@ class VerticalProfileDecorator: @classmethod def _ez(cls, e: float) -> float: - """Map an elevation data value to world-Z within the elevation zone.""" - e_span = max(cls._e_display_max - cls._e_display_min, 1e-10) - t = (e - cls._e_display_min) / e_span - return cls.elev_zone_bot + t * (cls.elev_zone_top - cls.elev_zone_bot) + """Map an elevation data value to world-Z via the fixed vertical exaggeration.""" + return (e - cls.elev_ref) * cls.ve @classmethod def screen_to_data( @@ -1368,12 +1454,8 @@ class VerticalProfileDecorator: 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. + Inverse of _ez (world X is distance-along directly; world Z is a fixed + linear function of elevation, so no per-call zone resync is needed). Returns None if there's no valid region/view to project against. """ @@ -1385,16 +1467,7 @@ class VerticalProfileDecorator: 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) + elevation = point.z / cls.ve + cls.elev_ref return point.x, elevation @classmethod @@ -1443,36 +1516,40 @@ class VerticalProfileDecorator: vis_d_min, vis_d_max = bl.x, tr.x vis_z_min, vis_z_max = bl.z, tr.z - # --- Horizontal-zoom self-correction -------------------------------- - # fit_view can only estimate the ortho projection; nail the X framing - # against the real one here so the whole alignment (segment 1 to the - # end) is on screen, and re-fit whenever the pane is resized. + # --- Zoom self-correction (both axes) -------------------------------- + # fit_view can only estimate the ortho projection; nail the real framing + # here so the whole alignment (and the fixed elevation/cant zones) are on + # 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) if wh != cls._last_region_wh: cls._last_region_wh = wh cls._xfit_frames = 6 if cls._xfit_frames > 0: cls._xfit_frames -= 1 - vis_span = vis_d_max - vis_d_min - data_span = max(cls.dist_max - cls.dist_min, 1e-6) - if vis_span > 1e-6: - ratio = (data_span / 0.88) / vis_span # alignment fills ~88% of width - if abs(ratio - 1.0) > 0.02: - # Adjust and repaint next frame; this frame still draws - # (one slightly-off frame reads better than a blank flash). - try: - rv3d.view_distance = max(rv3d.view_distance * ratio, 1.0) - rv3d.view_location = mathutils.Vector( - ((cls.dist_min + cls.dist_max) * 0.5, 0.0, 0.0) - ) - bpy.context.area.tag_redraw() - except Exception: - pass - else: - cls._xfit_frames = 0 - - # Frame the zone bands to the Z span actually visible right now. - cls._recompute_zones((vis_z_min + vis_z_max) * 0.5, vis_z_max - vis_z_min) + vis_span_x = vis_d_max - vis_d_min + vis_span_z = vis_z_max - vis_z_min + data_span_x = max(cls.dist_max - cls.dist_min, 1e-6) + z_bot = cls.cant_zone_bot if cls.has_cant else cls.elev_zone_bot + data_span_z = max(cls.elev_zone_top - z_bot, 1e-6) + ratio_x = (data_span_x / 0.88) / vis_span_x if vis_span_x > 1e-6 else 1.0 # fills ~88% of width + ratio_z = (data_span_z / 0.92) / vis_span_z if vis_span_z > 1e-6 else 1.0 # fills ~92% of height + ratio = max(ratio_x, ratio_z) # whichever axis needs more room wins (contain fit) + if abs(ratio - 1.0) > 0.02: + # Adjust and repaint next frame; this frame still draws + # (one slightly-off frame reads better than a blank flash). + try: + rv3d.view_distance = max(rv3d.view_distance * ratio, 1.0) + mid_z = (cls.elev_zone_top + z_bot) * 0.5 + rv3d.view_location = mathutils.Vector( + ((cls.dist_min + cls.dist_max) * 0.5, 0.0, mid_z) + ) + bpy.context.area.tag_redraw() + except Exception: + pass + else: + cls._xfit_frames = 0 # Small padding so grid lines fully cover the viewport edges 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_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) vis_e_min_clamp = cls._e_display_min vis_e_max_clamp = cls._e_display_max @@ -1948,9 +2022,18 @@ class VerticalProfileDecorator: gpu.state.blend_set("NONE") blf.size(font_id, pt_font_size) blf.color(font_id, *color) - 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) + # Stack upward from the point by default; flip downward near the top + # edge so a multi-line label can't run off-screen -- a point can end + # 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 # 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 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 bvc_key not in labeled: _draw_vp_label( 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, ) labeled.add(bvc_key) @@ -2003,21 +2092,21 @@ class VerticalProfileDecorator: sta_pvi = cls._dist_to_station_str(pvi_d) _draw_vp_label( 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, draw_cross=True, ) if evc_key not in labeled: _draw_vp_label( 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, ) labeled.add(evc_key) else: g_pct = info["g_start"] * 100.0 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: lines.append(f"[{v_label}]") _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: _draw_vp_label( 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, ) labeled.add(evc_key) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 88ea184324..d1fa9ff667 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -22,13 +22,16 @@ import bpy import blf import math +import mathutils import time +from typing import TYPE_CHECKING import bonsai.core.alignment as core import bonsai.tool as tool import ifcopenshell.api.alignment import ifcopenshell.util.geolocation import ifcopenshell.util.unit 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.props import StringProperty, FloatProperty, EnumProperty, IntProperty, BoolProperty 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}" +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): """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): if not poll_ifc4x3(cls, context): 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") return False + if _find_pi_markers(alignment.id()): + cls.poll_message_set("Finish or clear the PI marker edit first") + return False return True 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_y = 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. # 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_description = ( "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"} @@ -1249,12 +1441,87 @@ class ALIGN_OT_show_vertical_profile(Operator): 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) # ============================================================================= -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. 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 VerticalProfileDecorator.screen_to_data). ``lengths`` has exactly 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() - v_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment) 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.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) 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): @@ -1298,6 +1581,157 @@ def _sync_vertical_pi_markers(context, vpoints): 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): """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): if not poll_ifc4x3(cls, context): 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() if not alignment: 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]) 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: _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) 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 def _execute(self, context): + props = context.scene.CivilAlignmentProperties 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: - 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: self.report({"ERROR"}, str(e)) 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] 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: + props.editing_vertical_pi_layout_id = v_layout.id() _refresh_vertical_profile_view(context, alignment) self.report({"INFO"} if ok else {"WARNING"}, message) return {"FINISHED"} @@ -1560,7 +2026,9 @@ class ALIGN_OT_clear_vertical_pi_markers(Operator): return bool(context.scene.CivilAlignmentProperties.vertical_pi_markers) 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"} @@ -1693,6 +2161,10 @@ class ALIGN_OT_enable_editing_h_segments(Operator): if props.editing_segment_kind not in ("NONE", "HORIZONTAL"): cls.poll_message_set("Finish or cancel the current segment edit first") 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 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"): cls.poll_message_set("Finish or cancel the current segment edit first") return False + if props.vertical_pi_markers: + cls.poll_message_set("Finish or clear the PI marker edit first") + return False return True def execute(self, context): diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py index 96c378dd54..d0f55d6a9a 100644 --- a/src/bonsai/bonsai/bim/module/alignment/prop.py +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -39,6 +39,24 @@ def _on_vertical_visibility_update(self, context): 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 # 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 @@ -331,10 +349,31 @@ class CivilAlignmentProperties(PropertyGroup): # 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) + # 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 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 cant_items: CollectionProperty(type=CantAlignmentItem) diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py index 56e9f0ab79..05c3970c90 100644 --- a/src/bonsai/bonsai/bim/module/alignment/ui.py +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -291,6 +291,7 @@ class ALIGN_PT_alignment_authoring(Panel): row = col.row(align=True) row.enabled = bool(alignment) 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") if not alignment: 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 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: box = layout.box() @@ -517,6 +520,7 @@ class ALIGN_PT_alignment_segments(Panel): "align.show_vertical_profile", text="", icon="GRAPH", depress=dec.is_installed, ) + row.prop(props, "vertical_exaggeration", text="VE") for layout_entity in all_verticals: self._draw_vertical(layout, context, layout_entity) @@ -695,6 +699,18 @@ class ALIGN_PT_alignment_segments(Panel): ) 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: return @@ -723,6 +739,8 @@ class ALIGN_PT_alignment_segments(Panel): continue seg_type = dp.PredefinedType or "?" 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_end = getattr(dp, "EndGradient", 0.0) or 0.0 dist_along = getattr(dp, "StartDistAlong", None) @@ -828,6 +846,8 @@ class ALIGN_PT_alignment_segments(Panel): h_len = getattr(dp, "HorizontalLength", None) if h_len is None: 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_r = getattr(dp, "StartCantRight", None) or 0.0 end_l = getattr(dp, "EndCantLeft", None)