Compare commits

...

50 Commits

Author SHA1 Message Date
Ryan Schultz 053d8e9901 Add DriveDimensionLength operator to move anchored objects to target dimension
Clicking the near/far half of a parametric dimension segment opens a dialog
pre-filled with the current segment length; entering a target value moves
the corresponding anchored IFC element and regenerates the dimension curve.
First click selects the dimension; second click triggers the dialog.
Clears the cut-decorator cache after the move so section hatch updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 21:08:46 -05:00
Ryan Schultz a6f2396476 Add missing is_manual_drawing_reference getter to Drawing tool
set_manual_drawing_reference existed but the corresponding getter was
absent, causing an AttributeError in ui.py line 560.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 19:39:33 -05:00
Ryan Schultz c699c01e91 Fix LinePosition not preserved in section views on element move
depsgraph_update_post_handler was calling regenerate_dimension without
camera_dir, so section views always used plan-view fallback logic.
Vertical dimensions had offset_dir=None and silently dropped LinePosition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 16:41:29 -05:00
Ryan Schultz 12c3dabc34 Add insert/remove anchor vertices on existing parametric dimensions
Alt+click on an anchor dot removes that vertex (minimum 2 anchors enforced).
Ctrl+click on a dot appends/inserts after that position; Ctrl+click on a
segment midpoint inserts between the two flanking anchors. In both Ctrl cases
the new free-point anchor is immediately opened in SetDimensionAnchor so the
user can snap it to an IFC face.

New helpers / operators:
- _do_insert_anchor: inserts a world-point anchor at a computed midpoint or
  extrapolated end position, writes the BBIM_Dimension pset, and regenerates
- RemoveDimensionAnchor (bim.remove_dimension_anchor): deletes one anchor by
  index, clears gizmo highlight, and regenerates
- InsertDimensionAnchor (bim.insert_dimension_anchor): calls _do_insert_anchor
  then immediately invokes SetDimensionAnchor for the new slot
- ClickNearestDimensionAnchor extended: segment-midpoint hit-testing (Ctrl
  only), stores modifier state across the PRESS→RELEASE modal gap, routes to
  the three operators based on modifier + hit type
- Alt+LMB and Ctrl+LMB keymap entries registered alongside the existing LMB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 16:13:07 -05:00
Ryan Schultz a27c9ef01c Make LinePosition camera-relative in section/elevation views
_get_line_offset_direction now uses cross(camera_dir, dim_dir) instead of
cross(world_Z, dim_dir) when the camera is mostly horizontal (section or
elevation view).  This keeps the offset axis in the view plane so the
DimensionLinePositionWidget gizmo drags the line visually up/down rather
than in/out of the screen.

Plan view behaviour (cross(world_Z, dim_dir)) is preserved unchanged so
existing stored LinePosition values continue to work.

camera_dir is threaded through regenerate_dimension(), _get_line_offset_direction(),
and all callers: gizmos.py (_set_pos / _offset_dir), prop.py (_get/_set_line_position),
handler.py (regenerate_dims_for_layer and depsgraph handler), and all three
operator.py call sites (DrawParametricDimension, _do_write_anchor,
RegenerateDimensions).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:31 -05:00
Ryan Schultz 8c5d33f9e5 Evict stale snap_objs cache entries on ReferenceError
When a Blender object is deleted while the snap cache still holds a
SnapObj referencing it, accessing snap_obj.obj.name raises ReferenceError.
Catch it, evict the dead entry, and let the caller retry cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:30 -05:00
Ryan Schultz f63d02b2b4 Prioritize camera-perpendicular faces for parametric dimension snapping
In section/elevation views, face snapping previously preferred camera-facing
surfaces (front/back of walls), causing dimensions to anchor on the wrong
geometry. Fix by using 1-dot_abs scoring uniformly for all view types, so
edge-on faces (wall sides in section, wall faces in plan) are always preferred.

Also fix stale hit_pt: snapping_points[0]["point"] could carry a previous
IFC-override position for several frames after mousemove_count resets, causing
snap candidates to project from an outdated cursor position. Fix by deriving
hit_pt fresh from a camera-facing plane intersection on every FACE-mode frame.

Fix _init_snapping_points to use the camera forward vector as plane normal in
section view (z=0 plane is parallel to horizontal camera rays → returns origin).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:30 -05:00
Ryan Schultz 44bf8527f6 Invalidate dim GUID index after Shift+D to ensure duplicated parametric dimensions auto-regenerate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:30 -05:00
Ryan Schultz 6be06bb6c5 Allow deletion of user-placed SECTION_LEVEL/PLAN_LEVEL annotations
is_auto_annotation now returns False for SECTION_LEVEL/PLAN_LEVEL annotations
that carry a BBIM_Dimension pset, which is always written by _do_write_anchor
during AddElevationAnnotation placement. Auto-generated elevation annotations
(no BBIM_Dimension pset) remain protected from deletion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:30 -05:00
Ryan Schultz 904a4df651 Closes #6321: Auto-snap SECTION endpoints to drawing border
When a SECTION annotation is created or a drawing is activated,
endpoint vertices are automatically placed at a configurable
BorderOffset (paper-space mm, default 8) inside the camera border,
scaled by the drawing scale. BorderOffset is stored in the
BBIM_Section pset and visible in the Property Sets panel.
An "UpdateSectionEndpoints" operator (bim.update_section_endpoints)
resets endpoints back to the border offset on demand. Endpoints are
also recomputed when the diagram scale is changed.

Generated with the assistance of an AI coding tool.
2026-08-04 14:44:30 -05:00
Ryan Schultz ca866b3669 Add external SVG reference support to manual drawing reference tags
Extends manual drawing reference annotations (elevation and section) to
also support external SVG references imported via bim.add_reference.

- Add "Is a Reference" checkbox to the annotation tool sidebar, shown
  when Elevation or Section is the active type; checking it and pressing
  Add opens a dialog to optionally link the tag to a Bonsai drawing or
  an external SVG reference
- The MANUAL_DRAWING_REFERENCE dropdown type is retained for backwards
  compatibility; selecting it shows a style picker (Elevation/Section)
  and the same linking dialog
- External-reference annotations are flagged with IsDocumentReference
  in EPset_Annotation and linked to their IfcDocumentInformation via
  IfcRelAssociatesDocument; drawing-reference annotations continue to
  use IfcRelAssignsToProduct as before
- SVG export resolves the correct reference/sheet IDs for both link
  types via get_reference_and_sheet_id_from_annotation
- Add IsDocumentReference to the EPset_Annotation pset template
2026-08-04 14:44:29 -05:00
Ryan Schultz 28219973f6 Fix manual section/elevation annotation placement in non-plan views
Use get_default_annotation_matrix() for manual SECTION annotations so
the object is placed in the camera's annotation plane with the correct
rotation, matching how auto-generated section annotations are created.
Without this, annotations placed in elevation or section camera views
had identity rotation, causing the IFC representation to be in the wrong
coordinate system and failing to tessellate.

Also guard draw_edit_object_interface against non-IFC active objects to
prevent AssertionError during toolbar redraws, and skip IFC item edit
mode for ELEVATION/SECTION annotation types on placement.
2026-08-04 14:44:26 -05:00
Ryan Schultz 63fb5635ee Fix ElevationDecorator arrow direction in drawing camera views
The elevation tag's local -Z axis is intentionally parallel to its
drawing camera's view direction, making screen-space projection of that
axis always degenerate (zero XY delta). Fall through to the tag's local
+X axis, which lies in the camera plane and rotates correctly as the
user adjusts the tag's orientation. Also fix a zero-length vector crash
in svgwriter when the same degenerate case occurs during SVG export.
2026-08-04 14:44:25 -05:00
Ryan Schultz 4be91c26c4 Integrate manual drawing references into annotation tool
Adds MANUAL_DRAWING_REFERENCE to the annotation type dropdown.
Selecting it shows a dialog to choose elevation or section and
optionally assign a target drawing before placement. Tags are
protected from regeneration via EPset_Annotation.IsManualDrawingReference.

Generated with the assistance of an AI coding tool.
2026-08-04 14:44:25 -05:00
Ryan Schultz ca652f4534 Add AssignManualDrawingReference operator
Adds operator to link a manual drawing reference tag to a target
drawing via IfcRelAssignsToProduct, with a pre-populated dialog
and immediate Properties panel refresh on confirm.

Generated with the assistance of an AI coding tool.
2026-08-04 14:44:23 -05:00
Ryan Schultz ecd90e45b7 Add manual drawing reference annotation operator
Adds operator to place manual elevation/section drawing reference
tags that survive drawing regeneration. Includes core function,
tool methods, type-selection dialog, default horizontal rotation
for elevation tags, and SVG null guard for unassigned references.

Generated with the assistance of an AI coding tool.
2026-08-04 14:44:22 -05:00
Ryan Schultz f0482e7f0c Add IsManualDrawingReference to EPset_Annotation
Introduces a boolean pset property that marks an ELEVATION or
SECTION annotation as manually placed, exempting it from
automatic deletion or regeneration during drawing sync.

Generated with the assistance of an AI coding tool.
2026-08-04 14:44:20 -05:00
Ryan Schultz 3faa192db8 Add interactive PLAN_LEVEL / SECTION_LEVEL elevation annotation placement
- AddElevationAnnotation: new modal operator (inherits SetDimensionAnchor) that
  creates the annotation only after the user clicks a face/layer/edge/vertex,
  placing it at the picked world position with elevation tracked via BBIM_Dimension
- hotkey_S_A routes PLAN_LEVEL/SECTION_LEVEL to AddElevationAnnotation instead of
  the static AddAnnotation
- _annotation_is_2d: check predefined type first so PLAN_LEVEL/SECTION_LEVEL always
  use object-placement Z (not spline-point Z)
- _update_elevation_marker_z: fixed early-exit guard that skipped 2D placement update
  when splines were absent; now moves the object via geometry.edit_object_placement
- SetDimensionAnchor._handle_face_pick: for elevation types, relocate the whole
  annotation (XY and Z) to the new face hit, not just Z
- _zero_elevation_annotation_spline_z: flatten local spline-point Z to 0 so the
  visible line and the gizmo sit at the same world elevation
- DimensionAnchorWidget: position gizmo at object origin for elevation annotations
  (anchor reference point) instead of spline.points[0]
- ClickNearestDimensionAnchor: hit-test at object origin for elevation annotations
  so clicking the dot correctly turns it blue
- SECTION_LEVEL default curve is now horizontal (camera X) instead of vertical
- Remove 'Edit Elevation Anchor' button; replaced by viewport green-dot workflow
- Add 'Bake to Static' and 'Make Parametric' buttons for elevation annotation types
- MakeDimensionParametric: extended to handle elevation types (single world-point
  anchor at object origin rather than one anchor per spline vertex)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:19 -05:00
Ryan Schultz 5d5be1ea65 remove debug statements 2026-08-04 14:44:19 -05:00
Ryan Schultz 86844eedf7 Add BakeParametricDimension and MakeDimensionParametric operators with UI
BakeParametricDimension (bim.bake_parametric_dimension): removes the
BBIM_Dimension pset from the active annotation, leaving the curve
geometry in place as a static dimension that no longer regenerates
when referenced elements move.

MakeDimensionParametric (bim.make_dimension_parametric): adds a
BBIM_Dimension pset to a static dimension annotation, creating one
free world-point anchor per spline vertex at the current positions.
The endpoints can then be re-anchored to IFC faces via SetDimensionAnchor.
2026-08-04 14:44:19 -05:00
Ryan Schultz 6ab935216f Fix SetDimensionAnchor: use LOCAL_POINT anchor for tessellation VERTEX/EDGE snaps
When SetDimensionAnchor's tessellation fallback ran (element has no
IfcExtrudedAreaSolid, so get_profile_snap_candidates returns empty),
VERTEX and EDGE snaps created a static world anchor (free end) instead
of a parametric one.  The anchor had a position but no guid, so it
never moved with the element.

_compute_snap_geom now includes local_m (element-local Blender
coordinates, metres) in the tessellation fallback return dict for both
VERTEX and EDGE modes.  _handle_face_pick uses build_anchor_from_local_point
when local_m is present, storing a LOCAL_POINT anchor that resolves back
to world space via the element placement — so the endpoint follows the
element through moves and rotations.
2026-08-04 14:44:19 -05:00
Ryan Schultz 2a5685a7d0 Show drag arrows on all parametric dimensions, not just ForcePerpendicularToFace
DimensionLinePositionWidget was gated behind ForcePerpendicularToFace in
both the gizmo poll and the regenerate_dimension LinePosition application.
The coupling was unnecessary: _get_line_offset_direction already derives
the offset axis from cross(world_Z, dim_direction) as its primary path,
requiring the face normal only as a vertical-dimension fallback.

Remove the ForcePerpendicularToFace guard from both sites so any
anchor-based dimension shows the drag arrows and responds to LinePosition.
2026-08-04 14:44:19 -05:00
Ryan Schultz 5309b4614c Fix DrawParametricDimension: always regenerate from anchors after RMB
regenerate_dimension was only called when ForcePerpendicularToFace was
set, so normal two-anchor dimensions were left at raw polyline cursor
positions after placement.  The depsgraph handler would later correct
them when the user happened to select a referenced IFC element, making
accurate placement appear to require a manual selection step.

Remove the _force_perpendicular guard so the anchor-based regeneration
always runs at the end of _create_dimension_from_polyline.
2026-08-04 14:44:19 -05:00
Ryan Schultz ffe7296973 Fix parametric dimension anchor dot clicks: two-event modal + remove stale active-obj guard
ClickNearestDimensionAnchor was firing SetDimensionAnchor immediately on
LMB PRESS and returning FINISHED, which caused Blender to re-deliver the
RELEASE to view3d.select — deselecting the annotation mid-flight.  Rewrite
as a two-event modal: PRESS starts the modal, RELEASE fires SetDimensionAnchor
and exits.  SetDimensionAnchor also swallows any LMB RELEASE it receives to
prevent view3d.select from stealing the active object after hand-off.

The pre-click active-object guard (skip if dimension not active_object) caused
dots to never turn blue: view3d.select was silently replacing the dimension
with the plane underneath on every line-body click, so the dimension was
never the active object at the time of the dot click.  Removed — the operator
now selects the dimension itself before going modal, making each dot click
self-contained.

RADIUS_PX reduced from 60 to 15 to match the gizmo disc visual size and
prevent false triggers on line-body clicks near endpoints.
2026-08-04 14:44:19 -05:00
Ryan Schultz c92451e0fc Fix FACE/VERTEX/EDGE snap for thin edge-on walls in DrawParametricDimension
Walls viewed edge-on in plan (2-7 px screen bbox) were never hit by
Blender's raycast, so all three snap modes silently returned nothing.

- FACE: remove has_coplanar_edge Z-gate; vertical faces are now
  snappable regardless of what elevation the native snap lands on
  (sub-floor surfaces at Z~-7.5m were blocking all candidates)
- All modes: replace hardcoded 30 px _FACE_THRESH_D2 with a
  per-candidate max_tol that matches the adaptive _SCREEN_TOL used
  for bbox inclusion (~98 px for 2 px-wide walls)
- VERTEX/EDGE/LAYER: remove early `if not hit_obj: return None`;
  all modes now search objs_2d_bbox with adaptive tolerance when the
  primary raycast misses
- Add _get_mesh_snap_candidates fallback for tessellated elements
  (IfcFacetedBrep etc.) where get_profile_snap_candidates returns []
- Add LOCAL_POINT anchor method (build_anchor_from_local_point +
  resolve_anchor handler) so mesh-derived anchors store element-local
  coords and follow the element through moves/rotations rather than
  becoming free-floating WORLD anchors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:19 -05:00
Ryan Schultz 38dc24336e Add anchor gizmo dots, keymap click handler, and auto-sync for parametric dimensions
- GizmoAnchorHandle + DimensionAnchorWidget: colored dot gizmos at each
  dimension curve vertex (green=anchored, orange=free); color changes to
  blue while SetDimensionAnchor is in PICK_FACE mode for that vertex
- ClickNearestDimensionAnchor (LMB keymap): Python proximity operator that
  fires SetDimensionAnchor pre-targeted at the nearest anchor dot within
  120px, returning PASS_THROUGH for misses so normal viewport clicks are
  unaffected
- SetDimensionAnchor: added anchor_index prop to enter PICK_FACE directly;
  set_active_anchor called at all phase transitions (invoke, vertex-pick,
  face-pick, alt-click free, ESC/RMB) so gizmo color tracks state correctly
- handler._sync_dimension_anchors_to_curve: proximity-based anchor sync
  when curve vertex count changes in Edit Mode (subdivide / delete)
- depsgraph_update_post_handler: regenerates dimensions when referenced
  elements move; also handles annotation curve edits directly
- Remove standalone Set Anchor button from annotation tool UI (replaced by
  clicking a gizmo dot)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:14 -05:00
Ryan Schultz d2d47efdd4 Add LinePosition: absolute dimension line placement gated on ForcePerpendicularToFace
- BBIM_Dimension.LinePosition (IfcLengthMeasure): holds the dimension line at
  a fixed global coordinate along cross(world_Z, dim_direction), independent of
  geometry movement
- regenerate_dimension applies LinePosition only when ForcePerpendicularToFace is
  also set (the two are semantically coupled); anchor["pt"] always stores the true
  surface hit so the measured length is unaffected
- BIMAnnotationProperties.line_position uses get/set callbacks instead of an update
  callback to avoid the 'Writing to ID classes in this context is not allowed' error
  that fires when Blender draws the tool header
- DimensionLinePositionWidget (BIM_GGT_dimension_line_position): gizmo group with
  two opposing GizmoCone handles at the curve midpoint; poll requires
  ForcePerpendicularToFace so the handles only appear when the feature is active
- UI: line_position field and gizmo are hidden when ForcePerpendicularToFace is off
- Psets_BBIM_Annotation.ifc: #40 LinePosition template added to BBIM_Dimension

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:13 -05:00
Ryan Schultz 8a186b3d01 Move dimension anchor/regen buttons to annotation tool; ForcePerpendicularToFace toggle updates selection
workspace.py:
- Move "Set Dimension Anchor" and "Regenerate" buttons from the properties
  panel (ui.py) into draw_edit_object_interface in the annotation tool,
  visible whenever a selected object is a dimension-type IfcAnnotation
- Change force_perpendicular_to_face from a push-button (toggle=True) to
  a standard checkbox for clearer on/off state

ui.py:
- Remove the "Parametric Dimension" section (now lives in the tool header)

prop.py:
- Add _update_force_perpendicular update callback: when the checkbox is
  toggled, iterates all selected dimension annotations, writes the new
  ForcePerpendicularToFace value to each BBIM_Dimension pset, and calls
  regenerate_dimension so the constraint is applied immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:44:11 -05:00
Ryan Schultz 288b716574 Add BBIM_DimensionTarget: parametric dimensions anchored to element geometry
New modal operator (bim.set_dimension_anchor) anchors dimension vertices
to IFC element faces. Anchors are stored as JSON in a BBIM_DimensionTarget
pset on the IfcAnnotation and resolved via tessellation at regeneration time.

- resolve_anchor.py / regenerate_dimension.py: new ifcopenshell API modules
- bim.set_dimension_anchor: 2-phase Object Mode modal (pick vertex → pick face)
- bim.regenerate_dimensions: recomputes all parametric dimensions
- Auto-regeneration via depsgraph_update_post when referenced elements move
- placement_override reads Blender matrix_world for G-moved elements
- Plan-view annotations flattened to annotation plane (Z=0 in local space)
- IfcIndexedPolyCurve.Segments rebuilt to handle n-point chains correctly
2026-08-04 14:42:35 -05:00
Ryan Schultz 95025ad4b1 Closes #7775: have a BBIM_Dimension.SuppressZeroFeet like there is a BBIM_Dimension.SuppressZeroInches
Generated with the assistance of an AI coding tool.
2026-08-04 14:36:45 -05:00
Ryan Schultz b0aa54b37b closes #8060: add multiple customunits to the dimensions string. 2026-08-04 14:36:19 -05:00
Ryan Schultz 2d8c9561b8 Improve SetDimensionAnchor snap: hover indicator, visibility, face outline
- Add dedicated POST_PIXEL GPU callback (_draw_anchor_hover_global) using
  pre-converted 2D screen coords, replacing the shared POST_VIEW callback
  that caused GPU state issues and Blender freezes
- Add LAYER snap mode hover indicator showing full seam-corner outline
- Remove select_set calls from hover highlight to prevent green object outline
- Add _is_hidden() using hide_get/hide_viewport/visible_get so only scene-
  visible objects are snap candidates
- Add _face_perp_ok() filter (camera-based) to prefer wall faces over
  floor/ceiling faces in FACE mode; non-perp hits tracked in ray_hit_objs
  so directly-hit elements always rank above proximity-found neighbours
- Add _get_current_anchor_guid() to promote the currently-bound element to
  the front of the candidate list when re-picking an anchor vertex
- Add _coplanar_face_outline() to merge tessellated triangles (including
  walls with window/door voids) into the correct outer face boundary;
  walks all disconnected loops and returns the largest (outer perimeter),
  skips meshes > 500 polygons to avoid freezing on terrain objects
- Skip _prefer_perp_face_index in FACE mode so the exact hit face is used
  rather than the face most perpendicular to the camera
- Sort proximity candidates so ray-hit objects rank before bbox-only matches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:01 -05:00
Ryan Schultz fd343188b4 Fix coplanar face snap: correct bbox check, on-edge detection, and ForcePerpendicularToFace
- Switch FACE/LAYER mode nearby-object filtering from 3D bbox to 2D
  screen-space bbox (30px tolerance), fixing walls whose local Y extent
  doesn't contain the floor hit point (e.g. wall at Z=0 with mesh not
  quite reaching the floor level).

- Also run _snap_on_coplanar_faces on hit_obj itself so the blue
  outline and IFC snap fire even when the cursor lands exactly on
  the wall/floor boundary (hit_obj IS the wall, previously skipped).

- Store face_normal_world in coplanar face candidates and call
  build_anchor_from_hit in _build_ifc_anchor for snap=="FACE", so
  the anchor gets a proper FACE type with normal_local in addr.
  This enables ForcePerpendicularToFace and LinePosition to work
  for coplanar edge-on face snaps the same as directly-hit faces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:01 -05:00
Ryan Schultz a9790a3f18 Snap coplanar edge-on faces in FACE/LAYER mode for DrawParametricDimension
Vertical faces perpendicular to the camera cannot be hit by raycast, so
the dimension snap tool missed them entirely. Fix by checking nearby
objects whose 3D bbox contains the floor hit point and running
mode-appropriate candidate lookup on each:

- FACE mode: _snap_on_coplanar_faces finds vertical mesh faces with a
  bottom edge at the hovered Z, projects the cursor onto the face plane,
  and returns a FACE candidate (blue outline + face snap point).
- LAYER mode: get_layer_snap_candidates now runs on nearby bbox objects
  the same way VERTEX/EDGE mode already did, using the shared
  _snap_cand_multi_cache (cleared on TAB mode switch).
2026-08-04 14:30:01 -05:00
Ryan Schultz 40d1c20bcc Optimize DrawParametricDimension startup and MOUSEMOVE performance
- Remove clear_snap_objs() from PolylineOperator.invoke — BVH cache now
  persists across invocations; per-entry staleness is checked in
  create_snap_obj via matrix_world equality + vertex count, eliminating
  the ~11 s full rebuild on every Shift+A press.
- Add _init_snapping_points() hook to PolylineOperator; DrawParametricDimension
  overrides it with a cheap plane-intersection placeholder, deferring full
  BVH detection to the first MOUSEMOVE.
- Cache matrix_world in SnapObj and replace O(N_vertices) validation loop
  with O(1) matrix equality + single sample vertex check, cutting per-call
  create_snap_obj cost from 22-600 ms to <0.2 ms on cache hits.
- Use scene-level BVH pierce-through in SetDimensionAnchor._compute_candidates
  instead of per-object ray_cast loop (O(log N) vs O(N_objects)).
- Guard PolylineDecorator snap_mouse_point access against empty collection
  to prevent IndexError before first MOUSEMOVE populates the property.
- Wrap closest_point_on_mesh in try/except RuntimeError in
  _update_snap_draw_data for annotation objects with no internal mesh data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:01 -05:00
Ryan Schultz 2fcb8c17c7 spread out gizmo arrows. 2026-08-04 14:30:01 -05:00
Ryan Schultz ad9192027c Add TAB snap cycling, snap decorators, and fix anchor dot activation for DrawParametricDimension
- DrawParametricDimension: TAB cycles snap mode FACE→LAYER→EDGE→VERTEX during
  placement; consumes both PRESS and RELEASE when not in input mode to avoid
  conflict with polyline Cycle Input
- DrawParametricDimension: IFC-native snap candidate overrides polyline cursor
  position in LAYER/EDGE/VERTEX modes; FACE mode shows polygon outline; reuses
  _snap_draw_data / _draw_snap_indicator_global infrastructure from SetDimensionAnchor
- DrawParametricDimension: LAYER_BOUNDARY support in _update_perp_constraint,
  deriving normal from LayerSetDirection (AXIS1/2/3)
- ClickNearestDimensionAnchor: scan all visible annotations instead of only
  selected ones — view3d.select deselects the dimension before this operator
  runs, so pre-selection check caused dots to never activate
- AnnotationTool keymap: bim.click_nearest_dimension_anchor placed before
  view3d.select so it fires first when the annotation tool is active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:00 -05:00
Ryan Schultz b787d76ad6 Fix anchor click, undo, layer regen, and ForcePerpendicularToFace for layer anchors
- ClickNearestDimensionAnchor: scan all selected objects instead of active
  object so clicking a green dot doesn't lose to the underlying IFC geometry
- GizmoAnchorHandle: remove draw_select entirely (any entry in the select
  buffer causes Blender's gizmo system to consume clicks); keep purely visual
- Scale anchor dots to scale_basis = 0.2
- Fix ReferenceError in decoration.py draw loop after undo by catching
  ReferenceError and resetting DecoratorData.is_loaded
- SetDimensionAnchor: inherit tool.Ifc.Operator so IFC pset writes are
  tracked for undo; finish the modal after each face write so each anchor
  gets its own undo step
- Fix ReferenceError in _modal after undo when annotation RNA is freed
- handler.py: add regenerate_dims_for_layer; call it from
  EditMaterialSetItem._execute so dimensions update when layer thickness changes
- regenerate_dimension.py: fix ForcePerpendicularToFace for LAYER_BOUNDARY
  anchors by deriving the thickness-axis normal from LayerSetDirection
  (AXIS2→Y, AXIS1→X, AXIS3→Z) instead of requiring a stored normal_local

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:00 -05:00
Ryan Schultz 49ecfbf494 Add anchor gizmo dots, keymap click handler, and auto-sync for parametric dimensions
- GizmoAnchorHandle + DimensionAnchorWidget: colored dot gizmos at each
  dimension curve vertex (green=anchored, orange=free); color changes to
  blue while SetDimensionAnchor is in PICK_FACE mode for that vertex
- ClickNearestDimensionAnchor (LMB keymap): Python proximity operator that
  fires SetDimensionAnchor pre-targeted at the nearest anchor dot within
  120px, returning PASS_THROUGH for misses so normal viewport clicks are
  unaffected
- SetDimensionAnchor: added anchor_index prop to enter PICK_FACE directly;
  set_active_anchor called at all phase transitions (invoke, vertex-pick,
  face-pick, alt-click free, ESC/RMB) so gizmo color tracks state correctly
- handler._sync_dimension_anchors_to_curve: proximity-based anchor sync
  when curve vertex count changes in Edit Mode (subdivide / delete)
- depsgraph_update_post_handler: regenerates dimensions when referenced
  elements move; also handles annotation curve edits directly
- Remove standalone Set Anchor button from annotation tool UI (replaced by
  clicking a gizmo dot)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:00 -05:00
Ryan Schultz 22a17cd287 Add LinePosition: absolute dimension line placement gated on ForcePerpendicularToFace
- BBIM_Dimension.LinePosition (IfcLengthMeasure): holds the dimension line at
  a fixed global coordinate along cross(world_Z, dim_direction), independent of
  geometry movement
- regenerate_dimension applies LinePosition only when ForcePerpendicularToFace is
  also set (the two are semantically coupled); anchor["pt"] always stores the true
  surface hit so the measured length is unaffected
- BIMAnnotationProperties.line_position uses get/set callbacks instead of an update
  callback to avoid the 'Writing to ID classes in this context is not allowed' error
  that fires when Blender draws the tool header
- DimensionLinePositionWidget (BIM_GGT_dimension_line_position): gizmo group with
  two opposing GizmoCone handles at the curve midpoint; poll requires
  ForcePerpendicularToFace so the handles only appear when the feature is active
- UI: line_position field and gizmo are hidden when ForcePerpendicularToFace is off
- Psets_BBIM_Annotation.ifc: #40 LinePosition template added to BBIM_Dimension

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:00 -05:00
Ryan Schultz c35023de88 Move dimension anchor/regen buttons to annotation tool; ForcePerpendicularToFace toggle updates selection
workspace.py:
- Move "Set Dimension Anchor" and "Regenerate" buttons from the properties
  panel (ui.py) into draw_edit_object_interface in the annotation tool,
  visible whenever a selected object is a dimension-type IfcAnnotation
- Change force_perpendicular_to_face from a push-button (toggle=True) to
  a standard checkbox for clearer on/off state

ui.py:
- Remove the "Parametric Dimension" section (now lives in the tool header)

prop.py:
- Add _update_force_perpendicular update callback: when the checkbox is
  toggled, iterates all selected dimension annotations, writes the new
  ForcePerpendicularToFace value to each BBIM_Dimension pset, and calls
  regenerate_dimension so the constraint is applied immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:30:00 -05:00
Ryan Schultz 9b39dd629b Add ForcePerpendicularToFace + hover-cycle UX for parametric dimensions
SetDimensionAnchor — hover-select-then-confirm:
- Cursor highlights candidate IFC elements (orange Blender selection outline)
  before committing; Tab cycles through overlapping/coplanar candidates
- _compute_candidates: ray-cast all IFC mesh objects; falls back to 2D
  bounding-box proximity (5 cm tolerance) for plan-view picks where the
  ray misses the mesh by sub-mm amounts
- _write_anchor: after anchoring a face, immediately calls
  regenerate_dimension with placement_override (Blender matrix_world)
  and _update_blender_curve so the curve vertex moves to the resolved point

DrawParametricDimension — ForcePerpendicularToFace live snap constraint:
- Reads force_perpendicular_to_face toggle from annotation props on invoke
- After anchor[0] is placed on a FACE, _update_perp_constraint extracts
  the face normal and stores it as the constraint axis
- _apply_perp_constraint runs every modal tick after handle_snap_selection,
  projecting the current snap point onto pt[0] + t*normal
- On finalize, _create_dimension_from_polyline writes ForcePerpendicularToFace
  to the BBIM_Dimension pset and calls regenerate_dimension to snap the
  stored curve to the constraint before the operator exits

regenerate_dimension.py:
- ForcePerpendicularToFace block: after resolving all anchors, projects
  vertices 1…n onto the line through pt[0] along anchor[0]'s face normal
- _get_anchor_face_normal_world: reads normal_local from anchor fingerprint,
  calls _rotate_local_to_world with placement_override; falls back to stored
  world-space normal

resolve_anchor.py:
- _rotate_local_to_world: transforms an element-local direction vector to
  world space using the element's placement or placement_override matrix

pset/operator.py:
- EditPset._execute: after editing a BBIM_Dimension pset on an IfcAnnotation,
  auto-calls regenerate_dimension + _update_blender_curve so changes to
  anchors/ForcePerpendicularToFace are reflected immediately in the viewport

prop.py / workspace.py:
- Added force_perpendicular_to_face BoolProperty to BIMAnnotationProperties
- UI toggle shown in annotation tool header for DIMENSION/RADIUS/DIAMETER/
  ANGLE/PLAN_LEVEL/SECTION_LEVEL types

Psets_BBIM_Annotation.ifc:
- Added ForcePerpendicularToFace property template (#39) to BBIM_Dimension
- Extended BBIM_Dimension applicability to ANGLE, PLAN_LEVEL, SECTION_LEVEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:29:59 -05:00
Ryan Schultz 11543f19ca Add bim.draw_parametric_dimension: snap-based polyline operator for dimensions
Extends parametric dimension support with a modal polyline operator that uses
Bonsai's existing snap infrastructure (same as walls/slabs) for placing anchor
points.  Shift+A in the Annotation tool now routes dimension types through this
operator instead of the generic add_annotation path.

- DrawParametricDimension: PolylineOperator subclass; each confirmed snap point
  is converted to a BBIM_DimensionTarget anchor via _snap_to_anchor, which reads
  face_index from the snap dict for face hits and falls back to closest_point_on_mesh
  for vertex/edge hits
- handle_inserting_polyline override tracks anchor list in sync with polyline
  points (insert on count increase, pop on BACKSPACE)
- hotkey_S_A dispatches to bim.draw_parametric_dimension for DIMENSION/RADIUS/
  DIAMETER/ANGLE/PLAN_LEVEL/SECTION_LEVEL types; all other types keep existing path
- depsgraph_update_post_handler extended to also watch is_updated_geometry so
  dimensions auto-regenerate when a referenced mesh is edited in Edit Mode; the
  affected element's tessellation is evicted from _dim_shape_cache so resolve_anchor
  re-tessellates from the updated IFC representation on the next pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 14:29:59 -05:00
Ryan Schultz 0874c4e59c Add BBIM_DimensionTarget: parametric dimensions anchored to element geometry
New modal operator (bim.set_dimension_anchor) anchors dimension vertices
to IFC element faces. Anchors are stored as JSON in a BBIM_DimensionTarget
pset on the IfcAnnotation and resolved via tessellation at regeneration time.

- resolve_anchor.py / regenerate_dimension.py: new ifcopenshell API modules
- bim.set_dimension_anchor: 2-phase Object Mode modal (pick vertex → pick face)
- bim.regenerate_dimensions: recomputes all parametric dimensions
- Auto-regeneration via depsgraph_update_post when referenced elements move
- placement_override reads Blender matrix_world for G-moved elements
- Plan-view annotations flattened to annotation plane (Z=0 in local space)
- IfcIndexedPolyCurve.Segments rebuilt to handle n-point chains correctly
2026-08-04 14:29:59 -05:00
Ryan Schultz d2f18709cb Closes #8063: ordinate dimensioning
Generated with the assistance of an AI coding tool.
2026-08-04 14:29:59 -05:00
Ryan Schultz 101c4716ea Closes #7775: have a BBIM_Dimension.SuppressZeroFeet like there is a BBIM_Dimension.SuppressZeroInches
Generated with the assistance of an AI coding tool.
2026-08-04 14:29:59 -05:00
Ryan Schultz 22c8aa1960 closes #8060: add multiple customunits to the dimensions string. 2026-08-04 14:29:59 -05:00
Bruno Postle 6f3acc84ee ifcmcp: source tool descriptions from ifcquery/ifcedit instead of duplicating them
Alternative to #8955, for #8951 (23 of 25 ifcmcp tools reach MCP clients
with an empty description because FastMCP reads each wrapper's own
__doc__, and the server.py wrappers had none).

#8955 fixes this by hand-writing a new docstring directly onto each
server.py wrapper. Most of those wrappers are thin passthroughs to
IfcSession methods in core.py, which already had short docstrings, which
themselves mostly delegate to already-documented ifcquery/ifcedit
functions -- so that fix tripled up content across three layers that can
drift out of sync.

This instead enriches the true source (the ifcquery/ifcedit library
functions, useful independently of MCP) and has core.py's IfcSession
methods copy __doc__ from their delegate via a small _use_doc()
decorator, and server.py's tool registration pull description= from the
matching IfcSession method. Methods that aren't pure passthroughs
(session lifecycle, generic API/shape dispatch) keep their own
hand-written docs. Keeps #8955's regression test.

Generated with the assistance of an AI coding tool.
2026-08-03 12:52:55 +02:00
Richard Brice e077390e3d add update_key_point_referents to label key alignment points 2026-08-01 15:24:03 -07:00
Richard Brice 80cc603932 alignment: rename get_referent_nest to get_stationing_nest 2026-08-01 15:21:50 -07:00
54 changed files with 7840 additions and 206 deletions
@@ -5,7 +5,7 @@ FILE_NAME('Psets_BBIM_Annotation.ifc','2020-01-01T00:00:00',$,$,'Psets_BBIM_Anno
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2));
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2,#41,#42));
#2=IFCSIMPLEPROPERTYTEMPLATE('2P7JN79n96Q9pElZ83LKe4',$,'ZIndex','',.P_SINGLEVALUE.,'IfcInteger',$,$,$,$,$,.READWRITE.);
#3=IFCSIMPLEPROPERTYTEMPLATE('1Wpx_r2xj1_9w5JpI0QRJy',$,'Symbol','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#4=IFCSIMPLEPROPERTYTEMPLATE('3q0oxMUKP47vZ4jnyG$dDb',$,'Classes','Classes separated by spaces that end up in classes for this element in svg. Can be used to specify the text font size: small - 1.8mm; regular - 2.5mm; large - 3.5mm; header - 5mm; title - 7mm. By default regular size is used.',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -28,7 +28,7 @@ DATA;
#21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30));
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39,#40));
#25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -38,5 +38,14 @@ DATA;
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#40=IFCSIMPLEPROPERTYTEMPLATE('1LoNpKqR3sTuVwXyZaBcDe',$,'LinePosition','Absolute world-space coordinate (metres) of the dimension line along the horizontal offset axis (perpendicular to the dimension direction). When set, the dimension line is held at this fixed global position even if the measured geometry moves. When absent the line sits at the anchor points.',.P_SINGLEVALUE.,'IfcLengthMeasure',$,$,$,$,$,.READWRITE.);
#41=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsAnnotFaux0001aB',$,'IsManualDrawingReference','Marks this annotation as a manually placed drawing reference, exempt from automatic drawing regeneration.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#42=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsDocRefFaux001aB',$,'IsDocumentReference','Marks this annotation as pointing to an external document reference (not a Bonsai drawing camera).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
@@ -32,6 +32,7 @@ classes = (
operator.ActivateModel,
operator.AddAnnotation,
operator.AddAnnotationType,
operator.AddElevationAnnotation,
operator.AddDrawing,
operator.AddDrawingStyle,
operator.AddDrawingToSheet,
@@ -111,6 +112,16 @@ classes = (
operator.ToggleDrawingCategorySelection,
operator.OpenDocumentationWebUi,
operator.FilterSelectedObjectsIfIntersectedByCamera,
operator.DrawParametricDimension,
operator.SetDimensionAnchor,
operator.RegenerateDimensions,
operator.DriveDimensionLength,
operator.RemoveDimensionAnchor,
operator.InsertDimensionAnchor,
operator.ClickNearestDimensionAnchor,
operator.MakeDimensionParametric,
operator.BakeParametricDimension,
operator.DebugDimensionClicks,
prop.Variable,
prop.Drawing,
prop.Document,
@@ -172,11 +183,19 @@ classes = (
gizmos.UglyDotGizmo,
gizmos.ExtrusionGuidesGizmo,
gizmos.ExtrusionWidget,
gizmos.GizmoAnchorHandle,
gizmos.GizmoDriveDimLabel,
gizmos.DimensionAnchorWidget,
gizmos.DimensionLinePositionWidget,
gizmos.DimensionDriveLabelWidget,
workspace.LaunchAnnotationTypeManager,
workspace.Hotkey,
)
_keymaps = []
def menu_func(self, context):
active_obj = context.active_object
if active_obj:
@@ -196,9 +215,21 @@ def register():
bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties)
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
bpy.app.handlers.depsgraph_update_post.append(handler.depsgraph_update_post_handler)
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
if kc:
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS")
_keymaps.append((km, kmi))
kmi_alt = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS", alt=True)
_keymaps.append((km, kmi_alt))
kmi_ctrl = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS", ctrl=True)
_keymaps.append((km, kmi_ctrl))
def unregister():
if not bpy.app.background:
@@ -211,5 +242,10 @@ def unregister():
del bpy.types.TextCurve.BIMTextProperties
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler)
bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler)
for km, kmi in _keymaps:
km.keymap_items.remove(kmi)
_keymaps.clear()
bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func)
+22 -3
View File
@@ -55,6 +55,14 @@ class ProductAssignmentsData:
element = tool.Ifc.get_entity(bpy.context.active_object)
if not element or not element.is_a("IfcAnnotation"):
return
# Document-reference annotations link to an IfcDocumentInformation, not a product.
if tool.Drawing.is_document_reference(element):
for rel in element.HasAssociations:
if rel.is_a("IfcRelAssociatesDocument"):
doc = rel.RelatingDocument
if doc.is_a("IfcDocumentInformation"):
return doc.Name or "Unnamed"
return None
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToProduct"):
name = rel.RelatingProduct.Name or "Unnamed"
@@ -312,6 +320,9 @@ class DecoratorData:
"StartArrowSymbol": "",
"ShowEndArrow": True,
"EndArrowSymbol": "",
"BorderOffset": 8.0,
"AutoStartPosition": "",
"AutoEndPosition": "",
}
obj_pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {}
pset_data.update(obj_pset_data)
@@ -331,6 +342,9 @@ class DecoratorData:
"symbol": end_symbol or "section-arrow",
},
"connect_markers": pset_data["HasConnectedSectionLine"],
"border_offset": float(pset_data["BorderOffset"]),
"auto_start_position": pset_data["AutoStartPosition"] or "",
"auto_end_position": pset_data["AutoEndPosition"] or "",
}
cls.data[obj.name] = display_data
@@ -799,19 +813,24 @@ class DecoratorData:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") or {}
show_description_only = pset_data.get("ShowDescriptionOnly", False)
suppress_zero_inches = pset_data.get("SuppressZeroInches", False)
suppress_zero_feet = pset_data.get("SuppressZeroFeet", False)
is_ordinate = pset_data.get("IsOrdinate", False)
text_prefix = pset_data.get("TextPrefix", None) or ""
text_suffix = pset_data.get("TextSuffix", None) or ""
custom_unit_list = pset_data.get("CustomUnit", None) or ""
custom_unit = custom_unit_list[0] if custom_unit_list else ""
custom_units = list(pset_data.get("CustomUnit", None) or [])
separator = pset_data.get("Separator", None) or " / "
return {
"dimension_style": dimension_style,
"show_description_only": show_description_only,
"suppress_zero_inches": suppress_zero_inches,
"suppress_zero_feet": suppress_zero_feet,
"is_ordinate": is_ordinate,
"text_prefix": text_prefix,
"text_suffix": text_suffix,
"fill_bg": fill_bg,
"custom_unit": custom_unit,
"custom_units": custom_units,
"separator": separator,
}
@classmethod
@@ -494,7 +494,7 @@ class BaseDecorator:
self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs)
@cache
def format_value(self, context, value, suppress_zero_inches=False, custom_unit=None, in_unit_length=False):
def format_value(self, context, value, suppress_zero_inches=False, suppress_zero_feet=False, custom_unit=None, in_unit_length=False):
drawing_pset_data = DrawingsData.data["active_drawing_pset_data"]
precision = drawing_pset_data.get("MetricPrecision", None)
if not precision:
@@ -506,6 +506,7 @@ class BaseDecorator:
precision=precision,
decimal_places=decimal_places,
suppress_zero_inches=suppress_zero_inches,
suppress_zero_feet=suppress_zero_feet,
custom_unit=custom_unit,
in_unit_length=in_unit_length,
)
@@ -722,11 +723,13 @@ class DimensionDecorator(BaseDecorator):
if not dimension_data:
return
show_description_only = dimension_data["show_description_only"]
is_ordinate = dimension_data["is_ordinate"]
text_prefix = dimension_data["text_prefix"]
text_suffix = dimension_data["text_suffix"]
viewportDrawingScale = self.get_viewport_drawing_scale(context)
text_offset_value = viewportDrawingScale * 3
ordinate_total = 0.0
for i0, i1 in indices:
v0 = Vector(vertices[i0])
v1 = Vector(vertices[i1])
@@ -745,16 +748,25 @@ class DimensionDecorator(BaseDecorator):
"multiline": True,
"text_dir": text_dir,
}
base_pos = p0 + text_dir * 0.5
base_pos = p1 if is_ordinate else p0 + text_dir * 0.5
if not show_description_only:
length = (v1 - v0).length
text = self.format_value(
context,
length,
suppress_zero_inches=dimension_data["suppress_zero_inches"],
custom_unit=dimension_data["custom_unit"],
)
segment_length = (v1 - v0).length
if is_ordinate:
ordinate_total += segment_length
length = ordinate_total if is_ordinate else segment_length
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
parts = [
self.format_value(
context,
length,
suppress_zero_inches=dimension_data["suppress_zero_inches"],
suppress_zero_feet=dimension_data["suppress_zero_feet"],
custom_unit=unit,
)
for unit in units_to_format
]
text = dimension_data["separator"].join(str(p) for p in parts)
if isinstance(self, DiameterDecorator):
text = "D" + text
text = text_prefix + text + text_suffix
@@ -765,15 +777,18 @@ class DimensionDecorator(BaseDecorator):
self.draw_label(
text=text,
pos=base_pos + text_offset,
box_alignment="bottom-middle",
pos=base_pos + text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
box_alignment="bottom-right" if is_ordinate else "bottom-middle",
multiline_to_bottom=False,
**common_label_attrs,
)
if not show_description_only and description:
self.draw_label(
text=description, pos=base_pos - text_offset, box_alignment="top-middle", **common_label_attrs
text=description,
pos=base_pos - text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
box_alignment="top-right" if is_ordinate else "top-middle",
**common_label_attrs,
)
@@ -969,7 +984,9 @@ class RadiusDecorator(BaseDecorator):
def get_text():
length = (spline_points[-1] - spline_points[-2]).length
return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"])
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
parts = [self.format_value(context, length, suppress_zero_feet=dimension_data["suppress_zero_feet"], custom_unit=unit) for unit in units_to_format]
return "R" + dimension_data["separator"].join(str(p) for p in parts)
self.draw_dimension_text(
context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center"
@@ -1505,6 +1522,20 @@ class ElevationDecorator(BaseDecorator):
"output_edges": output_edges,
}
# Determine the arrow direction in camera-image-plane (XY) space.
# The elevation tag's local -Z is intentionally parallel to the drawing
# camera's view direction, so projecting it always gives a near-zero XY
# delta. Fall through to local +X (which is perpendicular to the view
# and rotates visibly when the user spins the tag).
view_mat = context.region_data.view_matrix
edge_dir_2d = Vector((1.0, 0.0)) # final fallback
for local_axis in (Vector((0, 0, -1)), Vector((1, 0, 0)), Vector((0, 1, 0))):
world_axis = obj.matrix_world.to_3x3() @ local_axis
cam_xy = (view_mat.to_3x3() @ world_axis).xy
if cam_xy.length > 1e-6:
edge_dir_2d = cam_xy.normalized()
break
# process edges
for edge in edges_original:
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
@@ -1513,7 +1544,7 @@ class ElevationDecorator(BaseDecorator):
circle_head = get_circle_head(circle_size)
start_i = add_verts_sequence(add_offsets(v0, circle_head), start_i, **out_kwargs, closed=True)
edge_dir = (v1 - v0).normalized()
edge_dir = edge_dir_2d.to_3d()
side = (edge_dir.yx * Vector((1, -1))).to_3d()
triangle_head = get_triangle_head(side, edge_dir, triangle_length, triangle_width)
start_i = add_verts_sequence(add_offsets(v0, triangle_head), start_i, **out_kwargs, closed=True)
@@ -2129,4 +2160,8 @@ class DecorationsHandler:
object_decorators = DecoratorData.data.get("object_decorators", [])
for obj, decorator in object_decorators:
decorator.decorate(context, obj)
try:
decorator.decorate(context, obj)
except ReferenceError:
DecoratorData.is_loaded = False
break
@@ -2297,6 +2297,19 @@ DISC = (
(1.0, 0.0, 0),
)
# Anchor index currently being edited by SetDimensionAnchor (-1 = none).
_active_anchor_idx: int = -1
# The annotation curve object being edited (kept so the gizmo group stays
# visible even when SetDimensionAnchor temporarily changes the active object).
_editing_annotation_obj = None
def set_active_anchor(idx: int, annotation_obj=None) -> None:
global _active_anchor_idx, _editing_annotation_obj
_active_anchor_idx = idx
_editing_annotation_obj = annotation_obj if idx >= 0 else None
X3DISC = (
(0.0, 0.0, 0.0),
(1.0, 0.0, 0),
@@ -2621,6 +2634,370 @@ class ExtrusionWidget(types.GizmoGroup):
self.handle.target_set_prop("offset", prop, "value")
self.guides.target_set_prop("depth", prop, "value")
class GizmoAnchorHandle(bpy.types.Gizmo):
"""Visual-only dot at a parametric dimension vertex.
No draw_select/invoke draw_select puts the gizmo in Blender's select buffer
and causes the gizmo system to consume clicks even without an explicit invoke,
blocking ClickNearestDimensionAnchor from receiving them. All click handling
is done by the bim.click_nearest_dimension_anchor keymap operator.
"""
bl_idname = "BIM_GT_anchor_handle"
__slots__ = ("anchor_index", "custom_shape")
def setup(self):
self.anchor_index = 0
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
def draw(self, context):
self.draw_custom_shape(self.custom_shape)
class DimensionAnchorWidget(types.GizmoGroup):
"""Anchor handle gizmos at each vertex of the active parametric dimension.
Green dots indicate vertices that are anchored to an IFC element face;
orange dots are free world-point anchors. Clicking any dot fires
``bim.set_dimension_anchor`` pre-targeted at that vertex index.
"""
bl_idname = "BIM_GGT_dimension_anchors"
bl_label = "Dimension Anchor Handles"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
_MAX_ANCHORS = 16
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get():
return False
# Stay visible while SetDimensionAnchor is running (active obj may temporarily
# be an IFC element in the face-picking phase rather than the annotation).
if _active_anchor_idx >= 0 and _editing_annotation_obj is not None:
active = context.active_object
if active is _editing_annotation_obj:
return True # annotation still active
if active is not None and tool.Ifc.get_entity(active) is not None:
return True # face-picking phase: active obj is a target element
# Active object is None or a non-IFC object — the modal ended without
# calling set_active_anchor(-1). Reset stale state and fall through.
set_active_anchor(-1)
obj = context.active_object
if not obj or obj.type != "CURVE":
return False
if not obj.select_get():
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
return False
import ifcopenshell.util.element as _ue
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
return False
pset = _ue.get_pset(element, "BBIM_Dimension")
return bool(pset and pset.get("Anchors"))
def setup(self, context: bpy.types.Context) -> None:
self._handles: list = []
for _ in range(self._MAX_ANCHORS):
gz = self.gizmos.new("BIM_GT_anchor_handle")
gz.scale_basis = 0.2
gz.use_draw_modal = True
gz.hide = True
self._handles.append(gz)
def refresh(self, context: bpy.types.Context) -> None:
import json
import ifcopenshell.util.element as _ue
obj = _editing_annotation_obj if _active_anchor_idx >= 0 and _editing_annotation_obj else context.active_object
if not obj or not obj.data or not getattr(obj.data, "splines", None):
for gz in self._handles:
gz.hide = True
return
element = tool.Ifc.get_entity(obj)
if not element:
for gz in self._handles:
gz.hide = True
return
pset = _ue.get_pset(element, "BBIM_Dimension")
if not pset or not pset.get("Anchors"):
for gz in self._handles:
gz.hide = True
return
try:
anchors = json.loads(pset["Anchors"])
except Exception:
for gz in self._handles:
gz.hide = True
return
spline = obj.data.splines[0]
n = min(len(spline.points), len(anchors), self._MAX_ANCHORS)
import ifcopenshell.util.element as _ue_gz
_ptype_gz = _ue_gz.get_predefined_type(element)
_is_elevation_gz = _ptype_gz in ("SECTION_LEVEL", "PLAN_LEVEL")
for i in range(n):
gz = self._handles[i]
if _is_elevation_gz:
# The object origin IS the anchor reference point (placed at face hit).
# Spline vertices are offset from the origin and should not be used.
world_co = obj.matrix_world.translation.copy()
else:
raw_co = spline.points[i].co
world_co = obj.matrix_world @ raw_co.to_3d()
gz.matrix_basis = Matrix.Translation(world_co)
gz.anchor_index = i
if i == _active_anchor_idx and obj is _editing_annotation_obj:
gz.color = (0.2, 0.7, 1.0)
gz.color_highlight = (0.4, 0.85, 1.0)
elif anchors[i].get("guid"):
gz.color = (0.2, 0.85, 0.2)
gz.color_highlight = (0.4, 1.0, 0.4)
else:
gz.color = (0.9, 0.6, 0.1)
gz.color_highlight = (1.0, 0.85, 0.2)
gz.alpha = 0.85
gz.alpha_highlight = 1.0
gz.hide = False
for i in range(n, self._MAX_ANCHORS):
self._handles[i].hide = True
def draw_prepare(self, context: bpy.types.Context) -> None:
self.refresh(context)
class DimensionLinePositionWidget(types.GizmoGroup):
"""Drag handle for the LinePosition of a parametric dimension annotation.
Shows two opposing cones at the midpoint of the dimension curve, oriented
along the horizontal offset axis (cross(world_Z, dim_direction)). Dragging
either cone updates BBIM_Dimension.LinePosition and regenerates the curve in
real time. The forward cone points in +offset_dir; the reverse cone in
-offset_dir both respond to mouse movement along the shared axis so the
user can drag in either direction from either handle.
"""
bl_idname = "BIM_GGT_dimension_line_position"
bl_label = "Dimension Line Position"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get():
return False
obj = context.active_object
if not obj or obj.type != "CURVE":
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
return False
import ifcopenshell.util.element as _ue
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
return False
pset = _ue.get_pset(element, "BBIM_Dimension")
return bool(pset and pset.get("Anchors"))
# ------------------------------------------------------------------
# Helpers
@staticmethod
def _cam_dir() -> "Vector | None":
"""Scene camera forward direction, or None."""
cam = bpy.context.scene.camera
if not cam:
return None
return (cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
@classmethod
def _offset_dir(cls, obj: bpy.types.Object) -> "Vector | None":
"""World-space direction perpendicular to the dimension line and in the view plane.
Plan view (camera mostly vertical): cross(world_Z, dim_dir) preserves
existing stored LinePosition values.
Section/elevation (camera mostly horizontal): cross(cam_forward, dim_dir)
keeps the offset axis inside the view plane so the gizmo moves the line
visually sideways (up/down in section) rather than into/out of the screen.
"""
if not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
return None
spline = obj.data.splines[0]
if len(spline.points) < 2:
return None
a = obj.matrix_world @ spline.points[0].co.to_3d()
b = obj.matrix_world @ spline.points[-1].co.to_3d()
dim = b - a
if dim.length < 1e-10:
return None
dim.normalize()
cam_view = cls._cam_dir()
cam_is_plan = (cam_view is None) or abs(cam_view.z) > 0.7
ref = Vector((0.0, 0.0, 1.0)) if cam_is_plan else cam_view
od = ref.cross(dim)
if od.length < 1e-6:
od = Vector((1.0, 0.0, 0.0)).cross(dim)
if od.length < 1e-6:
return None
return od.normalized()
@staticmethod
def _midpoint(obj: bpy.types.Object) -> "Vector":
spline = obj.data.splines[0]
pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
return sum(pts, Vector()) / len(pts)
@staticmethod
def _basis(origin: "Vector", x_axis: "Vector") -> "Matrix":
"""4×4 matrix with translation=origin, local-X=x_axis."""
ref = Vector((0.0, 0.0, 1.0)) if abs(x_axis.dot(Vector((0.0, 0.0, 1.0)))) < 0.9 else Vector((1.0, 0.0, 0.0))
y_ax = x_axis.cross(ref).normalized()
z_ax = x_axis.cross(y_ax)
return Matrix([
[x_axis.x, y_ax.x, z_ax.x, origin.x],
[x_axis.y, y_ax.y, z_ax.y, origin.y],
[x_axis.z, y_ax.z, z_ax.z, origin.z],
[0.0, 0.0, 0.0, 1.0],
])
# ------------------------------------------------------------------
# Value callbacks
def _get_pos(self) -> float:
obj = bpy.context.active_object
if not obj:
return 0.0
element = tool.Ifc.get_entity(obj)
if not element:
return 0.0
import ifcopenshell.util.element as _ue
pset = _ue.get_pset(element, "BBIM_Dimension")
if not pset:
return 0.0
stored = pset.get("LinePosition")
if stored is not None:
return float(stored)
# Natural position: projection of midpoint onto offset axis
od = self._offset_dir(obj)
if od is None:
return 0.0
return self._midpoint(obj).dot(od)
def _set_pos(self, value: float) -> None:
bpy.ops.ed.undo_push(message="Set Line Position")
import json
import numpy as np
import ifcopenshell.util.element as _ue
import ifcopenshell.api.pset as _pset_api
import ifcopenshell.api.drawing as drawing_api
from bonsai.bim.module.drawing.operator import _update_blender_curve
obj = bpy.context.active_object
if not obj:
return
file = tool.Ifc.get()
if not file:
return
element = tool.Ifc.get_entity(obj)
if not element:
return
pset_data = _ue.get_pset(element, "BBIM_Dimension")
if not pset_data:
return
pset_entity = file.by_id(pset_data["id"])
_pset_api.edit_pset(file, pset=pset_entity, properties={"LinePosition": value})
anchors = json.loads(pset_data.get("Anchors") or "[]")
placement_override: dict = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
cam_view = self._cam_dir()
cam_dir_tuple = tuple(cam_view) if cam_view is not None else None
resolved_pts = drawing_api.regenerate_dimension(
file, element, placement_override=placement_override, camera_dir=cam_dir_tuple
)
if resolved_pts:
_update_blender_curve(element, resolved_pts)
tool.Blender.update_viewport()
# ------------------------------------------------------------------
# GizmoGroup interface
def _make_cone(self, color: tuple, highlight: tuple) -> "bpy.types.Gizmo":
gz = self.gizmos.new("BIM_GT_gizmo_cone")
gz.color = color
gz.alpha = 0.8
gz.color_highlight = highlight
gz.alpha_highlight = 1.0
gz.scale_basis = 0.15
gz.use_draw_modal = True
gz.prop_name = "Line Position"
gz.move_get_cb = self._get_pos
gz.move_set_cb = self._set_pos
gz.gizmo_group = self
gz.delta_scale = 1.0
return gz
def setup(self, context: bpy.types.Context) -> None:
color = (0.9, 0.6, 0.1)
highlight = (1.0, 0.9, 0.2)
self.gz_fwd = self._make_cone(color, highlight)
self.gz_rev = self._make_cone(color, highlight)
def refresh(self, context: bpy.types.Context) -> None:
obj = context.active_object
if not obj:
self.gz_fwd.hide = self.gz_rev.hide = True
return
od = self._offset_dir(obj)
if od is None:
self.gz_fwd.hide = self.gz_rev.hide = True
return
mid = self._midpoint(obj)
# Lift each cone off the dimension line so the arrow base doesn't
# overlap anchor dots. 0.3 m gives clear separation at typical zoom.
_GAP = 0.15
fwd_origin = mid + _GAP * od
rev_origin = mid - _GAP * od
self.gz_fwd.matrix_basis = self._basis(fwd_origin, od)
self.gz_fwd.axis = od.copy()
self.gz_fwd.hide = False
# Reverse cone: visually points in -od; same drag axis so both cones
# respond identically — drag toward either tip to move the line.
self.gz_rev.matrix_basis = self._basis(rev_origin, -od)
self.gz_rev.axis = od.copy()
self.gz_rev.hide = False
@staticmethod
def get_scale_value(system: str, length_unit: str) -> float:
scale_value = 1
@@ -2645,6 +3022,76 @@ class ExtrusionWidget(types.GizmoGroup):
return scale_value
class DimensionDriveLabelWidget(types.GizmoGroup):
"""Pen-icon gizmos at each segment midpoint of the active parametric dimension.
Clicking a pen invokes ``bim.drive_dimension_length`` for that segment,
opening a dialog pre-filled with the current length.
"""
bl_idname = "BIM_GGT_dimension_drive_label"
bl_label = "Dimension Drive Label"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
_MAX_SEGMENTS = 15
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get():
return False
obj = context.active_object
if not obj or obj.type != "CURVE":
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
return False
import ifcopenshell.util.element as _ue
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
return False
pset = _ue.get_pset(element, "BBIM_Dimension")
return bool(pset and pset.get("Anchors"))
def setup(self, context: bpy.types.Context) -> None:
self._labels: list = []
for _ in range(self._MAX_SEGMENTS):
gz = self.gizmos.new("BIM_GT_drive_dim_label")
gz.color = (0.9, 0.75, 0.1)
gz.color_highlight = (1.0, 0.95, 0.3)
gz.alpha = 0.85
gz.alpha_highlight = 1.0
gz.scale_basis = 0.18
gz.use_draw_modal = True
gz.hide = True
self._labels.append(gz)
def refresh(self, context: bpy.types.Context) -> None:
obj = context.active_object
if not obj or not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
for gz in self._labels:
gz.hide = True
return
spline = obj.data.splines[0]
pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
n_segs = min(len(pts) - 1, self._MAX_SEGMENTS)
for i in range(n_segs):
gz = self._labels[i]
mid = (pts[i] + pts[i + 1]) * 0.5
gz.matrix_basis = Matrix.Translation(mid)
gz.segment_index = i
gz.hide = False
for i in range(n_segs, self._MAX_SEGMENTS):
self._labels[i].hide = True
def draw_prepare(self, context: bpy.types.Context) -> None:
self.refresh(context)
# ============================================================================
# Core Gizmo Classes
# ============================================================================
@@ -3611,6 +4058,24 @@ class GizmoPen(StaticTrisGizmoMixin, bpy.types.Gizmo):
)
class GizmoDriveDimLabel(bpy.types.Gizmo):
"""Visual-only pen icon at a parametric dimension segment midpoint.
No draw_select/invoke click handling is done by ClickNearestDimensionAnchor,
which dispatches bim.drive_dimension_length on a plain LMB at a midpoint.
"""
bl_idname = "BIM_GT_drive_dim_label"
__slots__ = ("segment_index", "custom_shape")
def setup(self):
self.segment_index = 0
self.custom_shape = self.new_custom_shape("TRIS", GizmoPen.tris)
def draw(self, context):
self.draw_custom_shape(self.custom_shape)
class GizmoValidate(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Validate/checkmark icon gizmo for confirming edits."""
@@ -16,15 +16,148 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import bpy
import numpy as np
from bpy.app.handlers import persistent
import bonsai.bim.module.drawing.decoration as decoration
import bonsai.tool as tool
# ---------------------------------------------------------------------------
# Parametric dimension auto-regeneration state
# ---------------------------------------------------------------------------
# Maps element GUID → list of annotation STEP IDs that reference it.
_dim_guid_index: dict = {}
# Persistent tessellation cache for the depsgraph handler (element id → shape).
_dim_shape_cache: dict = {}
# Set True whenever BBIM_Dimension anchors change or a new file loads.
_dim_index_dirty: bool = True
# Re-entry guard so curve updates don't trigger a second handler call.
_dim_handler_running: bool = False
def invalidate_dim_index() -> None:
"""Mark the GUID index as stale so it is rebuilt on the next handler call."""
global _dim_index_dirty, _dim_shape_cache
_dim_index_dirty = True
_dim_shape_cache.clear()
def _rebuild_dim_guid_index(file) -> None:
global _dim_guid_index, _dim_index_dirty
import ifcopenshell.util.element
_dim_guid_index = {}
for annotation in file.by_type("IfcAnnotation"):
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"):
continue
try:
anchors = json.loads(pset_data["Anchors"])
except Exception:
continue
ann_id = annotation.id()
for anchor in anchors:
guid = anchor.get("guid")
if not guid:
continue
ids = _dim_guid_index.setdefault(guid, [])
if ann_id not in ids:
ids.append(ann_id)
_dim_index_dirty = False
def regenerate_dims_for_layer(file, layer) -> None:
"""Regenerate all parametric dimensions anchored to elements that use *layer*."""
global _dim_shape_cache, _dim_index_dirty, _dim_guid_index
if _dim_index_dirty:
_rebuild_dim_guid_index(file)
affected_guids: set = set()
for layer_set in file.get_inverse(layer):
if not layer_set.is_a("IfcMaterialLayerSet"):
continue
for inv in file.get_inverse(layer_set):
if inv.is_a("IfcRelAssociatesMaterial"):
rels = [inv]
elif inv.is_a("IfcMaterialLayerSetUsage"):
rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")]
else:
continue
for rel in rels:
for element in rel.RelatedObjects:
if hasattr(element, "GlobalId"):
affected_guids.add(element.GlobalId)
_dim_shape_cache.pop(element.id(), None)
if not affected_guids:
return
annotation_ids: set = set()
for guid in affected_guids:
for ann_id in _dim_guid_index.get(guid, []):
annotation_ids.add(ann_id)
if not annotation_ids:
return
import ifcopenshell.util.element
import ifcopenshell.api.drawing as drawing_api
import ifcopenshell.geom
from bonsai.bim.module.drawing.operator import _update_blender_curve
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
cam = bpy.context.scene.camera
cam_dir_tuple = None
if cam:
from mathutils import Vector as _Vec
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
for ann_id in annotation_ids:
try:
annotation = file.by_id(ann_id)
except Exception:
continue
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset:
continue
placement_override: dict = {}
try:
anchors_raw = json.loads(pset.get("Anchors") or "[]")
for anchor in anchors_raw:
guid = anchor.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file,
annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
camera_dir=cam_dir_tuple,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
@persistent
def load_post(*args):
invalidate_dim_index()
props = tool.Drawing.get_document_props()
if props.should_draw_decorations:
decoration.DecorationsHandler.install(bpy.context)
@@ -61,3 +194,193 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
raster_x, raster_y = props.update_camera_resolution()
scene_render.resolution_x = raster_x
scene_render.resolution_y = raster_y
def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
"""Sync BBIM_Dimension.Anchors length to match the curve's spline point count.
Called when the user adds or removes vertices from a dimension annotation in
Edit Mode. New vertices get a free WORLD-type anchor at their current world
position; removed tail vertices simply lose their anchor entries.
Returns True if the pset was changed.
"""
import ifcopenshell.util.element
import ifcopenshell.api.pset
if not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
return False
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"):
return False
try:
anchors: list = json.loads(pset_data["Anchors"])
except Exception:
return False
spline = obj.data.splines[0]
spline_world = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
n_pts = len(spline_world)
n_anchors = len(anchors)
if n_pts == n_anchors:
return False
# Match each spline point to the nearest unused anchor by proximity.
# This handles insertions (subdivide) and deletions correctly regardless
# of where in the polyline the edit happened.
_MATCH_THRESH_SQ = 1e-4 # 1 cm² — distinguishes existing pts from new midpoints
used: set = set()
new_anchors: list = []
for pt in spline_world:
best_idx, best_sq = None, float("inf")
for i, anc in enumerate(anchors):
if i in used:
continue
stored = anc.get("pt")
if not stored:
continue
dx, dy, dz = stored[0] - pt.x, stored[1] - pt.y, stored[2] - pt.z
sq = dx * dx + dy * dy + dz * dz
if sq < best_sq:
best_sq, best_idx = sq, i
if best_idx is not None and best_sq < _MATCH_THRESH_SQ:
new_anchors.append(anchors[best_idx])
used.add(best_idx)
else:
new_anchors.append({
"guid": None,
"type": "WORLD",
"addr": {},
"hint": None,
"pt": [pt.x, pt.y, pt.z],
})
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
invalidate_dim_index()
return True
@persistent
def depsgraph_update_post_handler(scene, depsgraph):
"""Auto-regenerate parametric dimensions when referenced elements are moved."""
global _dim_handler_running, _dim_index_dirty, _dim_guid_index, _dim_shape_cache
if _dim_handler_running:
return
file = tool.Ifc.get()
if not file:
return
if _dim_index_dirty:
_rebuild_dim_guid_index(file)
import ifcopenshell.util.element
moved_guids: set = set()
edited_annotation_ids: set = set()
for update in depsgraph.updates:
obj = update.id
if not isinstance(obj, bpy.types.Object):
continue
if not (update.is_updated_transform or update.is_updated_geometry):
continue
element = tool.Ifc.get_entity(obj)
if element is None or not hasattr(element, "GlobalId"):
continue
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
import ifcopenshell.util.element as _ue
ptype = _ue.get_predefined_type(element)
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"):
changed = _sync_dimension_anchors_to_curve(file, element, obj)
if changed:
edited_annotation_ids.add(element.id())
continue
moved_guids.add(element.GlobalId)
if update.is_updated_geometry:
_dim_shape_cache.pop(element.id(), None)
annotation_ids: set = set(edited_annotation_ids)
for guid in moved_guids:
for ann_id in _dim_guid_index.get(guid, []):
annotation_ids.add(ann_id)
if not annotation_ids:
return
import ifcopenshell.api.drawing as drawing_api
import ifcopenshell.geom
from bonsai.bim.module.drawing.operator import _update_blender_curve, _update_elevation_marker_z
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
cam = bpy.context.scene.camera
cam_dir_tuple = None
if cam:
from mathutils import Vector as _Vec
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
_dim_handler_running = True
try:
for ann_id in annotation_ids:
try:
annotation = file.by_id(ann_id)
except Exception:
continue
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset:
continue
placement_override: dict = {}
try:
anchors_raw = json.loads(pset.get("Anchors") or "[]")
for anchor in anchors_raw:
guid = anchor.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_id = elem.id()
if elem_id in placement_override:
continue
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem_id] = np.array(elem_obj.matrix_world)
except Exception:
pass
except Exception:
pass
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
_update_elevation_marker_z(
file, annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
)
else:
resolved_pts = drawing_api.regenerate_dimension(
file,
annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
camera_dir=cam_dir_tuple,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
finally:
_dim_handler_running = False
@@ -170,6 +170,7 @@ def format_distance(
precision=None,
decimal_places=None,
suppress_zero_inches=False,
suppress_zero_feet=False,
in_unit_length=False,
custom_unit=None,
):
@@ -319,10 +320,10 @@ def format_distance(
tx_dist = ""
if feet:
tx_dist += str(feet) + "'"
if not feet and not add_inches:
if not feet and not add_inches and not suppress_zero_feet:
tx_dist += str(feet) + "'"
if not feet and add_inches and unit_length != "INCHES":
if not feet and add_inches and unit_length != "INCHES" and not suppress_zero_feet:
if value < 0:
tx_dist += "-0' - "
else:
File diff suppressed because it is too large Load Diff
@@ -95,6 +95,17 @@ def update_diagram_scale(self: "BIMCameraProperties", context: bpy.types.Context
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=diagram_scale)
self.update_camera_resolution()
group = tool.Drawing.get_drawing_group(element)
print(f"[SECTION] update_diagram_scale: camera={camera.name}, group={group}")
if group:
for annotation in tool.Drawing.get_group_elements(group) or []:
print(f"[SECTION] checking group member: {annotation}")
if annotation.is_a("IfcAnnotation") and ifcopenshell.util.element.get_predefined_type(annotation) == "SECTION":
ann_obj = tool.Ifc.get_object(annotation)
print(f"[SECTION] found SECTION annotation, ann_obj={ann_obj}")
if ann_obj:
tool.Drawing.update_section_endpoints(ann_obj, camera)
def update_is_nts(self: "BIMCameraProperties", context: bpy.types.Context) -> None:
if not self.update_props:
@@ -1038,6 +1049,182 @@ def update_sheet_data(self, context):
SheetsData.is_loaded = False
def _update_force_perpendicular(self, context):
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
import json
import numpy as np
import ifcopenshell.util.element
import ifcopenshell.api.pset
import ifcopenshell.api.drawing as drawing_api
import bonsai.tool as tool
file = tool.Ifc.get()
if not file:
return
new_value = self.force_perpendicular_to_face
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
targets = []
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
continue
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
continue
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if not pset_data:
continue
targets.append((obj, element, pset_data))
if not targets:
return
from bonsai.bim.module.drawing.operator import _update_blender_curve
for obj, element, pset_data in targets:
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"ForcePerpendicularToFace": new_value})
anchors = json.loads(pset_data.get("Anchors") or "[]")
placement_override = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
if resolved_pts:
_update_blender_curve(element, resolved_pts)
def _get_line_position(self) -> float:
"""Return LinePosition from the active annotation's BBIM_Dimension pset.
Falls back to the natural anchor projection when LinePosition has not been
explicitly set, so the field always shows a meaningful value.
"""
import math
import json
try:
import bpy as _bpy
import ifcopenshell.util.element as _ue
import bonsai.tool as _tool
obj = getattr(_bpy.context, "active_object", None)
if obj:
element = _tool.Ifc.get_entity(obj)
if element and element.is_a("IfcAnnotation"):
pset = _ue.get_pset(element, "BBIM_Dimension")
if pset:
stored = pset.get("LinePosition")
if stored is not None:
return float(stored)
raw = pset.get("Anchors")
if raw:
anchors = json.loads(raw)
if len(anchors) >= 2 and anchors[0].get("pt") and anchors[1].get("pt"):
a, b = anchors[0]["pt"], anchors[1]["pt"]
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
m = math.sqrt(dx * dx + dy * dy + dz * dz)
if m > 1e-10:
ddx, ddy, ddz = dx / m, dy / m, dz / m
cam = _bpy.context.scene.camera
cam_is_plan = True
cvx, cvy, cvz = 0.0, 0.0, 1.0
if cam:
from mathutils import Vector as _Vec
cv = (cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized()
cvx, cvy, cvz = cv.x, cv.y, cv.z
cam_is_plan = abs(cvz) > 0.7
if cam_is_plan:
# cross(world_Z, dim_dir)
ox, oy, oz = -ddy, ddx, 0.0
else:
# cross(cam_dir, dim_dir)
ox = cvy * ddz - cvz * ddy
oy = cvz * ddx - cvx * ddz
oz = cvx * ddy - cvy * ddx
om = math.sqrt(ox * ox + oy * oy + oz * oz)
if om > 1e-6:
od = (ox / om, oy / om, oz / om)
pt = anchors[0]["pt"]
return float(pt[0] * od[0] + pt[1] * od[1] + pt[2] * od[2])
except Exception:
pass
return 0.0
def _set_line_position(self, value: float) -> None:
"""Write LinePosition to all selected dimension annotations and regenerate."""
import json
import numpy as np
import ifcopenshell.util.element
import ifcopenshell.api.pset
import ifcopenshell.api.drawing as drawing_api
import bonsai.tool as tool
file = tool.Ifc.get()
if not file:
return
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
targets = []
import bpy as _bpy
for obj in getattr(_bpy.context, "selected_objects", []):
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
continue
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
continue
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if not pset_data:
continue
targets.append((obj, element, pset_data))
if not targets:
return
from bonsai.bim.module.drawing.operator import _update_blender_curve
cam = _bpy.context.scene.camera
cam_dir_tuple = None
if cam:
from mathutils import Vector as _Vec
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
for obj, element, pset_data in targets:
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"LinePosition": value})
anchors = json.loads(pset_data.get("Anchors") or "[]")
placement_override = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file, element, placement_override=placement_override, camera_dir=cam_dir_tuple
)
if resolved_pts:
_update_blender_curve(element, resolved_pts)
class BIMAnnotationProperties(PropertyGroup):
object_type: bpy.props.EnumProperty(
name="Annotation Object Type", items=annotation_classes, default="TEXT", update=update_annotation_object_type
@@ -1051,6 +1238,19 @@ class BIMAnnotationProperties(PropertyGroup):
)
is_adding_type: bpy.props.BoolProperty(default=False)
type_name: bpy.props.StringProperty(name="Name", default="TYPEX")
force_perpendicular_to_face: bpy.props.BoolProperty(
name="Force ⊥ to Face",
description="Constrain dimension vertices to the face normal of the first anchor. When dimensions are selected, toggling this updates them all.",
default=False,
update=_update_force_perpendicular,
)
line_position: bpy.props.FloatProperty(
name="Line Position",
description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.",
unit="LENGTH",
get=_get_line_position,
set=_set_line_position,
)
tag_rotation_mode: bpy.props.EnumProperty(
name="Tag Rotation Mode",
description="How to orient the tag relative to the tagged object",
@@ -872,7 +872,11 @@ class SvgWriter:
v1 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, 0)))
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, -1)))
angle = -math.degrees((v2 - v1).xy.angle_signed(Vector((0, 1))))
delta = (v2 - v1).xy
if delta.length <= 1e-6:
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((1, 0, 0)))
delta = (v2 - v1).xy
angle = -math.degrees(delta.angle_signed(Vector((0, 1)))) if delta.length > 1e-6 else 90.0
transform = "rotate({}, {}, {})".format(angle, *symbol_position_svg.xy)
@@ -895,6 +899,8 @@ class SvgWriter:
reference_id = "-"
sheet_id = "-"
drawing = tool.Drawing.get_annotation_element(element)
if not drawing:
return ("-", "-")
reference = tool.Drawing.get_drawing_reference(drawing)
if reference:
for sheet_reference in tool.Ifc.get().by_type("IfcDocumentReference"):
@@ -1367,14 +1373,18 @@ class SvgWriter:
def get_text():
radius = (points[-1].co - points[-2].co).length
radius = helper.format_distance(
radius,
precision=self.precision,
decimal_places=self.decimal_places,
custom_unit=dimension_data["custom_unit"],
)
text = f"R{radius}"
return text
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
parts = [
helper.format_distance(
radius,
precision=self.precision,
decimal_places=self.decimal_places,
suppress_zero_feet=dimension_data["suppress_zero_feet"],
custom_unit=unit,
)
for unit in units_to_format
]
return "R" + dimension_data["separator"].join(str(p) for p in parts)
self.draw_dimension_text(
get_text, tag, dimension_data, text_position=text_position, class_str="RADIUS", box_alignment="center"
@@ -1501,10 +1511,12 @@ class SvgWriter:
text_format=lambda x: "D" + x,
show_description_only=dimension_data["show_description_only"],
suppress_zero_inches=dimension_data["suppress_zero_inches"],
suppress_zero_feet=dimension_data["suppress_zero_feet"],
text_prefix=dimension_data["text_prefix"],
text_suffix=dimension_data["text_suffix"],
fill_bg=dimension_data["fill_bg"],
custom_unit=dimension_data["custom_unit"],
custom_units=dimension_data["custom_units"],
separator=dimension_data["separator"],
)
def draw_dimension_annotations(self, obj: bpy.types.Object) -> None:
@@ -1515,11 +1527,15 @@ class SvgWriter:
dimension_data = DecoratorData.get_dimension_data(obj)
assert isinstance(obj.data, bpy.types.Curve)
is_ordinate = dimension_data["is_ordinate"]
for spline in obj.data.splines:
points = self.get_spline_points(spline)
ordinate_total = 0.0
for i in range(len(points) - 1):
v0_global = matrix_world @ points[i].co.xyz
v1_global = matrix_world @ points[i + 1].co.xyz
if is_ordinate:
ordinate_total += (v1_global - v0_global).length
self.draw_dimension_annotation(
v0_global,
v1_global,
@@ -1527,10 +1543,13 @@ class SvgWriter:
dimension_text=dimension_text,
show_description_only=dimension_data["show_description_only"],
suppress_zero_inches=dimension_data["suppress_zero_inches"],
suppress_zero_feet=dimension_data["suppress_zero_feet"],
text_prefix=dimension_data["text_prefix"],
text_suffix=dimension_data["text_suffix"],
fill_bg=dimension_data["fill_bg"],
custom_unit=dimension_data["custom_unit"],
custom_units=dimension_data["custom_units"],
separator=dimension_data["separator"],
distance_override=ordinate_total if is_ordinate else None,
)
def draw_measureit_arch_dimension_annotations(self) -> None:
@@ -1554,10 +1573,13 @@ class SvgWriter:
text_format=lambda x: x,
show_description_only=False,
suppress_zero_inches=False,
suppress_zero_feet=False,
text_prefix="",
text_suffix="",
fill_bg=False,
custom_unit=None,
custom_units=None,
separator=" / ",
distance_override=None,
) -> None:
offset = Vector([self.raw_width, self.raw_height]) / 2
v0 = self.project_point_onto_camera(v0_global)
@@ -1570,7 +1592,10 @@ class SvgWriter:
sheet_dimension = (end - start).length
# if annotation can't fit offset text to the right of marker
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
if distance_override is not None:
text_position = end
else:
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
angle = math.degrees(vector.angle_signed(Vector((1, 0))))
line = self.svg.line(start=start, end=end, class_=" ".join(classes))
@@ -1585,15 +1610,20 @@ class SvgWriter:
}
if not show_description_only:
dimension = (v1_global - v0_global).length
dimension = helper.format_distance(
dimension,
precision=self.precision,
decimal_places=self.decimal_places,
suppress_zero_inches=suppress_zero_inches,
custom_unit=custom_unit,
)
text = text_prefix + str(dimension) + text_suffix
dimension = distance_override if distance_override is not None else (v1_global - v0_global).length
units_to_format = custom_units if custom_units else [None]
parts = [
helper.format_distance(
dimension,
precision=self.precision,
decimal_places=self.decimal_places,
suppress_zero_inches=suppress_zero_inches,
suppress_zero_feet=suppress_zero_feet,
custom_unit=unit,
)
for unit in units_to_format
]
text = text_prefix + separator.join(str(p) for p in parts) + text_suffix
else:
if not dimension_text:
return
@@ -1601,8 +1631,8 @@ class SvgWriter:
text_tags += self.create_text_tag(
text,
text_position + perpendicular,
box_alignment="bottom-middle",
text_position + perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
box_alignment="bottom-right" if distance_override is not None else "bottom-middle",
multiline_to_bottom=False,
**text_tag_kwargs,
)
@@ -1610,8 +1640,8 @@ class SvgWriter:
if not show_description_only and dimension_text:
text_tags += self.create_text_tag(
dimension_text,
text_position - perpendicular,
box_alignment="top-middle",
text_position - perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
box_alignment="top-right" if distance_override is not None else "top-middle",
multiline_to_bottom=True,
**text_tag_kwargs,
)
@@ -555,6 +555,17 @@ class BIM_PT_product_assignments(Panel):
assert self.layout
assert (obj := context.active_object)
element = tool.Ifc.get_entity(obj)
if element and tool.Drawing.is_manual_drawing_reference(element):
row = self.layout.row(align=True)
fallback = "No Reference Assigned" if element.ObjectType == "REFERENCE" else "No Drawing Assigned"
row.label(
text=ProductAssignmentsData.data["relating_product"] or fallback, icon="IMAGE_DATA"
)
row.operator("bim.assign_manual_drawing_reference", icon="GREASEPENCIL", text="")
return
props = tool.Drawing.get_object_assigned_product_props(obj)
if props.is_editing_product:
@@ -572,6 +583,8 @@ class BIM_PT_product_assignments(Panel):
col.enabled = bool(ProductAssignmentsData.data["relating_product"])
def get_category_icon(category_name):
"""Get appropriate icon for each category"""
icons = {
@@ -114,7 +114,11 @@ class AnnotationTool(WorkSpaceTool):
bl_description = "Gives you Annotation related superpowers"
bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.annotation")
bl_widget = None
bl_keymap = tool.Blender.get_default_selection_keypmap() + (
bl_keymap = (
# Before view3d.select: tool keymaps take priority over the addon keymap
# where ClickNearestDimensionAnchor is also registered.
("bim.click_nearest_dimension_anchor", {"type": "LEFTMOUSE", "value": "PRESS"}, None),
) + tool.Blender.get_default_selection_keypmap() + (
("bim.annotation_hotkey", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}),
("bim.annotation_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}),
("bim.annotation_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}),
@@ -221,14 +225,68 @@ class AnnotationToolUI:
props = tool.Drawing.get_document_props()
row.prop(props, "should_draw_decorations", text="Viewport Annotations")
_DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
@classmethod
def draw_edit_object_interface(cls, context):
if DecoratorData.get_text_data(bpy.context.active_object):
obj = bpy.context.active_object
if tool.Ifc.get_entity(obj) and DecoratorData.get_text_data(obj):
add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "")
if bpy.ops.bim.copy_annotation_to_drawing.poll():
row = cls.layout.row(align=True)
row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing")
obj = context.active_object
element = tool.Ifc.get_entity(obj) if obj else None
if element and element.is_a("IfcAnnotation"):
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in cls._DIMENSION_TYPES:
cls.layout.separator()
ann_props = tool.Drawing.get_annotation_props()
if ann_props.force_perpendicular_to_face:
row = cls.layout.row(align=True)
row.prop(ann_props, "line_position")
cls.layout.separator()
row = cls.layout.row(align=True)
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
obj = context.active_object
element = tool.Ifc.get_entity(obj) if obj else None
if element and element.is_a("IfcAnnotation"):
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in cls._DIMENSION_TYPES:
cls.layout.separator()
ann_props = tool.Drawing.get_annotation_props()
if ann_props.force_perpendicular_to_face:
row = cls.layout.row(align=True)
row.prop(ann_props, "line_position")
cls.layout.separator()
row = cls.layout.row(align=True)
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if pset and pset.get("Anchors"):
row = cls.layout.row(align=True)
row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED")
else:
row = cls.layout.row(align=True)
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
elif ptype in cls._ELEVATION_TYPES:
cls.layout.separator()
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if pset and pset.get("Anchors"):
row = cls.layout.row(align=True)
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
row = cls.layout.row(align=True)
row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED")
else:
row = cls.layout.row(align=True)
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
@classmethod
def draw_type_selection_interface(cls):
# shared by both sidebar and header
@@ -251,6 +309,11 @@ class AnnotationToolUI:
add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation")
_DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE"}
if object_type in _DIMENSION_TYPES:
row = cls.layout.row(align=True)
row.prop(cls.props, "force_perpendicular_to_face")
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
row = cls.layout.row(align=True)
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
@@ -333,8 +396,20 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
if created_objects:
bpy.context.view_layer.objects.active = created_objects[-1]
_PARAMETRIC_DIMENSION_TYPES = frozenset(
("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")
)
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
def hotkey_S_A(self):
if bpy.ops.bim.add_annotation.poll():
props = tool.Drawing.get_annotation_props()
if props.object_type in self._PARAMETRIC_DIMENSION_TYPES:
if bpy.ops.bim.draw_parametric_dimension.poll():
bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT")
elif props.object_type in self._ELEVATION_TYPES:
if bpy.ops.bim.add_elevation_annotation.poll():
bpy.ops.bim.add_elevation_annotation("INVOKE_DEFAULT")
elif bpy.ops.bim.add_annotation.poll():
bpy.ops.bim.add_annotation()
def hotkey_S_E(self):
@@ -1325,6 +1325,10 @@ class OverrideDuplicateMove(bpy.types.Operator):
if new_active_obj:
context.view_layer.objects.active = new_active_obj
if any(e.is_a("IfcAnnotation") for e in old_to_new):
import bonsai.bim.module.drawing.handler as _drawing_handler
_drawing_handler.invalidate_dim_index()
return old_to_new
@@ -834,6 +834,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
)
slab.DumbSlabPlaner().regenerate_from_layer(layer)
wall.DumbWallPlaner().regenerate_from_layer(layer)
from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer
regenerate_dims_for_layer(self.file, layer)
elif material.is_a("IfcMaterialProfileSet"):
profile_def = None
if mprops.profiles:
@@ -796,6 +796,8 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
rv3d = region.data
polyline_props = tool.Model.get_polyline_props()
if not polyline_props.snap_mouse_point:
return
snap_prop = polyline_props.snap_mouse_point[0]
mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z))
@@ -863,6 +865,8 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
gpu.state.point_size_set(6)
polyline_props = tool.Model.get_polyline_props()
if not polyline_props.snap_mouse_point:
return
snap_prop = polyline_props.snap_mouse_point[0]
# Point related to the mouse
mouse_point = [Vector((snap_prop.x, snap_prop.y, snap_prop.z))]
+15 -3
View File
@@ -462,14 +462,26 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
tool.Raycast.clear_snap_objs()
# Do not call clear_snap_objs() here — create_snap_obj() validates stale
# entries per-object (vertex count + position check), so the BVH cache can
# safely persist across invocations. Clearing it caused an 11-second stall
# on every Shift+A because SnapObj rebuilds a pure-Python BVH tree.
self.visible_objs = tool.Raycast.get_visible_objects(context)
for obj in self.visible_objs:
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
self.objs_2d_bbox.append(bbox_2d)
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
self._init_snapping_points(context, event)
tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
context.window_manager.modal_handler_add(self)
def _init_snapping_points(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
"""Populate self.snapping_points at operator start.
Override in subclasses to skip the full BVH snap detection when a cheap
placeholder is sufficient. The default runs the full detection pass.
"""
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
@@ -88,6 +88,44 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
props.active_pset_type = "-"
def _regenerate_parametric_dimension(file, annotation):
"""Regenerate a single parametric dimension annotation after a pset edit."""
try:
import json
import numpy as np
import ifcopenshell.util.element
import ifcopenshell.api.drawing as drawing_api
import bonsai.tool as _tool
from bonsai.bim.module.drawing.operator import _update_blender_curve
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"):
return
anchors = json.loads(pset_data["Anchors"])
placement_override = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = _tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file, annotation, placement_override=placement_override
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
except Exception:
import traceback
traceback.print_exc()
class EditPset(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_pset"
bl_label = "Edit Pset"
@@ -152,7 +190,12 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
)
if tool.Cost.has_schedules():
tool.Cost.update_cost_items(pset=pset)
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
if is_bbim_dimension:
_regenerate_parametric_dimension(self.file, element)
tool.Blender.update_viewport()
+26
View File
@@ -520,6 +520,7 @@ def add_annotation(
relating_type: ifcopenshell.entity_instance,
enable_editing: bool = False,
) -> bpy.types.Object:
print(f"[SECTION] core.add_annotation called: object_type={object_type}")
target_view = drawing_tool.get_drawing_target_view(drawing)
context = drawing_tool.get_annotation_context(target_view, object_type)
if not context:
@@ -542,6 +543,11 @@ def add_annotation(
if relating_type:
drawing_tool.run_type_assign_type(element=element, relating_type=relating_type)
ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), products=[element])
if object_type == "SECTION":
camera = ifc.get_object(drawing)
print(f"[SECTION] add_annotation: object_type=SECTION, camera={camera}")
if camera:
drawing_tool.update_section_endpoints(obj, camera)
if representation := drawing_tool.get_representation(element, context):
drawing_tool.reload_representation(obj=obj, representation=representation)
collector.assign(obj, should_clean_users_collection=True)
@@ -550,6 +556,26 @@ def add_annotation(
return obj
def assign_manual_drawing_reference(
ifc: type[tool.Ifc],
drawing_tool: type[tool.Drawing],
element: ifcopenshell.entity_instance,
drawing: Optional[ifcopenshell.entity_instance],
) -> None:
for existing in drawing_tool.get_assigned_product_workaround(element):
ifc.run("drawing.unassign_product", relating_product=existing, related_object=element)
if drawing:
ifc.run("drawing.assign_product", relating_product=drawing, related_object=element)
def assign_manual_reference_document(
drawing_tool: type[tool.Drawing],
element: ifcopenshell.entity_instance,
document: Optional[ifcopenshell.entity_instance],
) -> None:
drawing_tool.set_annotation_reference_doc(element, document)
def build_schedule(drawing: type[tool.Drawing], schedule: ifcopenshell.entity_instance) -> None:
drawing.create_svg_schedule(schedule)
drawing.open_svg(drawing.get_path_with_ext(drawing.get_document_uri(schedule), "svg"))
+281 -1
View File
@@ -77,6 +77,9 @@ if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties
print("[SECTION] tool/drawing.py module loaded")
class Drawing(bonsai.core.tool.Drawing):
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
@@ -209,6 +212,17 @@ class Drawing(bonsai.core.tool.Drawing):
co_end = co1 + vec * scaled_length
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
obj.matrix_world = obj.matrix_world @ Matrix.Rotation(math.radians(-90), 4, "Z")
elif object_type == "SECTION_LEVEL":
co1, _, co3, _ = annotation.Annotator.get_placeholder_coords()
# co3 - co1 is the camera X direction (horizontal in a section view).
vec = co3 - co1
if vec.length == 0:
vec = Vector((1, 0, 0))
else:
vec = vec.normalized()
scaled_length = 0.023 * scale
co_end = co1 + vec * scaled_length
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
elif object_type != "TEXT":
obj = annotation.Annotator.add_line_to_annotation(obj)
@@ -1632,7 +1646,14 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def is_auto_annotation(cls, element: ifcopenshell.entity_instance):
return element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL")
if not (element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL")):
return False
if ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsManualDrawingReference"):
return False
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL") and ifcopenshell.util.element.get_pset(element, "BBIM_Dimension"):
return False
return True
@classmethod
def get_drawing_reference_annotation(
@@ -1964,6 +1985,95 @@ class Drawing(bonsai.core.tool.Drawing):
element.Name = elevation.Name or "Unnamed"
return element
@classmethod
def create_manual_elevation_reference(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
cursor_location = bpy.context.scene.cursor.location.copy()
obj = bpy.data.objects.new("Unnamed", None)
obj.empty_display_size = 0.1
obj.matrix_world = Matrix.Translation(cursor_location) @ Matrix.Rotation(math.radians(90), 4, "X")
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="ELEVATION", should_add_representation=False
)
element.Name = "Unnamed"
return element
@classmethod
def create_manual_section_reference(
cls, drawing: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
cursor_location = bpy.context.scene.cursor.location.copy()
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new("Unnamed", mesh)
obj.matrix_world = Matrix.Translation(cursor_location)
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="SECTION", should_add_representation=False
)
element.Name = "Unnamed"
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1 = cursor_location + Vector((-0.5, 0, 0))
p2 = cursor_location + Vector((0.5, 0, 0))
points = [p1 / unit_scale, p2 / unit_scale]
representation = builder.get_representation(context, [builder.polyline(points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
return element
@classmethod
def set_manual_drawing_reference(cls, element: ifcopenshell.entity_instance) -> None:
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"IsManualDrawingReference": True})
@classmethod
def is_manual_drawing_reference(cls, element: ifcopenshell.entity_instance) -> bool:
return bool(ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsManualDrawingReference"))
@classmethod
def is_document_reference(cls, element: ifcopenshell.entity_instance) -> bool:
"""Return True if this annotation links to an external document (not a Bonsai drawing camera)."""
return bool(ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsDocumentReference"))
@classmethod
def set_document_reference_flag(cls, element: ifcopenshell.entity_instance) -> None:
"""Mark this annotation as pointing to an external document reference."""
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"IsDocumentReference": True})
@classmethod
def get_annotation_reference_doc(
cls, element: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the IfcDocumentInformation linked to a document-reference annotation."""
for rel in element.HasAssociations:
if rel.is_a("IfcRelAssociatesDocument"):
doc = rel.RelatingDocument
if doc.is_a("IfcDocumentInformation"):
return doc
return None
@classmethod
def set_annotation_reference_doc(
cls,
element: ifcopenshell.entity_instance,
document: Union[ifcopenshell.entity_instance, None],
) -> None:
"""Associate (or clear) an IfcDocumentInformation on a document-reference annotation."""
ifc_file = tool.Ifc.get()
# Remove existing document associations on this annotation.
for rel in list(element.HasAssociations):
if rel.is_a("IfcRelAssociatesDocument"):
ifcopenshell.api.document.unassign_document(
ifc_file, products=[element], document=rel.RelatingDocument
)
if document:
ifcopenshell.api.document.assign_document(ifc_file, products=[element], document=document)
@classmethod
def regenerate_elevation_reference_annotation(
cls,
@@ -2810,6 +2920,176 @@ class Drawing(bonsai.core.tool.Drawing):
numerator, denominator = scale.split("/")
return float(numerator) / float(denominator)
@classmethod
def get_camera_dimensions(cls, camera: bpy.types.Object) -> tuple[float, float]:
render = bpy.context.scene.render
assert isinstance(camera.data, bpy.types.Camera)
if render.resolution_x > render.resolution_y:
width = camera.data.ortho_scale
height = width / render.resolution_x * render.resolution_y
else:
height = camera.data.ortho_scale
width = height / render.resolution_y * render.resolution_x
return width, height
@staticmethod
def _section_ray_rect_intersections(
origin: Vector, direction: Vector, half_w: float, half_h: float
) -> list[float]:
"""Return t values where the ray origin+t*direction intersects the ±half_w/±half_h rectangle."""
results: list[float] = []
eps = 1e-6
if abs(direction.x) > eps:
for x_bound in (-half_w, half_w):
t = (x_bound - origin.x) / direction.x
if abs(origin.y + t * direction.y) <= half_h + eps:
results.append(t)
if abs(direction.y) > eps:
for y_bound in (-half_h, half_h):
t = (y_bound - origin.y) / direction.y
if abs(origin.x + t * direction.x) <= half_w + eps:
results.append(t)
return results
@classmethod
def get_section_border_positions(
cls,
camera: bpy.types.Object,
v0_world: Vector,
v1_world: Vector,
border_offset_mm: float,
) -> tuple[Vector, Vector]:
"""Return world-space positions for section endpoints placed at the camera border + border_offset_mm (paper mm)."""
diagram_scale = cls.get_diagram_scale(camera)
if not diagram_scale:
print("[SECTION] get_section_border_positions: no diagram_scale, returning original")
return v0_world, v1_world
scale = cls.get_scale_ratio(diagram_scale["Scale"])
model_offset = (border_offset_mm / 1000.0) / scale
print(f"[SECTION] scale={scale}, border_offset_mm={border_offset_mm}, model_offset={model_offset:.4f}m")
width, height = cls.get_camera_dimensions(camera)
half_w, half_h = width / 2, height / 2
print(f"[SECTION] camera dims: width={width:.3f}, height={height:.3f}, half_w={half_w:.3f}, half_h={half_h:.3f}")
cam_inv = camera.matrix_world.inverted()
v0_local = cam_inv @ v0_world
v1_local = cam_inv @ v1_world
print(f"[SECTION] v0_local={v0_local}, v1_local={v1_local}")
origin = Vector(((v0_local.x + v1_local.x) / 2, (v0_local.y + v1_local.y) / 2))
dir_xy = Vector((v1_local.x - v0_local.x, v1_local.y - v0_local.y))
if dir_xy.length < 1e-6:
print("[SECTION] get_section_border_positions: degenerate edge, returning original")
return v0_world, v1_world
dir_xy = dir_xy.normalized()
z = v0_local.z
print(f"[SECTION] origin={origin}, dir_xy={dir_xy}, z={z:.4f}")
t_values = cls._section_ray_rect_intersections(origin, dir_xy, half_w, half_h)
print(f"[SECTION] ray-rect t_values={t_values}")
pos_ts = sorted(t for t in t_values if t >= 0)
neg_ts = sorted((t for t in t_values if t < 0), reverse=True)
print(f"[SECTION] pos_ts={pos_ts}, neg_ts={neg_ts}")
if not pos_ts or not neg_ts:
print("[SECTION] get_section_border_positions: no valid border intersections, returning original")
return v0_world, v1_world
t_end = pos_ts[0]
t_start = neg_ts[0]
new_v0_local = Vector((
origin.x + (t_start + model_offset) * dir_xy.x,
origin.y + (t_start + model_offset) * dir_xy.y,
z,
))
new_v1_local = Vector((
origin.x + (t_end - model_offset) * dir_xy.x,
origin.y + (t_end - model_offset) * dir_xy.y,
z,
))
return camera.matrix_world @ new_v0_local, camera.matrix_world @ new_v1_local
@classmethod
def update_section_endpoints(cls, obj: bpy.types.Object, camera: bpy.types.Object) -> None:
"""Move section line endpoints to camera border + BorderOffset, skipping any manually moved vertex."""
print(f"[SECTION] update_section_endpoints called: obj={obj.name}, camera={camera.name}")
element = tool.Ifc.get_entity(obj)
if not element:
print("[SECTION] SKIP: no IFC element on obj")
return
if not obj.data or not hasattr(obj.data, "edges") or not obj.data.edges:
print("[SECTION] SKIP: obj has no mesh edges")
return
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {}
border_offset = float(pset_data.get("BorderOffset", 8.0))
print(f"[SECTION] pset_data={pset_data}, border_offset={border_offset}")
if border_offset <= 0:
print("[SECTION] SKIP: BorderOffset <= 0")
return
auto_v0 = cls._parse_vector3(pset_data.get("AutoStartPosition") or "")
auto_v1 = cls._parse_vector3(pset_data.get("AutoEndPosition") or "")
print(f"[SECTION] stored auto_v0={auto_v0}, auto_v1={auto_v1}")
edge = obj.data.edges[0]
v0 = obj.data.vertices[edge.vertices[0]]
v1 = obj.data.vertices[edge.vertices[1]]
v0_world = obj.matrix_world @ v0.co
v1_world = obj.matrix_world @ v1.co
print(f"[SECTION] current v0_world={v0_world}, v1_world={v1_world}")
# A vertex is "auto" if it has never been auto-positioned, or still sits at the stored auto position.
v0_is_auto = auto_v0 is None or (v0_world - auto_v0).length < 1e-4
v1_is_auto = auto_v1 is None or (v1_world - auto_v1).length < 1e-4
print(f"[SECTION] v0_is_auto={v0_is_auto}, v1_is_auto={v1_is_auto}")
if not v0_is_auto and not v1_is_auto:
print("[SECTION] SKIP: both vertices are manually overridden")
return
new_v0_world, new_v1_world = cls.get_section_border_positions(camera, v0_world, v1_world, border_offset)
print(f"[SECTION] new_v0_world={new_v0_world}, new_v1_world={new_v1_world}")
if v0_is_auto:
v0.co = obj.matrix_world.inverted() @ new_v0_world
if v1_is_auto:
v1.co = obj.matrix_world.inverted() @ new_v1_world
obj.data.update()
stored_v0 = new_v0_world if v0_is_auto else v0_world
stored_v1 = new_v1_world if v1_is_auto else v1_world
pset_id = pset_data.get("id")
if pset_id:
pset_entity = tool.Ifc.get().by_id(pset_id)
else:
pset_entity = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Section")
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(),
pset=pset_entity,
properties={
"BorderOffset": border_offset,
"AutoStartPosition": cls._format_vector3(stored_v0),
"AutoEndPosition": cls._format_vector3(stored_v1),
},
)
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="")
print(f"[SECTION] done. stored auto_v0={cls._format_vector3(stored_v0)}, auto_v1={cls._format_vector3(stored_v1)}")
@staticmethod
def _parse_vector3(s: str) -> Optional[Vector]:
try:
x, y, z = map(float, s.split(","))
return Vector((x, y, z))
except Exception:
return None
@staticmethod
def _format_vector3(v: Vector) -> str:
return f"{v.x:.6f},{v.y:.6f},{v.z:.6f}"
@classmethod
def get_diagram_scale(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> dict[str, str]:
props = cls.get_camera_props(camera)
+19 -5
View File
@@ -970,18 +970,31 @@ class Raycast(bonsai.core.tool.Raycast):
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
return None
for i, snap_obj in enumerate(cls.snap_objs):
if obj.name == snap_obj.obj.name:
# Handle objects modified while a modal operator is active.
# Example: adding a door or window alters the wall geometry.
try:
cached_name = snap_obj.obj.name
except ReferenceError:
cls.snap_objs.pop(i)
break
if obj.name == cached_name:
# Fast O(1) invalidation: vertex count change (mesh edit) or
# world matrix change (object moved/rotated).
if len(obj.data.vertices) != len(snap_obj.verts_3d):
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
if (obj.matrix_world @ v1.co) != v2:
return snap_obj
if obj.matrix_world != snap_obj.matrix_world:
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
# Sample one vertex to catch mesh edits that preserve vertex count.
if obj.data.vertices and snap_obj.verts_3d:
if (obj.matrix_world @ obj.data.vertices[0].co) != snap_obj.verts_3d[0]:
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
return snap_obj
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
@@ -1020,6 +1033,7 @@ class SnapObj:
self.root = None
self._bvh_built = False
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.matrix_world = obj.matrix_world.copy()
self.snap_points = []
def _ensure_bvh(self):
+8 -2
View File
@@ -94,9 +94,15 @@ def list_functions(module: str) -> list[dict]:
def function_docs(module: str, function: str) -> dict:
"""Full documentation for a single API function.
"""Show the full documentation for one ifcopenshell.api function.
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
Returns the summary and long description, every parameter with its type,
default and description, and the return type. Read this before calling
``run_api()`` so that parameter names and value types are correct.
:param module: API module name, for example ``'root'``.
:param function: Function name within the module, for example
``'create_entity'``.
"""
fn = _get_underlying_function(module, function)
if fn is None:
+14 -3
View File
@@ -14,10 +14,21 @@ def list_rules() -> list[dict[str, str]]:
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
"""Compute base quantities for elements and write them into the model.
Modifies the model in-place by adding/updating IfcElementQuantity psets.
Returns a summary dict with ok, rule, and elements_quantified.
This is a write operation: it derives lengths, areas and volumes from
element geometry and adds or updates their ``IfcElementQuantity`` sets.
It does not report a schedule see ``ifcquery.schedule()`` for the
construction programme and ``ifcquery.cost()`` for cost schedules. An
unrecognised ``rule`` is reported as an error listing the rules that are
available.
:param model: The in-memory IFC model. Modified in-place.
:param rule: Quantity take-off rule set, for example
``'IFC4QtoBaseQuantities'`` or ``'IFC4X3QtoBaseQuantities'``.
:param selector: ifcopenshell selector restricting which elements are
measured, e.g. ``'IfcWall'``. Omit to measure every ``IfcElement`` and
``IfcSpace``.
"""
from ifc5d.qto import edit_qtos, quantify
from ifc5d.qto import rules as rule_sets
+133 -45
View File
@@ -35,6 +35,27 @@ from ifcquery import (
from ifcquery import validate as validate_mod
def _use_doc(source: Callable, extra: str = "") -> Callable:
"""Decorator: copy `source`'s docstring onto the decorated method.
Keeps the query/edit logic in ``ifcquery``/``ifcedit`` as the single
source of truth for what a delegating ``IfcSession`` method does, rather
than maintaining a second prose description here. Only ``__doc__`` is
copied unlike `functools.wraps`, this leaves the method's own signature
(and MCP tool schema derived from it) untouched.
:param extra: Optional session-specific note appended after `source`'s
docstring, for the handful of methods that translate an argument
(e.g. a JSON/MCP-friendly default) before delegating.
"""
def decorator(fn: Callable) -> Callable:
fn.__doc__ = (source.__doc__ or "").rstrip() + extra
return fn
return decorator
def _jsonify(x: Any) -> Any:
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
if x is None or isinstance(x, (str, int, float, bool)):
@@ -231,20 +252,47 @@ class IfcSession:
return self.model
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
"""Create a new empty IFC model in memory."""
"""Create a new empty IFC model in memory.
Replaces the model currently held by the session, discarding any unsaved
edits. The new model has no file path of its own, so ``ifc_save`` must be
given an explicit path.
:param schema: IFC schema version ``IFC2X3``, ``IFC4``, ``IFC4X1``,
``IFC4X2`` or ``IFC4X3`` passed straight to ``ifcopenshell.file()``
(default ``IFC4``). ``IFC4X3_ADD2`` is also accepted and, like
``IFC4X3``, produces a model whose ``schema`` reports ``IFC4X3``.
"""
self.model = ifcopenshell.file(schema=schema)
self.model_path = None
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
def ifc_load(self, path: str) -> str:
"""Open an IFC file into memory. Returns confirmation string."""
"""Open an IFC file from disk into the session.
Replaces the model currently held by the session, discarding any unsaved
edits, and remembers the path so a later ``ifc_save`` can overwrite it.
Call this before any query or edit method. Returns a confirmation string
naming the schema version and entity count.
:param path: Filesystem path of the IFC file to open.
"""
self.model = ifcopenshell.open(path)
self.model_path = path
count = sum(1 for _ in self.model)
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
def ifc_save(self, path: str = "") -> str:
"""Write the in-memory model to disk. Empty path overwrites the original file."""
"""Write the in-memory model to disk.
Overwrites the target file without further confirmation. Edits made by
``ifc_edit``, ``ifc_shape`` and ``ifc_quantify`` exist only in memory
until this is called.
:param path: Destination path. Omit to overwrite the file the model was
loaded from; this fails for a model created by ``ifc_new``, which has
no original path.
"""
model = self._require_model()
target = path if path else self.model_path
if not target:
@@ -253,7 +301,11 @@ class IfcSession:
return f"Saved to {target}"
def ifc_reset(self) -> dict[str, Any]:
"""Drop the in-memory model."""
"""Discard the in-memory model.
Drops the model and its file path, throwing away any edits not already
written with ``ifc_save``. Succeeds even when no model is loaded.
"""
self.model = None
self.model_path = None
return {"ok": True}
@@ -261,39 +313,42 @@ class IfcSession:
# -------------
# Query tools
# -------------
@_use_doc(summary.summary)
def ifc_summary(self) -> dict[str, Any]:
"""Model overview: schema, entity counts, project info."""
return summary.summary(self._require_model())
@_use_doc(tree.tree)
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
return tree.tree(self._require_model())
@_use_doc(info.info)
def ifc_info(self, element_id: int) -> dict[str, Any]:
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return info.info(model, element)
@_use_doc(select.select)
def ifc_select(self, query: str) -> list[dict[str, Any]]:
"""Filter elements using ifcopenshell selector syntax.
Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``,
``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``,
``material = "Concrete"``.
"""
return select.select(self._require_model(), query)
@_use_doc(relations.relations)
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return relations.relations(model, element, traverse=traverse if traverse else None)
@_use_doc(
clash_mod.clash,
extra=(
"\n\nNote: this method takes a plain ``clearance: float`` rather than\n"
'``clearance: float | None`` — ``0.0`` (the default) means "skip the\n'
'clearance check", matching ``None`` in ``ifcquery.clash.clash()``.'
),
)
def ifc_clash(
self,
element_id: int,
@@ -301,7 +356,6 @@ class IfcSession:
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
@@ -314,33 +368,53 @@ class IfcSession:
scope=scope,
)
@_use_doc(contexts_mod.contexts)
def ifc_contexts(self) -> list[dict[str, Any]]:
"""List all geometric representation contexts and subcontexts with their step IDs."""
return contexts_mod.contexts(self._require_model())
@_use_doc(materials_mod.materials)
def ifc_materials(self) -> list[dict[str, Any]]:
"""List all materials and material sets (layers, constituents, profiles)."""
return materials_mod.materials(self._require_model())
# ------------------------
# Edit discovery + execute
# ------------------------
def ifc_list(self, module: str = "") -> list[dict]:
"""List all API modules, or functions within a module. Empty module = all modules."""
"""Discover the ifcopenshell.api functions available for editing.
With no argument returns every API module with its description,
function names and function count. With a module name returns that
module's functions, each with a one-line description and its
parameters. This is the starting point for ``ifc_docs`` and
``ifc_edit``; it inspects the installed ifcopenshell package and works
without a model loaded.
:param module: API module name, for example ``'root'``, ``'geometry'``
or ``'pset'``. Omit to list all modules.
"""
return list_functions(module) if module else list_modules()
@_use_doc(function_docs)
def ifc_docs(self, function_path: str) -> dict:
"""Show full documentation for an API function. Input format: 'module.function'."""
module, function = function_path.split(".", 1)
return function_docs(module, function)
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
"""Execute an ifcopenshell.api mutation.
"""Run an ifcopenshell.api function to modify the model.
params may be:
- JSON string
- dict (from tool calling / JS)
- JsProxy (handled upstream in embedded.py)
This is the general-purpose edit method; use ``ifc_list`` and
``ifc_docs`` first to find the function and its parameters. Changes
are made to the in-memory model only, so ``ifc_save`` is needed to
persist them. Returns ``{"ok": True, "result": ...}``, or
``{"ok": False, "error": ...}`` when the function is unknown, a
parameter cannot be converted, or the call raises.
:param function_path: ``'module.function'``, for example
``'root.create_entity'``.
:param params: Keyword arguments as a JSON string, a dict (tool
calling) or a JsProxy (handled upstream in embedded.py). Pass
entity references as integer step IDs, and arguments typed as an
IFC file as a file path string.
"""
model = self._require_model()
module, function = function_path.split(".", 1)
@@ -359,28 +433,20 @@ class IfcSession:
# ------------------------
# Extended query + edit tools
# ------------------------
@_use_doc(validate_mod.validate)
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
return validate_mod.validate(self._require_model(), express_rules=express_rules)
@_use_doc(schedule.schedule)
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
"""List work schedules and nested tasks from the model.
max_depth limits subtask expansion (None = unlimited). At the cutoff,
subtasks is replaced with {"truncated": True, "count": N}.
"""
return schedule.schedule(self._require_model(), max_depth=max_depth)
@_use_doc(cost_mod.cost)
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
"""List cost schedules and nested cost items from the model.
max_depth limits cost item expansion (None = unlimited). At the cutoff,
subitems is replaced with {"truncated": True, "count": N}.
"""
return cost_mod.cost(self._require_model(), max_depth=max_depth)
@_use_doc(schema.schema)
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type using the model's schema version."""
return schema.schema(self._require_model(), entity_type)
def ifc_plot(
@@ -456,18 +522,43 @@ class IfcSession:
# Shape builder tools
# ------------------------
def ifc_shape_list(self) -> list[dict]:
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
"""List the ShapeBuilder methods available for constructing geometry.
Returns every public ``ifcopenshell.util.shape_builder.ShapeBuilder``
method with a one-line description and its parameter names, read
directly from that class's own docstrings. Use it to find a method,
then ``ifc_shape_docs`` for the details and ``ifc_shape`` to call it.
Works without a model loaded.
"""
return _list_shape_methods()
def ifc_shape_docs(self, method: str) -> dict:
"""Full documentation for a ShapeBuilder method: params, types, return value."""
"""Show the full documentation for one ShapeBuilder method.
Returns the summary and long description, every parameter with its
type and default, and the return type read directly from
``ShapeBuilder``'s own docstring. Read this before ``ifc_shape`` so
that argument names and value shapes are correct. Works without a
model loaded.
:param method: ShapeBuilder method name, for example ``'polyline'``,
``'rectangle'`` or ``'extrude'``.
"""
return _shape_method_docs(method)
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
"""Call a ShapeBuilder method to build geometry in the model.
params is a JSON string of keyword arguments. Pass entity references as integer
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
The created entities are added to the in-memory model, so
``ifc_save`` is needed to persist them. On success the result
identifies the created entity by step ID and type; an unknown method
or a failed call is reported as an error instead.
:param method: ShapeBuilder method name, as listed by
``ifc_shape_list``.
:param params: JSON string of keyword arguments. Pass entity
references as integer step IDs and vectors as JSON arrays, e.g.
``[1.0, 0.0, 0.0]``.
"""
model = self._require_model()
@@ -493,11 +584,8 @@ class IfcSession:
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
@_use_doc(run_quantify, extra="\n\nCall ``ifc_save`` afterwards to persist the result.")
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
Modifies the model in-place; call ifc_save() after.
"""
model = self._require_model()
return run_quantify(model, rule, selector=selector if selector else None)
+29 -23
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import base64
import inspect
from typing import Any
from ifcmcp.core import IfcSession
@@ -23,6 +24,11 @@ def build_server() -> Any:
session = IfcSession()
def _tool(fn):
"""Register a tool, taking its MCP description from the identically-named
IfcSession method rather than duplicating it here."""
return server.tool(description=inspect.getdoc(getattr(IfcSession, fn.__name__)))(fn)
server = FastMCP(
name="ifc-mcp",
instructions=(
@@ -33,44 +39,44 @@ def build_server() -> Any:
)
# ---- Lifecycle ----
@server.tool()
@_tool
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
return session.ifc_new(schema=schema)
@server.tool()
@_tool
def ifc_load(path: str) -> str:
return session.ifc_load(path)
@server.tool()
@_tool
def ifc_save(path: str = "") -> str:
return session.ifc_save(path)
@server.tool()
@_tool
def ifc_reset() -> dict[str, Any]:
return session.ifc_reset()
# ---- Query ----
@server.tool()
@_tool
def ifc_summary() -> dict[str, Any]:
return session.ifc_summary()
@server.tool()
@_tool
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_tree()
@server.tool()
@_tool
def ifc_info(element_id: int) -> dict[str, Any]:
return session.ifc_info(element_id)
@server.tool()
@_tool
def ifc_select(query: str) -> list[dict[str, Any]]:
return session.ifc_select(query)
@server.tool()
@_tool
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_relations(element_id, traverse=traverse)
@server.tool()
@_tool
def ifc_clash(
element_id: int,
clearance: float = 0.0,
@@ -84,58 +90,58 @@ def build_server() -> Any:
scope=scope,
)
@server.tool()
@_tool
def ifc_contexts() -> list[dict[str, Any]]:
return session.ifc_contexts()
@server.tool()
@_tool
def ifc_materials() -> list[dict[str, Any]]:
return session.ifc_materials()
# ---- Edit ----
@server.tool()
@_tool
def ifc_list(module: str = "") -> list[dict]:
return session.ifc_list(module=module)
@server.tool()
@_tool
def ifc_docs(function_path: str) -> dict:
return session.ifc_docs(function_path=function_path)
@server.tool()
@_tool
def ifc_edit(function_path: str, params: str = "{}") -> dict:
return session.ifc_edit(function_path=function_path, params=params)
# ---- Extended query + edit ----
@server.tool()
@_tool
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
return session.ifc_validate(express_rules=express_rules)
@server.tool()
@_tool
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_schedule(max_depth=max_depth)
@server.tool()
@_tool
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_cost(max_depth=max_depth)
@server.tool()
@_tool
def ifc_schema(entity_type: str) -> dict[str, Any]:
return session.ifc_schema(entity_type=entity_type)
@server.tool()
@_tool
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
return session.ifc_quantify(rule=rule, selector=selector)
# ---- Shape builder ----
@server.tool()
@_tool
def ifc_shape_list() -> list[dict]:
return session.ifc_shape_list()
@server.tool()
@_tool
def ifc_shape_docs(method: str) -> dict:
return session.ifc_shape_docs(method=method)
@server.tool()
@_tool
def ifc_shape(method: str, params: str = "{}") -> dict:
return session.ifc_shape(method=method, params=params)
+6
View File
@@ -30,6 +30,12 @@ class TestServerRegistration:
for name in expected:
assert name in tools, f"Tool {name} not registered"
def test_all_tools_have_descriptions(self):
server = build_server()
tools = server._tool_manager.list_tools()
missing = [t.name for t in tools if not (t.description or "").strip()]
assert not missing, f"Tools with no description: {missing}"
@pytest.fixture
def tool_fns():
@@ -79,7 +79,7 @@ from .get_layout_curve import get_layout_curve
from .get_layout_segments import get_layout_segments
from .get_mapped_segments import get_mapped_segments
from .get_parent_alignment import get_parent_alignment
from .get_referent_nest import get_referent_nest
from .get_stationing_nest import get_stationing_nest
from .get_vertical_layout import get_vertical_layout
from .has_zero_length_segment import has_zero_length_segment
from .layout_horizontal_alignment_by_pi_method import (
@@ -91,6 +91,7 @@ from .layout_vertical_alignment_by_pi_method import (
from .name_segments import name_segments
from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position
from .update_key_point_referents import update_key_point_referents
from .util import *
__all__ = [
@@ -124,7 +125,7 @@ __all__ = [
"get_layout_curve",
"get_layout_segments",
"get_parent_alignment",
"get_referent_nest",
"get_stationing_nest",
"get_vertical_layout",
"has_zero_length_segment",
"layout_horizontal_alignment_by_pi_method",
@@ -133,5 +134,6 @@ __all__ = [
"register_referent_name_callback",
"update_end_point",
"update_fallback_position",
"update_key_point_referents",
"get_mapped_segments",
]
@@ -26,9 +26,10 @@ _cant_callback = None
def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
"""
Referents are automatically created at the start of each horizontal, vertical, and cant segment.
The referents represent key points in the alignment layout such as Point of Curvature, Point of Tangent, and others.
Different juristicions use different naming systems for these key points.
Referents are created at the start of each horizontal, vertical, and cant segment by
ifcopenshell.api.alignment.update_key_point_referents. The referents represent key points in the
alignment layout such as Point of Curvature, Point of Tangent, and others. Different
juristicions use different naming systems for these key points.
The referent name callback functions provide a customizable method for naming these referents. If a callback is registered,
it is called when creating the referent name, otherwise the default naming is used.
@@ -39,8 +40,8 @@ def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
The callback function returns a string that is used in the referent name for the referent at the start of `segment`.
The callback must accomodate the following cases:
* prev_segment = None and segment != None - this indicates the last segment so the "End of Alignment" name is returned
* prev_segment != None and segment == None - this indicates the first segment so the "Beginning of Alignment" name is returned
* prev_segment = None and segment != None - this indicates the first segment so the "Beginning of Alignment" name is returned
* prev_segment != None and segment == None - this indicates the last segment so the "End of Alignment" name is returned
* prev_segment != None and segment != None - this indicates an intermediate segment so a name representitive of the transition is returned
Setting any or all of the callbacks to None causes the default naming to be used.
@@ -0,0 +1,27 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from typing import Callable
from ifcopenshell import entity_instance
def _sort_nest(nest: entity_instance, key: Callable) -> entity_instance:
"""Sorts the RelatedObjects of an IfcRelNests in place, by an arbitrary key function."""
nest.RelatedObjects = sorted(nest.RelatedObjects, key=key)
return nest
@@ -20,6 +20,7 @@ from typing import Optional
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment._sort_nest import _sort_nest
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
import ifcopenshell.api.pset
import ifcopenshell.guid
@@ -114,7 +115,7 @@ def add_stationing_referent(
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
if nest is None:
nest = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,)
@@ -122,8 +123,6 @@ def add_stationing_referent(
else:
nest.RelatedObjects += (referent,)
nest.RelatedObjects = sorted(
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
)
_sort_nest(nest, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
return referent
@@ -64,22 +64,22 @@ def create_representation(
# if the alignment is created without geometry it's stationing referent isn't related to the alignment geometry.
# the stationing referent needs to be updated to have an IfcLinearPlacement that references the basis curve geometry
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
if (
referent_nest
and 0 < len(referent_nest.RelatedObjects)
and referent_nest.RelatedObjects[0].ObjectPlacement
and not referent_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
stationing_nest
and 0 < len(stationing_nest.RelatedObjects)
and stationing_nest.RelatedObjects[0].ObjectPlacement
and not stationing_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
):
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
if referent_nest.RelatedObjects[0].ObjectPlacement:
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement)
if stationing_nest.RelatedObjects[0].ObjectPlacement:
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement)
lp = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
@@ -93,4 +93,4 @@ def create_representation(
)
)
update_fallback_position(file, lp)
referent_nest.RelatedObjects[0].ObjectPlacement = lp
stationing_nest.RelatedObjects[0].ObjectPlacement = lp
@@ -67,8 +67,8 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
print(dist_along) # 100.00
"""
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
if referent_nest is None:
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
if stationing_nest is None:
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
return station - start_station
@@ -77,7 +77,7 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
_distance_along_of_referent(referent),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
)
for referent in referent_nest.RelatedObjects
for referent in stationing_nest.RelatedObjects
]
stations.sort(key=lambda entry: entry[0])
@@ -20,12 +20,18 @@ import ifcopenshell
from ifcopenshell import entity_instance
def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
def get_stationing_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
"""
Searches for the IfcRelNest that contains IfcReferent.
Searches for the IfcRelNests that defines the alignment's stationing scheme.
The returned nest is nested to the IfcAlignment and its RelatedObjects contains only the
IfcReferent(s) (PredefinedType="STATION") that establish the alignment's starting station and
any station equations along it, as created by add_stationing_referent. It does not contain any
other kind of referent (e.g. key-point referents from update_key_point_referents live in their
own, separate IfcRelNests).
:param file:
:param alignment: The IfcAlignment which hosts IfcReferent
:param alignment: The IfcAlignment which hosts the stationing IfcReferent(s)
:return: Returns the IfcRelNests or None
"""
if not alignment.is_a("IfcAlignment"):
@@ -0,0 +1,222 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.pset
import ifcopenshell.guid
import ifcopenshell.util.alignment
import ifcopenshell.util.element
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._sort_nest import _sort_nest
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
def _get_key_point_referent_nest(layout: entity_instance) -> Optional[entity_instance]:
"""
Searches layout.IsNestedBy for the IfcRelNests whose RelatedObjects are IfcReferent.
This is distinct from both get_stationing_nest (scoped to the parent IfcAlignment, and
specifically the STATION/station-equation nest) and get_alignment_segment_nest (the *segment*
nest that also lives on layout.IsNestedBy, holding IfcAlignmentSegment, never IfcReferent).
"""
for nest in layout.IsNestedBy:
for related_object in nest.RelatedObjects:
if related_object.is_a("IfcReferent"):
return nest
return None
def _remove_referent(file: ifcopenshell.file, referent: entity_instance) -> None:
"""Cleanly deletes a key-point IfcReferent: its Pset_Stationing, its ObjectPlacement (if
exclusively owned by it), and finally the referent itself."""
for inverse in list(file.get_inverse(referent)):
if inverse.is_a("IfcRelDefinesByProperties"):
ifcopenshell.api.pset.remove_pset(file, product=referent, pset=inverse.RelatingPropertyDefinition)
object_placement = referent.ObjectPlacement
if object_placement and file.get_total_inverses(object_placement) == 1:
referent.ObjectPlacement = None
ifcopenshell.util.element.remove_deep2(file, object_placement)
file.remove(referent) # also strips referent out of any IfcRelNests.RelatedObjects referencing it
def _create_key_point_referent(
file: ifcopenshell.file,
alignment: entity_instance,
curve: Optional[entity_instance],
label: str,
distance_along: float,
station: float,
) -> entity_instance:
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
DistanceAlong=file.createIfcLengthMeasure(distance_along),
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=curve,
)
),
)
update_fallback_position(file, object_placement)
else:
object_placement = file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
),
)
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
referent = file.createIfcReferent(
GlobalId=ifcopenshell.guid.new(),
OwnerHistory=None,
Name=name,
Description=None,
ObjectType=None,
ObjectPlacement=object_placement,
Representation=None,
PredefinedType="POSITION",
)
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
return referent
def update_key_point_referents(
file: ifcopenshell.file,
layout: entity_instance,
rel_nests: Optional[entity_instance] = None,
clear: bool = False,
) -> entity_instance:
"""
Creates IfcReferent key-point markers for every segment transition in an alignment layout.
Labels are derived from _get_segment_start_point_label (e.g. "P.C.", "P.T.", "P.O.B.",
"P.V.C.", ...), with the station appended, e.g. "P.C. (145+98.32)". Different jurisdictions use
different naming systems for these key points -- register_referent_name_callback() lets a
caller override the default horizontal/vertical/cant labeling before calling this function; if
a callback is registered, its output is used here instead of the built-in labels. Referents are
nested to `rel_nests`, an IfcRelNests distinct from the layout's segment nest (found via
get_alignment_segment_nest) and from the alignment's stationing nest (found via
get_stationing_nest) -- key-point referents never belong in either of those.
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
:param rel_nests: an existing IfcRelNests to (re)populate. May live anywhere (e.g. the parent
IfcAlignment, the layout, or elsewhere) -- the caller decides. If omitted, an existing
referent-nest already on `layout` is reused, or a new one is created and related to `layout`.
:param clear: if True, deletes all IfcReferent currently in rel_nests.RelatedObjects (and their
Pset_Stationing) before regenerating. If False (default), new referents are appended to
whatever already exists -- no deduplication.
:return: the IfcRelNests, with RelatedObjects sorted ascending by Pset_Stationing.Station
Example:
.. code:: python
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
Example, with custom labels for a jurisdiction that doesn't use the built-in abbreviations:
.. code:: python
def my_horizontal_labels(prev_segment, segment):
if prev_segment is None:
return "Start"
if segment is None:
return "End"
return "Curve Point" # a name representative of the prev_segment -> segment transition
ifcopenshell.api.alignment.register_referent_name_callback(horizontal=my_horizontal_labels)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
# nest.RelatedObjects[0].Name starts with "Start (" instead of the default "P.O.B. ("
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if not layout.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
if rel_nests is None:
rel_nests = _get_key_point_referent_nest(layout)
if rel_nests is None:
rel_nests = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=layout, RelatedObjects=()
)
if clear:
for referent in list(rel_nests.RelatedObjects):
_remove_referent(file, referent)
rel_nests.RelatedObjects = ()
segments = list(ifcopenshell.api.alignment.get_layout_segments(layout))
if segments and ifcopenshell.api.alignment.has_zero_length_segment(layout):
segments = segments[:-1]
if not segments:
_sort_nest(
rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
)
return rel_nests
alignment = ifcopenshell.api.alignment.get_alignment(layout)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
new_referents = []
distance_along = 0.0
prev_segment = None
for segment in segments:
dp = segment.DesignParameters
seg_distance_along = distance_along if is_horizontal else dp.StartDistAlong
label = _get_segment_start_point_label(prev_segment, segment)
station = start_station + seg_distance_along
new_referents.append(_create_key_point_referent(file, alignment, curve, label, seg_distance_along, station))
if is_horizontal:
distance_along += dp.SegmentLength
else:
distance_along = dp.StartDistAlong + dp.HorizontalLength
prev_segment = segment
label = _get_segment_start_point_label(prev_segment, None)
station = start_station + distance_along
new_referents.append(_create_key_point_referent(file, alignment, curve, label, distance_along, station))
rel_nests.RelatedObjects = tuple(rel_nests.RelatedObjects) + tuple(new_referents)
_sort_nest(rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
return rel_nests
@@ -25,12 +25,25 @@ annotations may have relationships which indicate smart data being populated.
from .. import wrap_usecases
from .assign_product import assign_product
from .edit_text_literal import edit_text_literal
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths
from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_local_point, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor
from .unassign_product import unassign_product
wrap_usecases(__path__, __name__)
__all__ = [
"assign_product",
"build_anchor_from_hit",
"build_anchor_from_layer_boundary",
"build_anchor_from_local_point",
"build_anchor_from_profile_edge",
"build_anchor_from_profile_vert",
"edit_text_literal",
"get_dimension_segment_lengths",
"get_layer_snap_candidates",
"get_profile_snap_candidates",
"make_world_anchor",
"regenerate_dimension",
"resolve_anchor",
"unassign_product",
]
@@ -0,0 +1,394 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors.
This module operates purely on IFC data. It:
1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an
``IfcAnnotation``.
2. Resolves each anchor to a world-space point (IFC project units) using
``resolve_anchor``.
3. Computes per-segment distances and updates (or creates) the linked
``IfcMetric`` + ``IfcRelAssociatesConstraint`` entities.
4. Returns the ordered list of resolved world-space points so that the
Bonsai operator layer can update the Blender curve object.
Updating the Blender curve (converting IFC world coords annotation local
coords) is the *caller's* responsibility and does **not** happen here.
"""
from __future__ import annotations
import json
import math
from typing import Optional
import ifcopenshell
import ifcopenshell.api.owner
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.guid
import ifcopenshell.util.element
from .resolve_anchor import resolve_anchor
_PSET_NAME = "BBIM_Dimension"
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
def regenerate_dimension(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
camera_dir: Optional[tuple[float, float, float]] = None,
) -> list[tuple[float, float, float]]:
"""Regenerate a parametric dimension from its stored anchor references.
Resolves every anchor in ``BBIM_Dimension.Anchors``, updates the
per-segment ``IfcMetric`` values (creating them when absent), and returns
the resolved world-space points in metres.
:param file: The open IFC file.
:param annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` pset.
:param settings: Geometry settings for tessellation (shared across calls).
:param shape_cache: Shape cache dict (shared across calls for performance).
:param placement_override: Optional dict mapping element STEP id 4×4 numpy
matrix (metres, row-major). Pass ``{elem.id(): np.array(obj.matrix_world)}``
for each referenced element so that viewport moves not yet synced to the
IFC ``ObjectPlacement`` are reflected. See ``resolve_anchor`` for details.
:return: Ordered list of ``(x, y, z)`` tuples, one per anchor.
Empty list if the pset is missing or malformed.
"""
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
if not pset_data or "Anchors" not in pset_data:
return []
try:
anchors: list[dict] = json.loads(pset_data["Anchors"])
except (json.JSONDecodeError, TypeError):
return []
if not anchors:
return []
if shape_cache is None:
shape_cache = {}
resolved: list[Optional[tuple]] = []
for anchor in anchors:
pt = resolve_anchor(file, anchor, settings, shape_cache, placement_override)
if pt is None:
pt = tuple(anchor["pt"]) if anchor.get("pt") else (0.0, 0.0, 0.0)
resolved.append(pt)
anchor["pt"] = list(pt)
# ForcePerpendicularToFace: project vertices 1…n onto the line through
# pt[0] in the direction of anchor[0]'s face normal, so the polyline is
# constrained perpendicular to the face the first vertex is anchored to.
if pset_data.get("ForcePerpendicularToFace") and len(resolved) >= 2 and resolved[0] is not None:
normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
if normal:
base = resolved[0]
for i in range(1, len(resolved)):
if resolved[i] is None:
continue
pt = resolved[i]
t = ((pt[0] - base[0]) * normal[0]
+ (pt[1] - base[1]) * normal[1]
+ (pt[2] - base[2]) * normal[2])
resolved[i] = (base[0] + t * normal[0],
base[1] + t * normal[1],
base[2] + t * normal[2])
anchors[i]["pt"] = list(resolved[i])
pset_entity_id = pset_data.get("id")
if pset_entity_id:
pset_entity = file.by_id(pset_entity_id)
ifcopenshell.api.pset.edit_pset(
file,
pset=pset_entity,
properties={"Anchors": json.dumps(anchors)},
)
n_segments = len(resolved) - 1
if n_segments >= 1:
existing_metrics = _get_segment_metrics(file, annotation)
_sync_segment_metrics(file, annotation, resolved, existing_metrics)
# LinePosition: project all points to a fixed absolute world coordinate along the
# horizontal offset axis (perpendicular to the dimension direction). Applied after
# the pset write so anchor["pt"] always stores the true geometry surface hit.
# Because it is absolute, the dimension line stays put even if the geometry moves.
line_position = pset_data.get("LinePosition")
if line_position is not None and resolved:
face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None], camera_dir)
if offset_dir:
resolved = [
_project_to_line_position(pt, offset_dir, float(line_position)) if pt is not None else None
for pt in resolved
]
return [pt for pt in resolved if pt is not None]
def get_dimension_segment_lengths(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
) -> list[float]:
"""Return the segment lengths for a parametric dimension from stored anchor pts.
Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors``
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
is absent or malformed.
"""
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
if not pset_data or not pset_data.get("Anchors"):
return []
try:
anchors: list[dict] = json.loads(pset_data["Anchors"])
except Exception:
return []
lengths: list[float] = []
for i in range(len(anchors) - 1):
pt_a = anchors[i].get("pt")
pt_b = anchors[i + 1].get("pt")
if pt_a and pt_b:
lengths.append(_dist(tuple(pt_a), tuple(pt_b)))
else:
lengths.append(0.0)
return lengths
# ---------------------------------------------------------------------------
# IfcMetric / IfcRelAssociatesConstraint management
# ---------------------------------------------------------------------------
def _get_segment_metrics(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
) -> dict[int, ifcopenshell.entity_instance]:
"""Return {segment_index: IfcMetric} for all constraint rels on the annotation."""
metrics: dict[int, ifcopenshell.entity_instance] = {}
for rel in annotation.HasAssociations:
if not rel.is_a("IfcRelAssociatesConstraint"):
continue
intent: str = rel.Intent or ""
if not intent.startswith(_METRIC_INTENT_PREFIX):
continue
try:
seg_idx = int(intent[len(_METRIC_INTENT_PREFIX):])
except ValueError:
continue
constraint = rel.RelatingConstraint
if constraint.is_a("IfcMetric"):
metrics[seg_idx] = constraint
return metrics
def _sync_segment_metrics(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
resolved_pts: list[tuple],
existing: dict[int, ifcopenshell.entity_instance],
) -> None:
"""Create missing and update existing IfcMetric entities for each segment."""
n_segments = len(resolved_pts) - 1
seen_guids: set[str] = set()
# Build a lookup of which elements are at each anchor endpoint
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
anchors: list[dict] = []
if pset_data and pset_data.get("Anchors"):
try:
anchors = json.loads(pset_data["Anchors"])
except Exception:
pass
for seg_idx in range(n_segments):
if seg_idx in existing:
pass # metric already exists; association is still valid
else:
# Create new IfcMetric + IfcRelAssociatesConstraint
# DataValue is IfcMetricValueSelect (entity-only SELECT in IFC4) — omit it;
# the measured distance is derivable from the anchor pt fields.
metric = file.create_entity(
"IfcMetric",
Name=f"seg_{seg_idx}",
ConstraintGrade="ADVISORY",
Benchmark="EQUALTO",
)
# Gather related products for this segment (the two anchor elements)
related: list[ifcopenshell.entity_instance] = [annotation]
for anchor_idx in (seg_idx, seg_idx + 1):
if anchor_idx < len(anchors):
guid = anchors[anchor_idx].get("guid")
if guid and guid not in seen_guids:
try:
elem = file.by_guid(guid)
related.append(elem)
seen_guids.add(guid)
except Exception:
pass
file.create_entity(
"IfcRelAssociatesConstraint",
GlobalId=ifcopenshell.guid.new(),
OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
Intent=f"{_METRIC_INTENT_PREFIX}{seg_idx}",
RelatingConstraint=metric,
RelatedObjects=related,
)
# Remove orphaned metrics for segments that no longer exist
for seg_idx, metric in existing.items():
if seg_idx >= n_segments:
for rel in file.get_inverse(metric):
if rel.is_a("IfcRelAssociatesConstraint"):
file.remove(rel)
file.remove(metric)
def _dist(a: tuple, b: tuple) -> float:
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
def _project_to_line_position(
pt: tuple, offset_dir: tuple, target: float
) -> tuple[float, float, float]:
"""Shift *pt* along *offset_dir* so its projection onto that axis equals *target*.
Keeps every other component of the point unchanged, so only the dimension line
is repositioned the measured length stays the same.
"""
current = pt[0] * offset_dir[0] + pt[1] * offset_dir[1] + pt[2] * offset_dir[2]
delta = target - current
return (
pt[0] + delta * offset_dir[0],
pt[1] + delta * offset_dir[1],
pt[2] + delta * offset_dir[2],
)
def _get_anchor_face_normal_world(
file: ifcopenshell.file,
anchor: dict,
placement_override: Optional[dict] = None,
) -> Optional[tuple[float, float, float]]:
"""Return the world-space unit face normal stored in a FACE anchor, or None.
Reads ``normal_local`` (element-local, rotation-invariant) from the anchor
addr and rotates it to world space via the current element placement.
Also accepts the legacy ``addr.fingerprint.normal_local`` format.
"""
if anchor.get("type") != "FACE":
return None
guid = anchor.get("guid")
if not guid:
return None
try:
element = file.by_guid(guid)
except Exception:
return None
addr = anchor.get("addr") or {}
from .resolve_anchor import _rotate_local_to_world
if addr.get("method") == "LAYER_BOUNDARY":
import ifcopenshell.util.element as _ifc_elem
usage = _ifc_elem.get_material(element, should_inherit=True)
if not usage or not usage.is_a("IfcMaterialLayerSetUsage"):
return None
axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2")
if axis == "AXIS1":
normal_local: tuple = (1.0, 0.0, 0.0)
elif axis == "AXIS3":
normal_local = (0.0, 0.0, 1.0)
else:
normal_local = (0.0, 1.0, 0.0)
else:
# FACE_NORMAL: normal_local stored in addr (new) or addr.fingerprint (legacy).
normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local")
if not normal_local:
return None
n = _rotate_local_to_world(element, normal_local, placement_override)
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
def _get_line_offset_direction(
face_normal: Optional[tuple[float, float, float]],
resolved_pts: list[tuple],
camera_dir: Optional[tuple[float, float, float]] = None,
) -> Optional[tuple[float, float, float]]:
"""Return the direction to slide the dimension line (perpendicular to it, in-view).
For plan views (camera mostly vertical) uses cross(world_Z, dim_dir)
unchanged from the original behaviour, so existing stored LinePosition
values continue to work.
For section/elevation views (camera mostly horizontal) uses
cross(camera_dir, dim_dir) so the offset lies in the camera's view plane.
This makes dragging the gizmo move the line visually up/down (or
left/right) rather than in/out of the screen.
Falls back to cross(face_normal, world_Z) when the dimension line is
nearly parallel to the reference vector (e.g. vertical elevation dims).
"""
world_z = (0.0, 0.0, 1.0)
# In section/elevation (camera mostly horizontal) use camera_dir as the
# reference so the offset axis lies in the view plane.
cam_is_plan = camera_dir is None or abs(camera_dir[2]) > 0.7
ref = world_z if cam_is_plan else camera_dir
# Primary: cross(ref, dim_dir)
if len(resolved_pts) >= 2:
a, b = resolved_pts[0], resolved_pts[1]
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
dim_mag = math.sqrt(dx * dx + dy * dy + dz * dz)
if dim_mag > 1e-10:
dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag)
d = (
ref[1] * dim_dir[2] - ref[2] * dim_dir[1],
ref[2] * dim_dir[0] - ref[0] * dim_dir[2],
ref[0] * dim_dir[1] - ref[1] * dim_dir[0],
)
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
if mag > 1e-6:
return (d[0] / mag, d[1] / mag, d[2] / mag)
# Fallback for dims parallel to ref (e.g. vertical dims in plan):
# cross(face_normal, world_Z)
if face_normal:
n = face_normal
d = (
n[1] * world_z[2] - n[2] * world_z[1],
n[2] * world_z[0] - n[0] * world_z[2],
n[0] * world_z[1] - n[1] * world_z[0],
)
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
if mag > 1e-6:
return (d[0] / mag, d[1] / mag, d[2] / mag)
return None
File diff suppressed because it is too large Load Diff
@@ -39,9 +39,9 @@ def test_add_segment_to_layout():
alignment = ifcopenshell.api.alignment.create(file, "")
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert (
len(referent_nest.RelatedObjects) == 1
len(stationing_nest.RelatedObjects) == 1
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
@@ -75,8 +75,8 @@ def test_add_segment_to_layout():
assert len(horizontal_alignment.IsNestedBy) == 1
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_alignment)
assert len(segment_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
test_add_segment_to_layout()
@@ -39,8 +39,8 @@ def test_add_stationing_to_alignment():
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
referent = referent_nest.RelatedObjects[0]
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
referent = stationing_nest.RelatedObjects[0]
assert referent.PredefinedType == "STATION"
assert referent.Name == "2+000.000"
@@ -54,10 +54,10 @@ def test_add_stationing_to_alignment():
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
)
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 2
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 2
assert second_referent == referent_nest.RelatedObjects[1]
assert second_referent == stationing_nest.RelatedObjects[1]
assert second_referent.PredefinedType == "STATION"
assert second_referent.Name == "4+000.000"
@@ -36,11 +36,11 @@ def test_add_vertical_alignment():
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert len(layout_nest.RelatedObjects) == 1
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert (
len(referent_nest.RelatedObjects) == 1
len(stationing_nest.RelatedObjects) == 1
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
curve = ifcopenshell.api.alignment.get_curve(alignment)
assert curve.is_a("IfcCompositeCurve")
@@ -51,8 +51,8 @@ def test_create_by_pi_method():
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert len(layout_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 1
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 1
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
@@ -46,9 +46,9 @@ def test_horizontal_layout_by_pi_method():
assert len(alignment.IsDecomposedBy) == 0 # no child alignments
assert len(alignment.IsNestedBy) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout_nest.RelatedObjects[0])
assert len(segment_nest.RelatedObjects) == 3 # segments in horizontal layout
@@ -97,16 +97,32 @@ def callback_alignment():
def test_with_default_names(default_names_alignment):
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, default_names_alignment)
file = default_names_alignment.file
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
vertical = ifcopenshell.api.alignment.get_vertical_layout(default_names_alignment)
expected = ["P.O.B", "P.C.", "P.T.", "P.O.E.", "V.P.O.B.", "P.V.C.", "P.V.T.", "V.P.O.E"]
for r in referent_nest.RelatedObjects:
assert [x in r.Name for x in expected]
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
expected_h = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
expected_v = ["V.P.O.B.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "V.P.O.E."]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
def test_with_callbacks(callback_alignment):
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, callback_alignment)
file = callback_alignment.file
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(callback_alignment)
vertical = ifcopenshell.api.alignment.get_vertical_layout(callback_alignment)
expected = ["A", "Q", "Z", "a", "q", "z"]
for r in referent_nest.RelatedObjects:
assert [x in r.Name for x in expected]
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state
@@ -0,0 +1,372 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections import Counter
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.alignment
import ifcopenshell.util.element
COORDINATES = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
RADII = [1000.0, 1250.0, 950.0]
VPOINTS = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
LENGTHS = [1600.0, 1200.0, 2000.0, 800.0]
def _new_file():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _new_file_no_context():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
return file
def _build_alignment(file, start_station=0.0):
return ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", COORDINATES, RADII, VPOINTS, LENGTHS, start_station
)
def _pset_station(referent):
return ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station")
def test_wrong_layout_type_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
with pytest.raises(TypeError):
ifcopenshell.api.alignment.update_key_point_referents(file, alignment)
def test_default_rel_nests_created_when_none_provided():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest.is_a("IfcRelNests")
assert nest.RelatingObject == horizontal
assert nest.id() != segment_nest.id()
assert len(nest.RelatedObjects) == 8
assert all(r.is_a("IfcReferent") for r in nest.RelatedObjects)
def test_second_call_without_rel_nests_reuses_existing_nest():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment_count_before = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest1.id() == nest2.id()
assert len(nest2.RelatedObjects) == 16
segment_count_after = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
assert segment_count_after == segment_count_before
def test_provided_rel_nests_is_used_as_is():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# the nest may live anywhere the caller chooses, e.g. hung off the parent IfcAlignment
rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=())
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests)
assert result.id() == rel_nests.id()
assert result.RelatingObject == alignment
assert len(result.RelatedObjects) == 8
def test_clear_true_removes_old_referents_and_psets():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
old_referent_ids = [r.id() for r in nest.RelatedObjects]
old_pset_ids = [r.IsDefinedBy[0].RelatingPropertyDefinition.id() for r in nest.RelatedObjects]
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=True)
assert len(nest.RelatedObjects) == 8
for old_id in old_referent_ids + old_pset_ids:
with pytest.raises(RuntimeError):
file.by_id(old_id)
def test_clear_false_appends_without_dedup():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=False)
assert len(nest.RelatedObjects) == 16
counts = Counter(r.Name for r in nest.RelatedObjects)
assert len(counts) == 8
assert all(count == 2 for count in counts.values())
def test_default_horizontal_labels_and_order():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
expected = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == sorted(stations)
assert stations[0] == 0.0
def test_default_vertical_labels_and_order():
file = _new_file()
alignment = _build_alignment(file)
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
expected = [
"V.P.O.B.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"V.P.O.E.",
]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
segments = ifcopenshell.api.alignment.get_layout_segments(vertical)
real_segments = segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(vertical) else segments
# spot check the interior referents' stations against the segments' StartDistAlong directly
for referent, segment in zip(nest.RelatedObjects[1:-1], real_segments[1:]):
assert _pset_station(referent) == pytest.approx(segment.DesignParameters.StartDistAlong)
def test_name_format():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
referent = nest.RelatedObjects[0]
station = _pset_station(referent)
assert referent.Name == f"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
def test_geometric_placement_when_layout_has_representation():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
curve = ifcopenshell.api.alignment.get_layout_curve(horizontal)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
for referent in nest.RelatedObjects:
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
location = referent.ObjectPlacement.RelativePlacement.Location
assert location.is_a("IfcPointByDistanceExpression")
assert location.BasisCurve == curve
assert referent.ObjectPlacement.CartesianPosition is not None
first, last = nest.RelatedObjects[0], nest.RelatedObjects[-1]
assert first.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(0.0)
assert last.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(
_pset_station(last)
)
def test_fallback_placement_when_layout_has_no_geometry():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal, COORDINATES, RADII)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
expected_coordinates = alignment.ObjectPlacement.RelativePlacement.Location.Coordinates
for referent in nest.RelatedObjects:
assert referent.ObjectPlacement.is_a("IfcLocalPlacement")
assert referent.ObjectPlacement.RelativePlacement.Location.Coordinates == expected_coordinates
def test_cant_layout_boundary_labels():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_cant=True, include_geometry=False)
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
dp1 = file.createIfcAlignmentCantSegment(
StartDistAlong=0.0,
HorizontalLength=100.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp1)
dp2 = file.createIfcAlignmentCantSegment(
StartDistAlong=100.0,
HorizontalLength=50.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp2)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
assert labels[0] == "C.P.O.B."
assert labels[-1] == "C.P.O.E."
# CONSTANTCANT -> CONSTANTCANT is currently an unfilled "xx" placeholder in the cant lookup
# table (_get_segment_start_point_label.py) -- out of scope to fill in here.
assert labels[1] == "xx"
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == [0.0, 100.0, 150.0]
def test_no_real_segments_produces_no_referents():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest.RelatedObjects == ()
def test_single_real_segment_produces_only_boundary_labels():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
GravityCenterLineHeight=None,
PredefinedType="LINE",
)
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
assert labels == ["P.O.B.", "P.O.E."]
def test_start_station_composes_for_child_alignment():
file = _new_file()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0)
ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split
child_alignment = alignment.IsDecomposedBy[0].RelatedObjects[-1]
child_vertical = ifcopenshell.api.alignment.get_vertical_layout(child_alignment)
dp1 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=0.0,
HorizontalLength=500.0,
StartHeight=10.0,
StartGradient=0.01,
EndGradient=0.01,
PredefinedType="CONSTANTGRADIENT",
)
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp1)
dp2 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=500.0,
HorizontalLength=300.0,
StartHeight=15.0,
StartGradient=0.01,
EndGradient=0.01,
PredefinedType="CONSTANTGRADIENT",
)
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp2)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, child_vertical)
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == pytest.approx([100.0, 600.0, 900.0])
def test_returns_ifc_rel_nests():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert result.is_a("IfcRelNests")
test_wrong_layout_type_raises_type_error()
test_default_rel_nests_created_when_none_provided()
test_second_call_without_rel_nests_reuses_existing_nest()
test_provided_rel_nests_is_used_as_is()
test_clear_true_removes_old_referents_and_psets()
test_clear_false_appends_without_dedup()
test_default_horizontal_labels_and_order()
test_default_vertical_labels_and_order()
test_name_format()
test_geometric_placement_when_layout_has_representation()
test_fallback_placement_when_layout_has_no_geometry()
test_cant_layout_boundary_labels()
test_no_real_segments_produces_no_referents()
test_single_real_segment_produces_only_boundary_labels()
test_start_station_composes_for_child_alignment()
test_returns_ifc_rel_nests()
@@ -64,8 +64,8 @@ def test_vertical_layout_by_pi_method():
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert len(layout_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 1
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 1
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(vlayout)
assert len(segment_nest.RelatedObjects) == 3
+16 -3
View File
@@ -102,13 +102,26 @@ def clash(
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes against other elements.
"""Check one element for geometric clashes against other elements.
Reports hard intersections and, optionally, violations of a required
clearance. Returns the overall ``pass``, the ``scope`` actually used, a
``checks`` block in which each clash names the other ``element``, the
clash ``type``, the ``distance`` and the two closest points ``p1``/``p2``,
and a de-duplicated flat ``elements`` list of everything involved.
Geometry is computed for every element in scope, so this is slow on large
models; ``pass`` is ``None`` with an ``error`` when the element has no
usable geometry.
:param model: The IFC model.
:param element: The element to check.
:param clearance: Minimum clearance distance; if provided, runs clearance check.
:param clearance: Minimum required clearance distance; when given, also
runs the clearance check alongside the intersection check.
:param tolerance: Intersection tolerance in meters (default 0.002).
:param scope: Which elements to check against: "storey" or "all".
:param scope: ``"storey"`` (default) checks only elements sharing the
same spatial container; ``"all"`` checks every ``IfcElement``.
``"storey"`` falls back to ``"all"`` when the element has no spatial
container.
:return: Dict with clash results suitable for JSON serialization.
"""
result: dict[str, Any] = {"element": _ref(element)}
+12 -3
View File
@@ -26,10 +26,19 @@ def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcCostSchedule entries with nested cost item trees.
"""List the cost schedules: bills of quantities and their cost items.
max_depth limits how many levels of subitems are expanded (None = unlimited).
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
Covers ``IfcCostSchedule`` only this is the money dimension of the
model; see ``schedule()`` in this module for the construction programme.
Each cost item reports its cost ``values`` as ``formula`` label and
``category`` pairs, together with its nested ``subitems``. Returns an
empty list when the model has no cost schedules.
:param model: The in-memory IFC model.
:param max_depth: Levels of cost item nesting to expand, counting root
items as level 1. Past the cutoff ``subitems`` is replaced by a
``{"truncated": True, "count": N}`` marker giving the number of items
not expanded. ``None`` (default) expands to unlimited depth.
"""
result = []
for cost_schedule in model.by_type("IfcCostSchedule"):
+10 -1
View File
@@ -188,7 +188,16 @@ def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return deep inspection data for an element."""
"""Inspect a single entity in depth.
Returns the entity's direct ``attributes`` plus, where present,
``property_sets``, ``element_type``, ``material``, ``container``,
``placement`` and ``geometry_summary``. Keys are omitted when the
information is unavailable.
:param model: The in-memory IFC model.
:param element: The entity to inspect.
"""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
+7 -1
View File
@@ -22,7 +22,13 @@ import ifcopenshell
def materials(model: ifcopenshell.file) -> list[dict]:
"""Return all materials and material sets from the model.
"""List the materials and material sets defined in the model.
Returns a single list covering ``IfcMaterial`` (with its category),
``IfcMaterialLayerSet`` (each layer's name, thickness, material and
ventilation flag), ``IfcMaterialConstituentSet`` (constituent names,
materials and fractions) and ``IfcMaterialProfileSet`` (profile names and
materials). Every entry carries the step ID of the material entity.
:param model: The in-memory IFC model.
:return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
+16 -1
View File
@@ -182,7 +182,22 @@ def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -
def relations(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
) -> dict[str, Any] | list[dict[str, Any]]:
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
"""Show how an element relates to the rest of the model.
By default returns a dict whose optional blocks are ``hierarchy`` (parent,
container, aggregate, nest, filled void, voided element), ``children``
(contained, parts, components, openings), ``type_relationship``,
``groups``, ``systems``, ``zones``, ``material``, ``referenced_structures``
and ``connections`` (connected to/from, ports), plus a de-duplicated flat
``elements`` list of everything referenced. Blocks with nothing to report
are omitted.
:param model: The IFC model.
:param element: The element to examine.
:param traverse: Set to ``'up'`` to instead return the chain of ancestors
from the element to ``IfcProject`` as a flat list. Any other value
gives the default behaviour.
"""
if traverse == "up":
return _traverse_up(element)
result = _all_relations(model, element)
+13 -3
View File
@@ -37,10 +37,20 @@ def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, dep
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcWorkSchedule entries with nested task trees.
"""List the construction programme: work schedules and their task trees.
max_depth limits how many levels of subtasks are expanded (None = unlimited).
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
Covers ``IfcWorkSchedule`` only this is the time dimension of the
model; see ``cost()`` in this module for the money dimension. Each
schedule lists its tasks recursively, and each task carries its scheduled
``start`` and ``finish``, an ``is_milestone`` flag, the products it
``outputs`` and its ``subtasks``. Returns an empty list when the model has
no work schedules.
:param model: The in-memory IFC model.
:param max_depth: Levels of subtask nesting to expand, counting root tasks
as level 1. Past the cutoff ``subtasks`` is replaced by a
``{"truncated": True, "count": N}`` marker giving the number of tasks
not expanded. ``None`` (default) expands to unlimited depth.
"""
result = []
for work_schedule in model.by_type("IfcWorkSchedule"):
+9 -1
View File
@@ -8,7 +8,15 @@ import ifcopenshell.util.doc
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type from model's schema version."""
"""Look up the IFC documentation for an entity class.
Returns the class ``description``, its ``predefined_types``, per-attribute
documentation and a ``spec_url``, resolved against the model's schema
version. Returns an ``error`` key for an unknown class.
:param model: The in-memory IFC model, used only for its schema version.
:param entity_type: IFC class name, for example ``'IfcWall'``.
"""
schema_name = model.schema
try:
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
+9 -1
View File
@@ -26,7 +26,15 @@ import ifcopenshell
def summary(model: ifcopenshell.file) -> dict[str, Any]:
"""Return a model overview with schema, element counts, and project info."""
"""Summarise the model: schema, entity counts and project info.
Returns the ``schema`` version, ``total_entities``, and a ``project``
block with the id, name and description of the first ``IfcProject``
(omitted if the model has none). The count covers every entity in the
file, not just physical elements.
:param model: The in-memory IFC model.
"""
# Count elements by IFC type, sorted by count descending
type_counter: Counter[str] = Counter()
total = 0
+11 -1
View File
@@ -59,7 +59,17 @@ def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
"""Return the spatial hierarchy tree starting from IfcProject."""
"""Return the spatial hierarchy of the model as a nested tree.
Starts at ``IfcProject`` and descends through decomposition (site,
building, storeys) and containment (the elements placed in each storey).
Every node carries ``id``, ``type`` and ``name``; ``children`` holds
decomposed sub-spaces and ``elements`` holds contained elements, and
either key is omitted when empty. Returns a list when the file contains
several projects, or an ``error`` key when it contains none.
:param model: The in-memory IFC model.
"""
projects = model.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in model"}
+10 -1
View File
@@ -8,7 +8,16 @@ import ifcopenshell.validate
def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
"""Validate the model and return a dict with 'valid' bool and 'issues' list."""
"""Validate the model against the IFC schema.
Returns ``valid`` together with a list of ``issues``, each carrying a
``level`` and a ``message``. Worth running after a batch of edits and
before writing the model back to disk.
:param model: The in-memory IFC model.
:param express_rules: Also evaluate the schema's EXPRESS rules. Catches
more problems but is considerably slower (default ``False``).
"""
logger = ifcopenshell.validate.json_logger()
ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]