From 10ce30b2ac876bf12487ac497fb147cc691a7679 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 21 Jan 2026 18:59:31 +1100 Subject: [PATCH] Forgot to commit these files --- .../bonsai/bim/module/alignment/data.py | 75 ++++ .../bonsai/bim/module/alignment/prop.py | 258 ++++++++++++ src/bonsai/bonsai/bim/module/alignment/ui.py | 366 ++++++++++++++++++ 3 files changed, 699 insertions(+) create mode 100644 src/bonsai/bonsai/bim/module/alignment/data.py create mode 100644 src/bonsai/bonsai/bim/module/alignment/prop.py create mode 100644 src/bonsai/bonsai/bim/module/alignment/ui.py diff --git a/src/bonsai/bonsai/bim/module/alignment/data.py b/src/bonsai/bonsai/bim/module/alignment/data.py new file mode 100644 index 0000000000..a4dc5f18ed --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/data.py @@ -0,0 +1,75 @@ +# ============================================================================== +# Saikei Civil - Civil Engineering Tools for Blender +# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . +# +# Primary Author: Michael Yoder +# Company: Desert Springs Civil Engineering PLLC +# ============================================================================== + + +"""Data caching layer for the alignment module + +This module provides cached access to alignment data for UI display, +following Bonsai's data loading pattern. +""" + + +def get_ifc_file(): + """Get the current IFC file from Bonsai""" + try: + import bonsai.tool as tool + + return tool.Ifc.get() + except (ImportError, AttributeError): + return None + + +class AlignmentData: + """Cached alignment data for UI display""" + + data = {} + is_loaded = False + + @classmethod + def load(cls): + """Load alignment data from IFC file""" + cls.data = { + "alignments": [], + "active_alignment": None, + "segments": [], + } + + ifc = get_ifc_file() + if ifc is None: + cls.is_loaded = True + return + + # Load all alignments + alignments = ifc.by_type("IfcAlignment") + cls.data["alignments"] = [ + { + "id": a.id(), + "name": a.Name or f"Alignment {a.id()}", + "global_id": a.GlobalId, + } + for a in alignments + ] + + cls.is_loaded = True + + @classmethod + def refresh(cls): + """Force refresh of alignment data""" + cls.is_loaded = False + cls.load() diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py new file mode 100644 index 0000000000..e4f67b4169 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -0,0 +1,258 @@ +# ============================================================================== +# Saikei Civil - Civil Engineering Tools for Blender +# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . +# +# Primary Author: Michael Yoder +# Company: Desert Springs Civil Engineering PLLC +# ============================================================================== + + +"""Property groups for the alignment module""" + +import bpy +from bpy.types import PropertyGroup +from bpy.props import ( + StringProperty, + FloatProperty, + IntProperty, + BoolProperty, + CollectionProperty, + EnumProperty, +) + + +def get_pi_type_items(self, context): + """Get available PI types based on position in list""" + # First and last PIs are always endpoints (no curve) + # Interior PIs can have curves + return [ + ("ENDPOINT", "Endpoint", "Start or end point (no curve)"), + ("TANGENT", "Tangent", "Pass-through point (no curve)"), + ("CURVE", "Curve", "Point of intersection with curve"), + ] + + +def _on_radius_update(self, context): + """Callback when radius property changes. + + This dynamically imports the operator module to call on_radius_changed, + avoiding circular imports since prop.py is imported before operator.py. + """ + from . import operator as ops + + ops.on_radius_changed(self, context) + + +class AlignmentPI(PropertyGroup): + """Property group for a single PI (Point of Intersection) + + In the PI method, alignments are defined by: + - Endpoint PIs: Start (POB) and End (POE) points + - Interior PIs: Points where tangents intersect, optionally with curves + """ + + # Coordinates + x: FloatProperty( + name="X", + description="X coordinate (Easting)", + default=0.0, + precision=3, + unit="LENGTH", + ) + + y: FloatProperty( + name="Y", + description="Y coordinate (Northing)", + default=0.0, + precision=3, + unit="LENGTH", + ) + + # PI Type + pi_type: EnumProperty( + name="Type", + description="Type of PI point", + items=[ + ("ENDPOINT", "Endpoint", "Start or end point (no curve)"), + ("TANGENT", "Tangent", "Pass-through point (no curve)"), + ("CURVE", "Curve", "Point of intersection with curve"), + ], + default="TANGENT", + ) + + # Curve parameters (only used when pi_type == "CURVE") + radius: FloatProperty( + name="Radius", + description="Curve radius (0 = no curve, sharp angle)", + default=0.0, + min=0.0, + precision=3, + unit="LENGTH", + update=_on_radius_update, + ) + + # Computed/display values (updated by recalculate operator) + length_to_next: FloatProperty( + name="Length", + description="Length of tangent to next PI", + default=0.0, + precision=3, + unit="LENGTH", + ) + + direction_to_next: FloatProperty( + name="Direction", + description="Bearing/direction to next PI (degrees)", + default=0.0, + precision=4, + subtype="ANGLE", + ) + + # Station at this PI (computed) + station: FloatProperty( + name="Station", + description="Station value at this PI", + default=0.0, + precision=2, + ) + + # Selection state + is_selected: BoolProperty( + name="Selected", + description="Whether this PI is selected for editing", + default=False, + ) + + +class AlignmentSegmentItem(PropertyGroup): + """Property group for displaying alignment segments in a UIList""" + + name: StringProperty(name="Name", default="") + segment_type: StringProperty(name="Type", default="LINE") + length: FloatProperty(name="Length", default=0.0, unit="LENGTH") + ifc_id: IntProperty(name="IFC ID", default=0) + + +class AlignmentDisplayRow(PropertyGroup): + """Property group for interleaved point/segment display in the table. + + This creates the Civil 3D-style view where points and segments + are shown on separate rows: + Point 1 (End) + Segment 1 (Tan) + Point 2 (Tan) + Segment 2 (Tan) + ... + """ + + # Row type discriminator + row_type: EnumProperty( + name="Row Type", + items=[ + ("POINT", "Point", "A PI point row"), + ("SEGMENT", "Segment", "A segment row between points"), + ], + default="POINT", + ) + + # Segment number (1, 2, 3...) - only for SEGMENT rows + segment_number: IntProperty(name="Segment #", default=0) + + # Point index in the pis collection - for both types + # For POINT rows: the PI index + # For SEGMENT rows: the starting PI index of this segment + pi_index: IntProperty(name="PI Index", default=0) + + # Display type string (End, Tan, Curve for points; Tan, Curve for segments) + display_type: StringProperty(name="Type", default="") + + # Point coordinates (only for POINT rows) + x: FloatProperty(name="X", default=0.0, precision=3, unit="LENGTH") + y: FloatProperty(name="Y", default=0.0, precision=3, unit="LENGTH") + + # Segment properties (only for SEGMENT rows) + length: FloatProperty(name="Length", default=0.0, precision=2, unit="LENGTH") + radius: FloatProperty(name="Radius", default=0.0, precision=2, unit="LENGTH") + arc_length: FloatProperty(name="Arc Length", default=0.0, precision=2, unit="LENGTH") + + +class SaikeiAlignmentProperties(PropertyGroup): + """Properties for the alignment module""" + + # Active alignment selection + active_alignment_id: IntProperty( + name="Active Alignment ID", + description="IFC entity ID of the active alignment", + default=0, + ) + + active_alignment_name: StringProperty( + name="Active Alignment", + description="Name of the currently active alignment", + default="", + ) + + # New alignment creation properties + new_alignment_name: StringProperty( + name="Name", + description="Name for new alignment", + default="Alignment 1", + ) + + start_station: FloatProperty( + name="Start Station", + description="Starting station value (e.g., 10000 for 100+00)", + default=10000.0, + min=0.0, + ) + + # PI collection for PI method creation + pis: CollectionProperty(type=AlignmentPI) + active_pi_index: IntProperty(name="Active PI", default=0) + + # Segment display + segments: CollectionProperty(type=AlignmentSegmentItem) + active_segment_index: IntProperty(name="Active Segment", default=0) + + # Combined point/segment display rows (for Civil 3D-style table) + display_rows: CollectionProperty(type=AlignmentDisplayRow) + active_display_row_index: IntProperty(name="Active Display Row", default=0) + + # Editing state + is_editing: BoolProperty( + name="Is Editing", + description="Whether alignment is being edited", + default=False, + ) + + # Display options + show_pi_markers: BoolProperty( + name="Show PI Markers", + description="Show PI markers in viewport", + default=True, + ) + + show_station_labels: BoolProperty( + name="Show Station Labels", + description="Show station labels along alignment", + default=True, + ) + + station_interval: FloatProperty( + name="Station Interval", + description="Interval between station markers", + default=100.0, + min=1.0, + unit="LENGTH", + ) diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py new file mode 100644 index 0000000000..6e9224830a --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -0,0 +1,366 @@ +# ============================================================================== +# Saikei Civil - Civil Engineering Tools for Blender +# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see . +# +# Primary Author: Michael Yoder +# Company: Desert Springs Civil Engineering PLLC +# ============================================================================== + + +"""UI panels for the alignment module + +All panels appear in the VIEW_3D N-panel under the "Saikei Civil" tab. +""" + +import bpy +from bpy.types import Panel, UIList + + +def get_ifc_file(): + """Get the current IFC file from Bonsai""" + try: + import bonsai.tool as tool + + return tool.Ifc.get() + except (ImportError, AttributeError): + return None + + +def is_ifc4x3(): + """Check if the current IFC file is IFC4X3 schema""" + ifc = get_ifc_file() + return ifc is not None and ifc.schema == "IFC4X3" + + +# ============================================================================= +# UILists +# ============================================================================= + + +class SAIKEI_UL_alignment_pis(UIList): + """UIList for displaying interleaved points and segments (Civil 3D style) + + Row types: + - POINT rows: End (endpoint), Mid (interior PI without curve) + - SEGMENT rows: Tan (tangent line), Curve (circular arc) + + When a Mid point has radius > 0, it becomes a Curve segment row. + """ + + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + if self.layout_type in {"DEFAULT", "COMPACT"}: + row = layout.row(align=True) + + if item.row_type == "POINT": + # Point row: No., Type, X, Y, Length, Radius + row.label(text="") # No segment number for points + + # Type with point/dot icon + # "End" = endpoint (POB/POE), "Mid" = interior PI point + row.label(text=item.display_type, icon="DOT") + + # X, Y coordinates - get actual PI for editing + pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None + if pi: + sub = row.row(align=True) + sub.prop(pi, "x", text="") + sub.prop(pi, "y", text="") + else: + row.label(text=f"{item.x:.2f}") + row.label(text=f"{item.y:.2f}") + + # Length column - empty for point rows + row.label(text="") + + # Radius column - editable for Mid points (where curves can be added) + if item.display_type == "Mid" and pi: + row.prop(pi, "radius", text="") + else: + row.label(text="") + + elif item.row_type == "SEGMENT": + if item.display_type == "Curve": + # Curve segment row: No., Type (arc icon), X, Y, Arc Length, Radius + row.label(text=f"{item.segment_number}") + row.label(text="Curve", icon="SPHERECURVE") + + # Show PI coordinates on curve row + row.label(text=f"{item.x:.2f}") + row.label(text=f"{item.y:.2f}") + + # Arc length + row.label(text=f"{item.arc_length:.2f}") + + # Radius - editable so user can modify or delete curve (set to 0) + pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None + if pi: + row.prop(pi, "radius", text="") + else: + row.label(text=f"{item.radius:.2f}") + else: + # Tangent segment row: No., Type (line icon), -, -, Length, - + row.label(text=f"{item.segment_number}") + row.label(text="Tan", icon="IPO_LINEAR") + + # No X, Y for tangent segments + row.label(text="") + row.label(text="") + + # Length + row.label(text=f"{item.length:.2f}") + + # No radius for tangent segments + row.label(text="-") + + elif self.layout_type == "GRID": + layout.alignment = "CENTER" + layout.label(text="", icon="DECORATE") + + +# ============================================================================= +# Main Panel +# ============================================================================= + + +class SAIKEI_PT_horizontal_alignment(Panel): + """Main Horizontal Alignment panel in the N-panel""" + + bl_label = "Horizontal Alignment" + bl_idname = "SAIKEI_PT_horizontal_alignment" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Saikei Civil" + + def draw(self, context): + layout = self.layout + props = context.scene.SaikeiAlignmentProperties + + # Status box + box = layout.box() + ifc = get_ifc_file() + + if ifc is None: + box.label(text="No IFC file loaded", icon="ERROR") + box.label(text="Open an IFC4X3 file via Bonsai") + return + + if ifc.schema != "IFC4X3": + box.label(text=f"Schema: {ifc.schema}", icon="ERROR") + box.label(text="Alignments require IFC4X3") + return + + # IFC file is loaded and correct schema + row = box.row() + row.label(text="IFC4X3", icon="CHECKMARK") + + # Count alignments + alignments = ifc.by_type("IfcAlignment") + row.label(text=f"Alignments: {len(alignments)}") + + # Active alignment selector + if alignments: + box = layout.box() + box.label(text="Active Alignment:", icon="CURVE_PATH") + row = box.row() + row.prop(props, "active_alignment_name", text="") + + +# ============================================================================= +# Creation Sub-Panel +# ============================================================================= + + +class SAIKEI_PT_alignment_creation(Panel): + """Sub-panel for alignment creation tools""" + + bl_label = "Creation" + bl_idname = "SAIKEI_PT_alignment_creation" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Saikei Civil" + bl_parent_id = "SAIKEI_PT_horizontal_alignment" + bl_options = {"DEFAULT_CLOSED"} + + @classmethod + def poll(cls, context): + return is_ifc4x3() + + def draw(self, context): + layout = self.layout + props = context.scene.SaikeiAlignmentProperties + + # New alignment properties + box = layout.box() + box.label(text="New Alignment:", icon="ADD") + box.prop(props, "new_alignment_name") + box.prop(props, "start_station") + + # Creation operators + col = layout.column(align=True) + col.operator("saikei.create_alignment", icon="ADD") + col.operator("saikei.create_alignment_by_pi", icon="CURVE_PATH") + col.operator("saikei.import_alignment_csv", icon="IMPORT") + + +# ============================================================================= +# PI Editor Sub-Panel +# ============================================================================= + + +class SAIKEI_PT_pi_editor(Panel): + """Sub-panel for PI point table editor (Civil 3D style grid view)""" + + bl_label = "PI Editor" + bl_idname = "SAIKEI_PT_pi_editor" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Saikei Civil" + bl_parent_id = "SAIKEI_PT_horizontal_alignment" + bl_options = set() # Open by default + + @classmethod + def poll(cls, context): + return is_ifc4x3() + + def draw(self, context): + layout = self.layout + props = context.scene.SaikeiAlignmentProperties + + # Header row with column labels + header = layout.row(align=True) + header.label(text="No.") + header.label(text="Type") + header.label(text="X") + header.label(text="Y") + header.label(text="Length") + header.label(text="Radius") + + # Combined point/segment list (interleaved view) + row = layout.row() + row.template_list( + "SAIKEI_UL_alignment_pis", + "", + props, + "display_rows", + props, + "active_display_row_index", + rows=8, + ) + + # Side buttons for list management + col = row.column(align=True) + col.operator("saikei.add_pi", icon="ADD", text="") + col.operator("saikei.remove_pi", icon="REMOVE", text="") + col.separator() + col.operator("saikei.pick_pi_from_viewport", icon="EYEDROPPER", text="") + + # Active item details - show details based on selected row + if props.display_rows and 0 <= props.active_display_row_index < len(props.display_rows): + active_row = props.display_rows[props.active_display_row_index] + + if active_row.row_type == "POINT" and active_row.pi_index < len(props.pis): + pi = props.pis[active_row.pi_index] + + box = layout.box() + box.label(text=f"PI {active_row.pi_index + 1} Details:", icon="PROPERTIES") + + row = box.row() + row.prop(pi, "pi_type", text="Type") + + row = box.row(align=True) + row.prop(pi, "x", text="X") + row.prop(pi, "y", text="Y") + + # Show radius for interior points (can add curve) + if pi.pi_type != "ENDPOINT": + row = box.row() + row.prop(pi, "radius", text="Radius") + + # Display computed values + row = box.row() + row.label(text=f"Station: {pi.station:.2f}") + row.label(text=f"Length: {pi.length_to_next:.2f}") + + elif active_row.row_type == "SEGMENT": + if active_row.display_type == "Curve": + # Curve segment - show curve details with editable radius + pi = props.pis[active_row.pi_index] if active_row.pi_index < len(props.pis) else None + + box = layout.box() + box.label(text=f"Curve {active_row.segment_number} Details:", icon="SPHERECURVE") + + row = box.row() + row.label(text=f"PI Location: ({active_row.x:.2f}, {active_row.y:.2f})") + + row = box.row() + row.label(text=f"Arc Length: {active_row.arc_length:.2f}") + + # Editable radius + if pi: + row = box.row() + row.prop(pi, "radius", text="Radius") + else: + row = box.row() + row.label(text=f"Radius: {active_row.radius:.2f}") + else: + # Tangent segment + box = layout.box() + box.label(text=f"Tangent {active_row.segment_number} Details:", icon="IPO_LINEAR") + + row = box.row() + row.label(text=f"Length: {active_row.length:.2f}") + + # Bottom actions + layout.separator() + row = layout.row(align=True) + row.operator("saikei.recalculate_pis", icon="FILE_REFRESH", text="Recalculate") + row.operator("saikei.clear_pis", icon="TRASH", text="Clear All") + + +# ============================================================================= +# Stationing Sub-Panel +# ============================================================================= + + +class SAIKEI_PT_alignment_stationing(Panel): + """Sub-panel for stationing and referents""" + + bl_label = "Stationing" + bl_idname = "SAIKEI_PT_alignment_stationing" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Saikei Civil" + bl_parent_id = "SAIKEI_PT_horizontal_alignment" + bl_options = {"DEFAULT_CLOSED"} + + @classmethod + def poll(cls, context): + return is_ifc4x3() + + def draw(self, context): + layout = self.layout + props = context.scene.SaikeiAlignmentProperties + + # Station display options + box = layout.box() + box.label(text="Display:", icon="HIDE_OFF") + box.prop(props, "show_station_labels") + box.prop(props, "station_interval") + + layout.separator() + + # Stationing operators + col = layout.column(align=True) + col.operator("saikei.add_stationing_referent", icon="EMPTY_AXIS") + col.operator("saikei.name_segments", icon="FONT_DATA")