Compare commits

..

151 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
dependabot[bot] a11ebdf8c4 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 13:57:34 +02:00
Bartok b997726564 docs: add Eigen to Linux install deps
Linux "basic dependencies" omitted libeigen3-dev even though
ifcgeom requires Eigen3 (find_package Eigen3 REQUIRED) and the
cmake snippet already passes -DEIGEN_DIR=/usr/include/eigen3.
macOS Homebrew line already installs eigen.

Closes #6903

Generated with the assistance of an AI coding tool.
2026-07-31 12:49:37 +02:00
Petru Conduraru 25713a486a Fix test_rules.py filtering by sys.argv, which empties the corpus under pytest
test_file's parametrize list was filtered with `sys.argv[1] in
os.path.basename(fn)`, reading the raw process argv instead of a
pytest-native option. Under a bare `pytest` invocation sys.argv[1] is
pytest's own first CLI token, never a match, so the 138-fixture EXPRESS
rule corpus in test/fixtures/rules collapses to an empty parametrize and
pytest reports it as a single skipped test rather than an error. Under
CI's actual invocation (pytest -p no:pytest-blender -n $NPROCS test ...)
sys.argv[1] is "-p", which happens to substring-match 47 of the 138
fixtures, so CI has been silently running a coincidental 34% slice of
the corpus with no signal anything was wrong.

Replaced the module-level list comprehension with a pytest_generate_tests
hook plus a --rule CLI option (added via a new test/conftest.py). This
runs the full corpus by default under any pytest invocation, still
allows filtering to one rule for local debugging via --rule, and no
longer collides with pytest's own argv.

Verified all 138 fixtures collect and pass under the fixed harness
(63 fail- fixtures each raise a violation, 75 pass- fixtures raise none).

Generated with the assistance of an AI coding tool.
2026-07-31 10:38:58 +02:00
Petru Conduraru d3b6b82151 ifcmcp: pin mcp below 2.0 to fix broken FastMCP import
mcp 2.0.0 (unpinned in CI and in the ifcmcp[mcp] extra) renamed
mcp.server.fastmcp.FastMCP to mcp.server.mcpserver.MCPServer, which
ifcmcp does not support yet. server.py caught the resulting
ModuleNotFoundError with a bare except Exception and silently
reported it as FastMCP not installed, masking the real breakage
until the ifcmcp test suite failed in CI.

Pinned mcp to >=1.0,<2 in both ci.yml and ifcmcp's pyproject.toml
mcp extra, confirmed the full ifcmcp test suite (70 tests) passes
against mcp 1.29.0, and confirmed the genuinely-not-installed path
still raises the expected ImportError. Also narrowed the except
clause to ImportError only so an unrelated future bug in that
import block surfaces instead of being swallowed as "not installed".

Generated with the assistance of an AI coding tool.
2026-07-31 10:36:39 +02:00
yekose 9e6797e172 ifcparse: check the result of fopen before using the FILE*
FullBufferImpl and PagedFileImpl both open the file and then use the handle
without ever testing it:

    auto stream = _wfopen(fn_wide, L"rb");   // null when the file is missing
    fseek(stream, 0, SEEK_END);              // null goes straight to the CRT
    buf_.resize((size_t)ftell(stream));

Opening a path that does not exist therefore hands a null FILE* to the CRT. On
MSVC that does not return an error: the runtime terminates the process
immediately (fastfail, exit code 0xC0000409). No exception is thrown, no stack
unwinding starts, so a caller cannot defend with try/catch — the host
application simply dies. On glibc it is undefined behaviour as well.

This is reachable through the ordinary entry point, because guess_file_type()
answers FT_IFCSPF for a path that does not exist (its own comment calls this
"just weird, but for consistency with earlier behaviour"), so a missing path
flows into the reader rather than being reported.

The fix is to leave the reader empty when the open fails. Both implementations
then behave like a zero-length file: size() is 0 and get() throws out_of_range
for any position, so the parse fails and IfcFile::good() reports it, which is
what a caller can actually handle. PagedFileImpl's destructor already tested
fp_ for null, so the possibility was known — only the constructor did not check.

Verified by reading a non-existent path through IfcParse::IfcFile: the
constructor returns and good() reports the failure, where before the process
died with 0xC0000409 and no output.
2026-07-31 10:33:04 +02:00
Petru Conduraru 1f9a0a53bb ifcopenshell.template: fix timestring ignoring an explicit timestamp of 0
create(timestamp=0) computed the FILE_NAME timestring with
`d.get("timestamp") or time.time()`, which treats 0 (a legitimate
epoch timestamp) as unset because 0 is falsy. The header ended up
with the current wall-clock time in FILE_NAME while IFCOWNERHISTORY
correctly stored CreationDate=0, an inconsistent pair of dates in
the same file. Switched to an explicit None check so an explicit
timestamp of 0 is honoured the same way any other explicit
timestamp is.

Generated with the assistance of an AI coding tool.
2026-07-31 10:19:33 +02:00
yekose b82c4c53fe ifcgeom: add a profile_point overload taking a plain double
The profile mapping builds its points as

    profile_helper(m4, {
        {{-x, -y}, {f2}},
        ...

where `f2` is a `double` and profile_point's second member is a
`boost::optional<double>`. In recent Boost (somewhere between 1.85 and 1.91)
optional's converting constructor became explicit, and an explicit constructor
cannot be used in copy-initialization — which is what a braced element is. So
every one of these call sites stops compiling:

  MSVC 19.4x:  error C2664: cannot convert argument 2 from
               'initializer list' to 'const std::vector<profile_point>&'
  clang-cl 22: error: chosen constructor is explicit in copy-initialization

Twelve translation units are affected (IfcCShapeProfileDef,
IfcIShapeProfileDef, IfcLShapeProfileDef, IfcTShapeProfileDef,
IfcUShapeProfileDef, IfcZShapeProfileDef, IfcAsymmetricIShapeProfileDef,
IfcCraneRailAShapeProfileDef, IfcRectangleProfileDef,
IfcRectangleHollowProfileDef, IfcRoundedRectangleProfileDef,
IfcTrapeziumProfileDef), roughly 100 call sites in total.

Adding one overload that takes the double directly fixes all of them without
touching a single call site, and changes nothing for existing code: the
optional overload still wins wherever an optional is passed.

Verified by building schemas 2x3;4;4x3_add2 with MSVC 2022 against Boost
1.91 and OCCT 7.9.3 — IfcParse, IfcGeom, the schema mappings and
geometry_kernel_opencascade all archive cleanly. Without this, the same build
against Boost 1.85 succeeds, which is what identified Boost as the variable.
2026-07-31 10:14:16 +02:00
CyrilWaechter 8deefe497c Fix space regen doubling Z location
Removing translate_obj_to_z_location from the existing-IfcSpace
regeneration branch. The ShapeBuilder rewrite (d8de62308) builds
geometry in local space preserving obj.matrix_world, making the
translate call redundant — it adds z on top of the already-correct
location.z, producing 2*z.

Add test_regenerate_space_preserves_z_location to cover the
regeneration path with a non-zero Z elevation.

Generated with the assistance of an AI coding tool.
2026-07-26 16:19:51 +02:00
Petru Conduraru 9621388953 ifcpatch: correct the AGS2IFC docstring example
The example block was copy pasted verbatim from ExtractPropertiesToSQLite,
so it named the wrong recipe and wrote a .sqlite file. These docstrings are
what ifcpatch surfaces as CLI and UI help, so anyone following the example
for AGS2IFC got a recipe name that does not match the one they selected.

Also state that the input file is not read and that a new IFC4X3 model is
built, since that is not obvious from the signature and the recipe creates
its own project rather than patching the one passed in.

Generated with the assistance of an AI coding tool.
2026-07-25 19:46:32 +10:00
Petru Conduraru 89523999b3 Bonsai: fix UnboundLocalError crash in polyline angle calculation
angle_round_threshold was only assigned inside the `distance > 0`
branch of calculate_distance_and_angle, but read unconditionally
whenever should_round is True. When the mouse sample coincides with
the last placed point (distance == 0), such as the first mouse move
after placing a wall's start point on a YZ plane view, this crashed
the modal wall tool.

angle_round_threshold is a fixed cutoff unrelated to whether distance
is currently zero, so it is now assigned once before the branch.

Fixes #8597.

Generated with the assistance of an AI coding tool.
2026-07-25 13:57:51 +10:00
Petru Conduraru 51ab38de27 Fix ci-bonsai-daily: configure unmerged_blobs mock in git_mergetool tests (#8574)
test_returns_none_when_report_file_absent/empty build a MagicMock repo
without configuring index.unmerged_blobs(), so it returned a truthy
MagicMock and git_mergetool's load-bearing "unresolved conflicts remain"
fallback (tool/ifcgit.py:646-647) returned that list instead of None -
failing "assert [] is None". The production fallback is correct and
intentionally left untouched; the tests just misrepresented the
"mergetool resolved cleanly" scenario they are named for. Set
mock_repo.index.unmerged_blobs.return_value = {} in both.

Verified in headless Blender: test/tool/test_ifcgit.py::TestGitMergetool
2 failed / 1 passed -> 3 passed.

This change was made with the assistance of an AI tool.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:21:18 +01:00
Petru Conduraru fbe36532a0 ifcdiff: fix crash when exporting property diffs to JSON
DeepDiff's dictionary_item_added/set_item_added results are a
deepdiff.helper.SetOrdered instance, which subclasses orderly_set's
StableSetEq rather than the OrderedSet class json_dump_default checked
for, so the property relationship check always crashed export() with
"Object of type SetOrdered is not JSON serializable". Check against
StableSet, the common base class shared by every orderly_set set
flavour, instead.

Fixes #8905

Generated with the assistance of an AI coding tool.
2026-07-25 07:47:13 +10:00
Andrej730 2f1b2f9638 ifcwrap: use swig shadowing for keeping reference to Element 2026-07-24 21:51:21 +05:00
Andrej730 9001cca078 ifcwrap: exclude internal geometry pointers
Still available as `int(xxx.this)`.
2026-07-24 21:51:21 +05:00
Andrej730 ec558dc57e stub: add logger_or_root arg type 2026-07-24 18:37:25 +05:00
Andrej730 2db5658386 ifcwrap: hide guess_file_type from Python as unused 2026-07-24 18:22:59 +05:00
Andrej730 88c8bd032f ifcwrap: fix breaking validate_stub (824c1fc)
It's ignoring underscore prefixed functions as not actually used.
Removing underscore to keep it happy without adding new exceptions.
2026-07-24 18:00:54 +05:00
Andrej730 3d8654acfd ifcwrap: ignore newly added conversion settings structs (183e4c4) 2026-07-24 17:59:44 +05:00
Andrej730 4a20b67038 IfcSchema: provide arg names for register_schema, schema_by_name 2026-07-24 16:48:51 +05:00
Andrej730 b14df627d7 ci: fix failing test for ifc5d 2026-07-24 16:15:54 +05:00
dependabot[bot] 0828c6ba92 build(deps): bump ty from 0.0.61 to 0.0.63
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.61 to 0.0.63.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.61...0.0.63)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.63
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:46:39 +05:00
Andrej730 a586c7f695 black . 2026-07-24 15:43:59 +05:00
Andrej730 0bad5a9389 ty: add ignores 2026-07-24 15:43:59 +05:00
dependabot[bot] 0ce400cace build(deps): bump gersemi from 0.26.1 to 0.28.0
Bumps [gersemi](https://github.com/BlankSpruce/gersemi) from 0.26.1 to 0.28.0.
- [Release notes](https://github.com/BlankSpruce/gersemi/releases)
- [Changelog](https://github.com/BlankSpruce/gersemi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BlankSpruce/gersemi/compare/0.26.1...0.28.0)

---
updated-dependencies:
- dependency-name: gersemi
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:29:41 +05:00
dependabot[bot] 91ed59311b build(deps): bump ruff from 0.15.22 to 0.16.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.22 to 0.16.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.22...0.16.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:29:22 +05:00
dependabot[bot] 1906481a01 Bump svelte from 5.53.6 to 5.55.8 in /src/ifctester/webapp
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.53.6 to 5.55.8.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.8/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.55.8
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:06:42 +05:00
dependabot[bot] 73bf238232 Bump uuid and hyperid in /src/ifctester/webapp
Removes [uuid](https://github.com/uuidjs/uuid). It's no longer used after updating ancestor dependency [hyperid](https://github.com/mcollina/hyperid). These dependencies need to be updated together.


Removes `uuid`

Updates `hyperid` from 3.3.0 to 4.0.0
- [Release notes](https://github.com/mcollina/hyperid/releases)
- [Commits](https://github.com/mcollina/hyperid/compare/v3.3.0...v4.0.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 
  dependency-type: indirect
- dependency-name: hyperid
  dependency-version: 4.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:06:21 +05:00
dependabot[bot] a85d5cc990 Bump lxml from 4.9.1 to 6.1.0 in /src/ifcopenshell-python
Bumps [lxml](https://github.com/lxml/lxml) from 4.9.1 to 6.1.0.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-4.9.1...lxml-6.1.0)

---
updated-dependencies:
- dependency-name: lxml
  dependency-version: 6.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:04:15 +05:00
dependabot[bot] c16aec2cb0 Bump ws and engine.io-client in /src/ifctester/webapp
Bumps [ws](https://github.com/websockets/ws) and [engine.io-client](https://github.com/socketio/socket.io). These dependencies needed to be updated together.

Updates `ws` from 8.17.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.17.1...8.21.0)

Updates `engine.io-client` from 6.6.3 to 6.6.6
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/engine.io-client@6.6.3...engine.io-client@6.6.6)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
- dependency-name: engine.io-client
  dependency-version: 6.6.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:03:56 +05:00
dependabot[bot] 76be31561e build(deps-dev): bump immutable in /src/ifctester/webapp
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.5 to 5.1.9.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.5...v5.1.9)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:01:00 +05:00
dependabot[bot] a111c68d44 Bump devalue from 5.6.4 to 5.8.1 in /src/ifctester/webapp
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.6.4 to 5.8.1.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.6.4...v5.8.1)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.8.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:59:19 +05:00
dependabot[bot] 62f627ecc6 Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:59:09 +05:00
dependabot[bot] 279e16f2ab build(deps-dev): bump tar from 7.5.16 to 7.5.21 in /src/ifctester/webapp
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.16 to 7.5.21.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.16...v7.5.21)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:58:54 +05:00
dependabot[bot] 3d68d0f0e5 build(deps-dev): bump postcss in /src/ifctester/webapp
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.4 to 8.5.22.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.4...8.5.22)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.22
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:58:50 +05:00
Petru Conduraru e759135608 Bonsai: cache-bust webui static assets so shipped JS/CSS changes reach users
Browsers were caching /static/js and /static/css for the standalone
webui (costing, gantt, drawings, index, demo pages) indefinitely, so a
shipped JS fix (e.g. the Download CSV button) would only reach a user
after a manual hard refresh.

Two changes, applied consistently across all five webui pages.

1. Every locally served link/script tag in the pystache templates now
carries a ?v=<bonsai version> query string, falling back to a static
asset mtime hash when BONSAI_VERSION isn't set (e.g. running
sioserver.py standalone). Since get_bonsai_version() includes the
build's commit hash, the token changes on every shipped update.

2. Responses under /static/ and /jsgantt/ now carry
Cache-Control: no-cache, must-revalidate. This covers what query
stamping alone can't reach: cost.js and gantt.js statically import
utilities/costui.js by a fixed relative path with no query string, so
that nested module still needed server side revalidation to pick up
changes.

Verified against a live aiohttp instance of sioserver.py: rendered
HTML for all five routes shows the stamped URLs, and the token
changes when BONSAI_VERSION changes between two server runs. A
conditional GET against a static file with a stale If-Modified-Since
header confirms the cheap 304 revalidation path still works.

Also used this instance plus a real headless Chromium (Playwright) to
click test the previously untested Download CSV button on the costing
page. The ribbon renders it correctly, and clicking it (with a
synthetic cost-items table injected into the DOM to stand in for a
connected Blender's data) triggers a real Blob download with the
correct filename and CSV content. No bug found, the button works as
intended.

AI-generated with Claude Code.
2026-07-24 11:32:34 +02:00
Petru Conduraru 1df738d968 ifc5d: match cost schedule export columns to the Bonsai cost panel (#6251)
Stefano's final ask on #6251 was specific: the ODS/XLSX export should
show exactly what the cost panel shows, ID (Identification), Name,
Quantity, Value, Total Cost, no more, no less. The previous fix in
this PR removed the internal bookkeeping columns but still exported
Description, Unit and a per-category cost breakdown (Labor Cost,
Material Cost, etc), none of which appear in the panel.

Presentation formats (.ods/.xlsx) now use an explicit allow-list of
columns instead of a block-list of internal ones, and relabel headers
to match the panel's own wording (ID / Value / Total Cost). The .csv
format is unchanged: csv2ifc still reads back the extra bookkeeping
columns for the import round trip, which is why it keeps them.

Also add a "Download CSV" button to the browser costing view
(Generate spreadsheet browser), which previously only offered a
clipboard-based Copy Selected. It reuses the already-rendered table
(respecting the user's column visibility settings) and triggers a
real file download, dropping only the UI-only Actions column.

AI-generated with Claude Code; reviewed and tested by Petru Conduraru.
2026-07-24 11:32:34 +02:00
Petru Conduraru 98c28a1f30 ifc5d: professional grade ODS/XLSX cost schedule export #6251
Three defects reported against the Costing tab export:

1. XLSX export crashed with ModuleNotFoundError: xlsxwriter was never
   bundled with Bonsai. Port the writer to openpyxl, which ifccsv
   already uses and Bonsai already ships, so it works out of the box.
2. Every ODS cell was written as a string (numbers as text), and the
   formula branch was dead code: it compared against 'Total Price' /
   'Rate Subtotal' while the headers are 'TotalPrice' / 'RateSubtotal'.
   Numeric columns are now typed float cells and TotalPrice becomes a
   real formula: Quantity*RateSubtotal on leaf items, SUM over the
   direct children's TotalPrice cells on sum items.
3. Internal bookkeeping columns (Id, ItemIsASum, Hierarchy, Index,
   Quantities) leaked into the presentation formats. ODS/XLSX now hide
   them; CSV keeps them since csv2ifc consumes them for the round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:32:34 +02:00
Petru Conduraru 21122c0d28 Resolve nested complex quantity paths in the selector (#2041)
get_element_value could not reach the members of an IfcPhysicalComplexQuantity
(or IfcComplexProperty) by their natural path. util.element expands a complex
quantity into a dict whose nested members live under a "properties" sub-dict,
but the selector's dict navigation only looked at the top level, so
"Qto_Custom.Layer1.Width" returned None and IfcCsv exported nothing for it.
Only the internal "Qto_Custom.Layer1.properties.Width" path worked.

When a key is not a direct member of the value dict, descend into its
"properties" sub-dict so nested quantities/properties resolve with the
natural "Set.Complex.Nested" path. Direct keys still take priority, so the
explicit ".properties." path stays backward compatible and the regex branch
is untouched.

Verified: Qto_Custom.Layer1.Width -> 0.1 and Layer1.Height -> 2.5 (were
None), the sibling simple NetArea still resolves, the legacy .properties.
path still works, and IfcCsv now exports the nested value. test_selector.py:
38 passed (adds test_selecting_a_nested_complex_quantity).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:31:38 +02:00
falken10vdl d63a99b70c Merge pull request #8342 from falken10vdl/style-flat-pretty-toggle
Style flat pretty toggle
2026-07-24 09:56:24 +02:00
falken10vdl f2d8f17f88 Remove unused has_any_textures return from restore_material_style_types 2026-07-24 09:47:16 +02:00
falken10vdl e9b619e3fb Remove unused _get_shader_label helper method 2026-07-24 09:41:23 +02:00
Petru Conduraru d1f9e5243e Fix ci-bonsai-daily: ProjectLibraryData duplicate parent-library enum entry (#8573)
* Fix ci-bonsai-daily: ProjectLibraryData duplicate parent-library enum

parent_libraries_enum() adds an explicit entry for get_root_context(),
then loops over cls.data["project_libraries"] (all IfcProjectLibrary
entities) and appends each. For a library-only file (no IfcProject),
get_root_context falls back to the top-level IfcProjectLibrary itself,
so the root is appended twice with the same enum key (its STEP id),
which Blender EnumProperty requires to be unique -> the data load
asserts. Normal project files are unaffected (root is an IfcProject
whose id never collides with a library id).

Skip library_id == root.id() in the loop (dedup by id, the colliding
key). Verified in headless Blender:
test_project_library_data.py::TestLibraryOnlyFile goes from 1 failed /
5 passed to 6 passed.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bonsai: repair library files missing the required IfcProject, not just the symptom

Per the IFC Project Context concept template, every project data set (library
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. There is no such thing as a
spec-valid file rooted on IfcProjectLibrary alone.

get_root_context() (added in 260a387069, #8184) treated a missing IfcProject
as license to use the top-level IfcProjectLibrary as the file's root context
instead. That invalid premise is why project_libraries() (which walks every
IfcProjectLibrary, root included) then re-added that same entity, producing
the duplicate, colliding enum key this PR originally papered over with a
dedup guard.

Add tool.Project.ensure_project_context(), which repairs a file missing
IfcProject by creating one and declaring the file's root-level
IfcProjectLibrary instances to it, and tool.Project.open_library_file(),
which opens a library file through that repair. Route all three
IfcStore.library_file load sites in SelectLibraryFile through it. Downstream
code (get_root_context, ProjectLibraryData, RefreshLibrary,
AddProjectLibrary) now always operates on a spec-valid model, so the
duplicate enum entry cannot occur; the previous one-line dedup guard in
parent_libraries_enum() is kept only as cheap defense in depth for callers
that bypass the load-time repair, not as the fix.

Rework test_project_library_data.py: the previous _make_library_only_file()
fixture built an invalid library-only model and asserted that as correct
behaviour. Replace it with a spec-valid fixture (IfcProject + IfcProjectLibrary
declared to it) for the downstream tests, and a malformed fixture used only to
exercise the new repair path.

Verified live in headless Blender (isolated profile): reproduced the original
duplicate-enum-key failure mode, then confirmed ensure_project_context/
open_library_file repair a malformed file and ProjectLibraryData,
refresh_library and add_project_library all operate correctly on the result,
with no duplicate keys and no regression on already-valid files or IFC2X3.

This change was made with the assistance of an AI tool.

* Bonsai: stop supporting library-only files, do not repair them

Per Moult's feedback: if the IFC is invalid, our default position is to not
support it, not to patch around it. A library file with no IfcProject is
invalid IFC (Project Context concept template requires exactly one
IfcProject), and it is not ubiquitous: every library file bonsai ships under
bim/data/libraries has an IfcProject with the IfcProjectLibrary declared to
it via IfcRelDeclares. The single #8183 report is an outlier, not a common
authoring pattern worth accommodating.

Remove tool.Project.ensure_project_context() and open_library_file() (the
load-time repair added in the previous commit here) and revert
SelectLibraryFile's three load sites to plain ifcopenshell.open. Simplify
get_root_context() back to returning ifc_file.by_type("IfcProject")[0]
directly, no IfcProjectLibrary fallback: a file without IfcProject now raises
IndexError instead of being silently treated as valid. AddProjectLibrary's
nest-under-library branch is now dead code (root_context is always an
IfcProject) and is removed. The one-line enum dedup guard from the original
commit here is also removed: since get_root_context can only return an
IfcProject or raise, an IfcProject id can never collide with a library id, so
the guard has nothing left to guard against.

Rework test_project_library_data.py: drop the invalid _make_library_only_file
fixture and its tests, which asserted an unsupported model as correct
behaviour. Replace with a single spec-valid fixture matching bonsai's own
shipped library files (IfcProject + IfcProjectLibrary declared to it), used
for the ci-bonsai-daily regression test and the refresh/add-library
operators, plus one explicit test that get_root_context raises for a file
without IfcProject, documenting that this input is intentionally
unsupported rather than silently tolerated.

Verified live in headless Blender (isolated profile, source-loaded, never
the real profile): confirmed the removed methods are gone, that a
library-only file now raises instead of being handled, that
ProjectLibraryData/refresh_library/add_project_library all work correctly
on a spec-valid model with unique enum keys, and spot-checked that every
library file under bim/data/libraries already has an IfcProject.

This change was made with the assistance of an AI tool.

* Bonsai: inline get_root_context, trim docstrings, confirm get_parent_library unchanged

Per Moult's round 3 review. get_root_context added nothing over
ifc_file.by_type("IfcProject")[0], which is guaranteed by the IFC Project
Context concept template; remove it and inline the call at its three sites
(operator.py's RefreshLibrary and AddProjectLibrary, data.py's
parent_libraries_enum). Trim the get_parent_library docstring to one line;
its logic is untouched by this PR, byte for byte identical to origin/v0.8.0,
and still returns None only when project_library has neither Nests nor
HasContext, never for a library declared directly to IfcProject.

Rework test_project_library_data.py to match: replace the two
get_root_context-specific tests with one that exercises the real call site
(ProjectLibraryData.parent_libraries_enum raising IndexError for a file
without IfcProject), and add an explicit test that get_parent_library
returns None for a genuinely orphaned library. Also drop a long inline
comment that restated what the test body already shows.

Verified live in headless Blender (isolated profile, source-loaded, never
the real profile): all 17 test/bim/module/project tests pass, including the
new get_parent_library None-for-orphan case. Ran the full test/bim suite
before and after on the identical harness: 82 failed/1335 passed both times,
same failing tests (all pre-existing, unrelated to this module).

This change was made with the assistance of an AI tool.

* Bonsai: fix EditProjectLibrary leaving stale declarations after reparenting

Per Moult's round 4 review. The assertion change (get_parent_library(root)
now returns the IfcProject instead of None) is correct: in the old
library-only test model a top-level library had neither IfcRelNests nor
IfcRelDeclares, so None meant "top level". In the new spec-valid model a
top-level library is always declared to the guaranteed IfcProject via
IfcRelDeclares, so get_parent_library correctly resolves it through the
HasContext branch instead of falling through to None. get_project_hierarchy
already keys top-level libraries under the project for exactly this reason,
so the library tree still renders correctly.

Auditing every caller found one real bug in EditProjectLibrary, which
Gorgious56 originally wrote for the library-only model. Its move-library
logic assumed a top-level library (previous_parent_library is None) needed
no cleanup before nesting it under a new parent, and that unnesting a
library back to the project needed no new relationship because it was
"already assigned by default". Both assumptions relied on a top-level
library never actually holding a IfcRelDeclares, which is no longer true.
Reproduced live: moving a project-declared library under another library
left its old IfcRelDeclares dangling alongside the new IfcRelNests (an
invalid double parentage), and moving a nested library back to the project
left it with neither relationship, orphaning it out of the tree entirely.

Fixed by tearing down whichever of IfcRelDeclares/IfcRelNests the library
previously had before establishing whichever one the new parent requires,
instead of assuming which prior state applies.

Added tests: get_parent_library resolving a nested sub-library to its
library parent (the third contract case alongside project-declared and
orphaned), and both EditProjectLibrary reparenting directions, which fail
without the operator.py fix and pass with it.

Verified live in headless Blender (isolated profile, source-loaded, never
the real profile): all 20 test/bim/module/project tests pass. Ran the full
test/bim suite before and after on the identical harness: 123 failed/1294
passed before, 123 failed/1297 passed after, identical failing test names
in both runs (diffed), the extra 3 passes are the new tests above.

This change was made with the assistance of an AI tool.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:31:44 +10:00
Petru Conduraru 537317b26f Fix ci-bonsai-daily: guard on_depsgraph_update_caps during file load
on_depsgraph_update and on_depsgraph_update_caps are registered together
as persistent depsgraph handlers (bim/module/clip_box/__init__.py:50-52).
on_depsgraph_update guards with `if cls._file_loading: return`, but the
sibling on_depsgraph_update_caps did not, so a depsgraph tick during the
file-load window still ran it. Beyond the failing test, this can re-arm a
cap-rebuild bpy.app.timers callback in the exact load window _on_load_pre
cancels timers for, against regions whose GPU state is not yet wired.

Add the same _file_loading guard as the first check.

Verified in headless Blender:
test_clip_box.py::TestRefreshTimerLifecycle::test_depsgraph_update_no_op_while_loading
1 failed -> passed.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:22:01 +10:00
Petru Conduraru 4a095f9810 Bonsai: don't crash querying a freshly linked IFC with cache off
Link IFC with 'Use Cache' unchecked crashed with FileNotFoundError
when no .ifc.cache.blend existed yet (a fresh link). Regression from
35e3d9c42, which refactored the cache-clear guard from
'if not self.use_cache and blend_filepath.exists()' into
should_clear_cache() but dropped the existence check on the
not-use_cache path, so os.remove() ran on a non-existent file.

Check blend_filepath.exists() first in should_clear_cache() so the
remove is never attempted when there is nothing to clear, while
keeping the query-mismatch cache invalidation intact.

Fixes #8350

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:19:12 +10:00
falken10vdl c92a825a94 Cache last shading type to skip redundant material style restores 2026-07-24 09:12:04 +02:00
falken10vdl 4c5bd88877 add material update_tag in restore_material_style_types 2026-07-24 09:12:04 +02:00
falken10vdl 92e3e400f8 Use consistent material style prop accessor 2026-07-24 09:12:04 +02:00
falken10vdl 13c4ba257d Fix initila style when loading (default is SOLID - Flat: Shade) 2026-07-24 09:12:04 +02:00
falken10vdl c77a28c862 Add Flat/Pretty style toggle and dual-branch external style management 2026-07-24 09:12:04 +02:00
Petru Conduraru 82f73c29ea style.assign_representation_styles: fix crash on IfcPresentationStyleAssignment #7883
When replacing a style on an item whose previous IfcStyledItem wraps its styles
in the deprecated IfcPresentationStyleAssignment, and the assignment is not
being reused (use_style_assignment is False, e.g. an IFC4 file authored by
AVEVA E3D), the else branch called remove_same_type_styles(style_assignment)
with style_assignment still None, raising
AttributeError: 'NoneType' object has no attribute 'Styles'. Operate on style_,
the assignment found in the current iteration, instead of the accumulator.
Verified red-green with a minimal IFC4 file using IfcPresentationStyleAssignment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:46:29 +10:00
Petru Conduraru 209c44db83 Selector: negate list comparisons as an aggregate #8129
compare() recursed into list values passing the negated comparison through,
so != meant "at least one item differs" and both = and != matched the same
elements on any multi-valued property (e.g. an enumerated property with two
values selected). Strip the negation for the per-item comparison and negate
the aggregate instead, so != means "no item equals" and stays the complement
of =. The same applies to !*=.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:24:03 +10:00
Petru Conduraru 382f5e0c21 ifcpatch: use stdlib graphlib for Optimise topological sort (#4399)
The Optimise recipe imported `toposort`, a third-party PyPI package that
is not bundled with Bonsai, so running the recipe there raised
`ModuleNotFoundError: No module named 'toposort'`.

Replace it with the standard library `graphlib.TopologicalSorter`
(available since Python 3.9), which provides the same dependencies-first
ordering guarantee the recipe relies on: forward-referenced instances are
mapped before the instances that reference them. The dependency-graph
dict format ({node: {predecessors}}) is identical between the two, so the
graph construction is unchanged. Drop `toposort` from ifcpatch's
dependencies since it is no longer used.

Verified with toposort NOT installed: the Optimise recipe now runs and
deduplicates correctly (IfcParseExamples_test.ifc 88 -> 63 instances, all
6 products preserved, output reopens cleanly).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:54:49 +10:00
Petru Conduraru 81a42cec1a Bonsai tests: give bSDDClientStub the client baseurl attribute
tool.Bsdd.identifier_url() (pset/ui.py pset name check in the Property
Sets panel) reads client.baseurl unconditionally, but the test stub
never had that attribute, so any scenario that opens the Property Sets
panel dies with AttributeError under the stub. The boolean.feature
scenarios only surfaced this once their STEP id failures were fixed,
the id failure had been masking it. Mirror the real bsdd.Client
default so identifier_url() resolves to the standard identifier URL.

This change was made with the assistance of an AI tool.
2026-07-24 14:45:18 +10:00
Petru Conduraru 45fa04a94b Bonsai tests: stop hardcoding STEP ids in boolean.feature
The two boolean.feature scenarios pinned representation item objects by
absolute STEP id (Item/IfcHalfSpaceSolid/90, the BBIM_Boolean pset text
[91]). Those ids shift every time any earlier entity allocation in an
empty project changes (latest instance: #8577 moved 90 to 86), so this
cluster re-breaks on unrelated commits.

Make the object-name and panel-text BDD steps run their argument through
replace_variables, the same substitution 'the variable' and the
connection steps already use, and have boolean.feature capture the real
ids from the IFC file (by_type(...)[0].id()) into variables at the point
the entities are created. The steps stay strict: the substituted name
must still resolve to exactly the named object, there is no wildcard
matching. Substitution is a no-op for every existing feature string
without a {variable} placeholder.

This change was made with the assistance of an AI tool.
2026-07-24 14:45:18 +10:00
Petru Conduraru e27624c77f Fix ci-bonsai-daily BDD: OperatorSpy.bl_rna + stale MEP port name
Two independent test-harness/fixture defects in test/bim/test_feature.py:

- OperatorSpy had no bl_rna, so any BDD step that redraws a panel calling
  helper.draw_filter() (which tests "module" in op.bl_rna.properties)
  crashed with AttributeError. Give OperatorSpy a bl_rna property that
  forwards to the real registered operator class
  (bpy.types[bl_idname].bl_rna), matching live UILayout.operator()
  semantics. Fixes test_select_all_walls and test_edit_filter_query.
- The shared "I create default MEP types" step looked up
  bpy.data.objects["IfcDistributionPort/Port"], but port creation never
  sets port.Name, so tool.Loader.get_name deterministically names the
  object "IfcDistributionPort/Unnamed". Update the literal. Fixes the MEP
  scenarios (connect/transition/bend) that share this setup.

Verified in headless Blender: OperatorSpy scenarios 2 passed (were
AttributeError); MEP test_connect_mep_elements* go from
KeyError 'IfcDistributionPort/Port' to passing.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:39:53 +10:00
Ryan Schultz 836d57e7ff Fix #7774: Fix Select Similar failing on pset names with spaces
Pset names containing spaces (e.g. "SOLIDWORKS Custom Properties") were
not quoted when building selector keys in SelectSimilarData, causing
get_element_value to fail when the operator ran. Now wraps pset names
and property names in double quotes if they contain spaces, consistent
with the selector syntax used elsewhere.

Generated with the assistance of an AI coding tool.
2026-07-24 14:30:29 +10:00
Petru Conduraru 3d7d1ff4f3 ci: drop the ColumnPSetsOfSets.ifc fixture change, conflicts upstream
Per aothms's review comment: this file's schema was already changed
independently on v0.8.0 since this branch was created, so this PR's own
edit conflicts with it. Reverting to the current upstream version of the
fixture; the bsdd.py rate-limiting fix is untouched.
2026-07-24 14:28:18 +10:00
Petru Conduraru 6911418c67 ci: fix bSDD 429 rate limiting and restore ColumnPSetsOfSets.ifc schema
bsdd.py: the Client made every request with a bare requests.get, so a single
429 from the (unauthenticated, aggressively rate limited) bSDD API failed the
whole test. Route requests through a Session with a mounted urllib3 Retry
(5 attempts, backoff, honouring Retry-After) for 429/5xx, matching how a
resilient API client should behave, not just papering over the test.

ColumnPSetsOfSets.ifc: FILE_SCHEMA was accidentally changed from IFC4X3_ADD2
to IFC2X3 in a7738eeb64 (an unrelated logger refactor), a one line collateral
edit to this fixture. The file's DATA section still uses IFCPROPERTYSETDEFINITIONSET,
an IFC4+ only type. Parsing it against IFC2X3 threw "Entity ... not found in
schema", which silently fell back to interpreting the value as a raw nested
aggregate instead of the intended defined-type wrapper, producing the
double-nested tuple that broke test_stream, test_file and test_rocks in
test_streaming_rocksdb_and_simpletyperefs.py. Restoring the original schema
declared when the fixture was added (ff3fa48332) fixes all three.

Generated with the assistance of an AI coding tool.
2026-07-24 14:28:18 +10:00
Petru Conduraru 5c8eab981c Fix ci-bonsai-daily: get_dictionaries no longer clobbers injected client
Bsdd.get_dictionaries() unconditionally did cls.client = bsdd.Client(),
replacing whatever client was already set - including the
bSDDClientStub the BDD suite injects at module load
(test_feature.py: tool.Bsdd.client = bSDDClientStub()) to avoid live
network calls. Because "Load bSDD Dictionaries" is the first step of
every bsdd.feature scenario, the stub was discarded before its fixture
data ("LCA", "BonsaiTestDict") could ever be returned.

The re-init is unnecessary: bsdd.Client.__init__ only sets baseurl and
blank tokens, and the next line already updates baseurl defensively via
hasattr. Drop the clobbering assignment; reuse whichever client is
already set.

Verified in headless Blender: bsdd scenarios (load dictionaries, search
all/single dictionary) go from 3 failed ("Could not see LCA/
BonsaiTestDict") to 3 passed.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:24:34 +10:00
Petru Conduraru a155a1ca80 Fix ci-bonsai-choco: choco_release.py still targets the old choco/blenderbim path
The choco dir was renamed from choco/blenderbim to choco/bonsai back in
2024 (Rename choco dir), but choco_release.py's BLENDERBIM_DIR constant
was never updated, so the daily choco release job crashes immediately
with FileNotFoundError trying to os.chdir into the now nonexistent
choco/blenderbim directory.

Release tags also moved from a bare blenderbim-YYMMDD scheme to
bonsai-X.Y.Z-alphaYYMMDDHHMM, so the tag-prefix strip used to build the
package version still looked for the old "blenderbim-" prefix and left
it untouched, embedding the raw tag (including the already-present
"-alpha" segment) into the nuspec version field, which the template
then doubled up with its own "-alpha" suffix, producing an invalid
NuGet version string. Both are fixed together since the second bug
would otherwise surface as soon as the first one is unblocked.

The pre-commit black hook also reformatted pre-existing whitespace
drift in choco_release.py (this file sits outside CI's lint scope, so
it had never been auto-formatted before); that reformatting is
incidental to satisfying the local hook, not part of the fix itself.

Generated with the assistance of an AI coding tool.
2026-07-24 14:23:00 +10:00
Petru Conduraru 8c434b7167 ifcopenshell.util.element: dedupe SET-typed attributes in replace_attribute
replace_attribute() rewrites references inside aggregate attributes via
element.walk(), but never checked whether the replacement value was
already present elsewhere in the same aggregate. For an EXPRESS SET
(e.g. IfcProject.RepresentationContexts, IfcRelAggregates.RelatedObjects)
this can leave the same reference listed twice, which is invalid IFC.
LIST and BAG aggregates legitimately allow duplicates, so a blanket dedup
would be wrong; only SET-typed attributes are deduplicated, determined at
runtime from the schema declaration (IfcOpenShell#8706 review comment).

The SET/LIST/BAG check is cached per (schema, class, attribute index), and
the dedup pass itself only runs when a cheap linear pre-check finds the
replacement value already present in the aggregate, so the common case
(no duplicate produced) pays only that pre-check, not a hash-set rebuild.
Benchmarked against a 23MB (431k entities) and a 104MB (2.4M entities) IFC
model against a large SET attribute: worst case adds well under 1ms per
call; the realistic case (merging duplicate contexts, matching the PR
#8706 scenario) shows no measurable regression.

Fixes the root cause flagged in IfcOpenShell#8706 (Moult), obviating the
need for MergeDuplicateContexts' own manual aggregate-dedup pass for that
scenario.

Generated with the assistance of an AI coding tool.
2026-07-24 14:21:31 +10:00
Petru Conduraru 8fb8966094 docs: cover reading properties and quantities from an element and its type in C++ getting started
Fixes issue #3910's documentation gap. IsDefinedBy() returns
IfcRelDefinesByProperties relationship objects, not the property set
itself, and RelatingPropertyDefinition() must be used to reach the
IfcPropertySet or IfcElementQuantity. Properties can also come from an
element's type via IsTypedBy() -> RelatingType() -> HasPropertySets(),
a path that is easy to miss because it works differently. Adds a
worked, beginner-commented, compilable example covering both paths.

Generated with the assistance of an AI coding tool.
2026-07-24 06:14:34 +02:00
Petru Conduraru d506f5df1e Bonsai: compute earthworks base quantities in ifc5d take-off #6325
The ifcopenshell take-off engine left every Qto_EarthworksFillBaseQuantities
value null, so Bonsai added the qset with no numbers on IFC4X3 models. Map the
geometrically derivable quantities using the slab axis convention: Length on
local X, Width on local Y, Depth on local Z, and the net solid volume as the
compacted (Fill) or undisturbed (Cut) volume. LooseVolume and Weight stay
unmapped because they need soil bulking and density factors absent from
geometry. Bring IfcEarthworksCut to parity with the Blender engine and wire
IfcReinforcedSoil on both engines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:02:57 +02:00
Petru Conduraru e23142d01a Fix #7331: derive cost item quantities from IfcSpace base quantities
assign_cost_item_quantity skipped every IfcSpatialElement, which also
swallowed IfcSpace. Spaces are legitimate quantifiable objects, so their
Qto_SpaceBaseQuantities (for example GrossFloorArea) were never picked up
and count based cost items fell back to 0. Keep skipping spatial
containers (site, building, storey) but allow IfcSpace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:58:44 +02:00
Petru Conduraru 6085894433 Fix #8570: populate ifc5d IfcOpenShell QTO formulas for IfcSpace
The headless "IfcOpenShell" calculator had all Qto_SpaceBaseQuantities
formulas set to null for IfcSpace in both IFC4QtoBaseQuantities.json and
IFC4X3QtoBaseQuantities.json, so qto.py's `if not formula: continue`
skipped every quantity, no geometry task was queued, and spaces never
appeared in results (elements_quantified: 0). The Blender calculator
already computes these; they were just never ported to the
ifcopenshell.util.shape-backed calculator.

Map the eight computable quantities to existing util.shape functions,
mirroring the Blender calculator semantics (no new shape.py code):
GrossFloorArea=gross_get_footprint_area, NetFloorArea=net_get_footprint_area,
GrossCeilingArea=gross_get_top_area, NetCeilingArea=net_get_top_area,
GrossPerimeter=gross_get_footprint_perimeter, GrossVolume=gross_get_volume,
NetVolume=net_get_volume, Height=net_get_z.

Left null (matching the Blender ruleset, not guessed): GrossWallArea,
NetWallArea, NetPerimeter (Blender stub), and FinishFloor/CeilingHeight
(Blender derives these from sibling IfcCovering decomposition geometry,
which this per-element calculator architecture can't reach).

Verified on IFC4 (4x3 space extruded 2.5m): before -> {} / elements_quantified 0;
after -> GrossFloorArea 12, GrossPerimeter 14, Height 2.5, GrossVolume 30,
etc. - all exact matches to the extrusion. IFC4X3 formulas are identical
and the formula->function resolution is schema-agnostic.

Scope: fixes the IfcSpace case (the issue title). The 12 other all-null
classes noted in the issue (IfcDoor, IfcSite, IfcRailing, ...) are left as
follow-up.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:55:02 +02:00
Petru Conduraru c61ebe3876 Fix IfcCovering Qto_CoveringBaseQuantities mismatch between calculators (#6728)
The ifc5d IfcOpenShell (geometry-based) Qto engine computed
Qto_CoveringBaseQuantities using axis-agnostic heuristics:

- GrossArea/NetArea: gross_get_max_side_area / net_get_max_side_area,
  the largest of the X/Y/Z projected side areas.
- Width: gross_get_min_xyz, the smallest of the X/Y/Z dimensions.

The Blender Qto engine instead already used
EPset_Parametric.LayerSetDirection (AXIS2 for wall-like coverings,
AXIS3 for floor/ceiling-like coverings) to pick the correct axis via
get_covering_gross_area/get_covering_net_area/get_covering_width in
bonsai/bim/module/qto/calculator.py.

For any covering whose length isn't the largest dimension (e.g. a
short wall-covering strip, or a small covering patch), the two
engines' heuristics can pick different faces/axes entirely, giving
different Width/Area values for the same element - this is what was
reported in #6728.

Fix: give the IfcOpenShell engine the same layer-set-direction
awareness. Added IfcOpenShell.get_covering_parametric_axis/
get_covering_area/get_covering_width (dispatched as internal
functions, like the existing get_weight/get_segment_length), and
wired gross_get_covering_area/net_get_covering_area/
gross_get_covering_width into the IfcCovering rules in
IFC4QtoBaseQuantities.json and IFC4X3QtoBaseQuantities.json.

The AXIS2 area/width formulas (get_side_area, net_get_y) intentionally
match the simpler formulas already used for Qto_WallBaseQuantities in
this same rule set (net_get_side_area/net_get_y), rather than
replicating the Blender engine's more elaborate get_lateral_area/
get_width (min(X,Y)) helpers, consistent with how the two engines
already diverge for regular walls without being considered a bug.

Verified with a standalone script driving ifc5d.qto.IfcOpenShell
directly against synthetic AXIS2/AXIS3 IfcCovering geometry: for
typical proportions old and new formulas agree, and for
disproportionate coverings (thin dimension not the smallest/largest)
the old formulas picked the wrong axis while the new ones correctly
track the covering's LayerSetDirection, matching the Blender engine.
Did not verify through the full Blender/Bonsai UI, as it would have
required registering the addon in the machine's shared Blender
profile, which is unsafe while other agents may have it loaded.

Generated with the assistance of an AI coding tool.
2026-07-23 19:51:03 +02:00
carlopav 305f8c6003 cost: don't leave copied cost items in the copied schedule (#8851)
copy_cost_item appends the copy to the inverse relationships of the
original cost item, which for a root cost item includes the source
schedule's IfcRelAssignsToControl. copy_cost_schedule then assigned that
same cost item to the new schedule as well, so the copies showed up in
both schedules and deleting them from one removed them from the other.

Unassign the copy from the source schedule before assigning it to the
new one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:04:38 +02:00
Petru Conduraru 98a25eca96 Bonsai: wire Shift+Q quantity take off hotkey into Spatial tool
Fixes #4443. The Wall/Slab/other authoring tools (BimTool subclasses)
already bind Shift+Q to bim.perform_quantity_take_off via hotkey_S_Q,
but the Spatial tool has its own separate keymap/operator
(bim.spatial_hotkey) that never registered a Q entry, forcing users to
switch tools just to (re)calculate quantities for a selected element.
Added the same Shift+Q keymap entry and a matching hotkey_S_Q handler
to the Spatial tool, mirroring BimTool's existing behavior exactly
(including the same selected-objects guard).

The other part of the request, a bulk "calculate all quantities"
entry point, already exists today: bim.perform_quantity_take_off
computes quantities for every IfcElement when no objects are
selected, exposed via the Scene > Quantity Take-off panel regardless
of which workspace tool is active, so no change was needed there.

AI-generated, reviewed and tested by BIMvoice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 07:19:04 +02:00
Petru Conduraru 99c370b755 Bonsai: allow nesting element type objects together (#2283)
can_nest() only permitted IfcElement-to-IfcElement pairs, so nesting
two IfcElementType objects (e.g. an IfcElementAssemblyType nesting a
component IfcDoorType) was silently rejected. IfcRelNests.RelatingObject/
RelatedObjects are typed as the general IfcObjectDefinition in the
schema, so type-to-type nesting is schema legal, IfcOpenShell's core
nest.assign_object API already handles it generically, and the Nest
UI panel is driven purely by ifcopenshell.util.element.get_nest/
get_components (IFC data queries, not Blender collection structure),
so once the relationship exists it displays correctly with no other
changes needed.

Extended is_compatible_class to also accept a same-kind IfcTypeProduct
pair. Mixing an occurrence element with a type is intentionally still
rejected, that isn't a real modeling pattern.

Verified live in headless Blender: type-to-type nesting now creates
a real IfcRelNests and the Nest panel's own data functions reflect
it correctly; mixing an occurrence with a type is still rejected;
existing element-to-element nesting is unaffected.

Generated with the assistance of an AI coding tool.
2026-07-23 07:19:04 +02:00
Petru Conduraru f3cb7aea61 ifc5d: wire up GrossFootprintArea/NetFootprintArea for IfcWall QTO
Root cause: the IfcOpenShell-geometry-engine calculator ruleset
(IFC4QtoBaseQuantities.json and IFC4X3QtoBaseQuantities.json) left
IfcWall's GrossFootprintArea/NetFootprintArea mapped to null, so
these two quantities were silently omitted from Qto_WallBaseQuantities
whenever that ruleset was used. The generic gross_get_footprint_area
and net_get_footprint_area formulas already exist and are already
wired up for IfcSlab in the same files, so this was a missing mapping,
not a missing implementation.

Fixes #7029.

Generated with the assistance of an AI coding tool.
2026-07-23 07:16:01 +02:00
Andrej730 84b2cf6db0 dev-setup: use Python 3.13 2026-07-22 18:47:44 +05:00
Andrej730 113643c916 dev_environment.py: add --skip-binaries flag 2026-07-22 18:36:24 +05:00
Andrej730 f0e6cfecc1 cmake: skip compiled extensions when installing ifcwrap sources 2026-07-22 18:31:48 +05:00
Andrej730 7338736898 ColumnPSetsOfSets.ifc: restore original schema
It seems it was switched to ifc2x3 by accident.
Related - a7738ee 6c590bf00
2026-07-22 17:24:40 +05:00
Andrej730 e9e2f89649 Revert "Sync ifcopenshell_wrapper.pyi with sync_stub.py"
This reverts commit b61f809731.

This commit was probably using not updated build, currently latest build is e333c1c and can confirm that it has `logger_or_root` added and `delete_same_facet_edge_pairs` removed.
2026-07-22 15:19:16 +05:00
Petru Conduraru 4ceadd8f10 Fix IfcFooting Qto_FootingBaseQuantities axis mapping per predefined type #4783
Footings are authored two ways with different local axis conventions. Beam-like
footings (STRIP_FOOTING, FOOTING_BEAM) are a profile extruded along local Z, so
Length is local Z and the cross section sits on local X (Width, horizontal) and
local Y (Height, vertical). Slab-like footings (PAD_FOOTING, PILE_CAP) have their
footprint on local X/Y and their thickness (Height) on local Z.

The engine rule set is keyed per IfcFooting and cannot branch on predefined type,
so the previous static rule (Height=net_get_z, Length=net_get_max_xy, Width=null)
swapped Length and Height for beam-like footings and never emitted Width.

Add predefined-type-aware get_footing_length/width/height to the IfcOpenShell and
Blender calculators, and point the IfcFooting rule at them in all four IFC4/IFC4X3
ios/Blender rule files.

Confirmed by authoring footings through the real Bonsai generators and measuring
world-axis orientation: a beam-like footing with a 0.3 wide by 0.6 tall cross
section and 6.0 run reports Length 6.0, Width 0.3, Height 0.6, with the 0.3
physically horizontal and 0.6 physically vertical; a 2.0x1.5x0.3 pad reports
Length 2.0, Width 1.5, Height 0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:05:04 +02:00
falken10vdl 0cfc77030d Merge pull request #8843 from IfcOpenShell/bonsai-6680-material-rename
Bonsai: right-click Rename Material on material rows (#6680)
2026-07-21 23:31:03 +02:00
Petru Conduraru efac8a0ec0 ifc5d: measure openings in their real orientation on both take-off engines
See #6835. Qto_OpeningElementBaseQuantities came out axis-scrambled for
openings authored in a Z-up local frame (X along the voided wall, Y
through it, Z vertical), which is how Bonsai authors every wall opening:

- The IfcOpenShell engine mapped Height to the local Y extent and Depth
  to the local Z extent, so a 0.9 x 2.0 door opening with Bonsai's
  default 1.2m void depth reported Height 1.2 and Depth 2.0, and Area
  (max side area) picked the through-wall side, 2.4 instead of 1.8.
  This matches the wrong Height=1.2/Area=1.2 screenshots reported for a
  1x1 window opening in #6835.
- The Blender engine mapped opening Width to get_length, which returns
  the longest bounding box edge, i.e. the opening height for typical
  door openings (the same defect 4adaf0d fixed for IfcDoor Width), and
  get_opening_depth used min(x, y), which returns the opening width
  whenever the width is smaller than the void depth.

The IfcOpenShell engine now has opening-aware internal calculators
(get_opening_width/height/depth/area) that detect horizontal (slab
style) openings with the same heuristic as the Blender calculator, so
slab opening depths keep reporting the slab thickness. The Blender
ruleset uses get_x for opening Width, and get_opening_depth measures the
through-element Y extent for vertical openings.

Door and window quantities themselves are addressed separately: the
Blender engine door Width was fixed in 4adaf0d, and the remaining
door/window defects (door not quantified on the IfcOpenShell engine,
inflated areas) are fixed by the attribute-based calculators in #8389.

Generated with the assistance of an AI coding tool.
2026-07-21 21:58:35 +02:00
Petru Conduraru 7ab0628c54 Bonsai: refresh material data unconditionally instead of forcing a redraw
falken10vdl reviewed 16b1b4e7b1 on #8843 and pointed out that tagging
every area for redraw was overkill. The actual problem was that the
Object Material panel and the scene Materials list read from plain
python caches (ObjectMaterialData and MaterialsData) that only get
invalidated when the Materials editing UI list is reloaded, which
never happens while you are not in editing mode. The redraw itself was
never the issue, closing the rename dialog already triggers one.

Removed the tag_redraw loop from RenameMaterial and instead call the
existing bonsai.bim.module.material.data.refresh() function from
core.rename_material, unconditionally, through a new tool.Material.refresh()
method. This is the same invalidate-on-next-load mechanism already used
by every other module's Data classes, just wired up for this operator
too, instead of introducing a new one.

Also updates the core tests to prescribe the new unconditional refresh()
call, and adds tool-layer coverage for tool.Material.refresh().

Generated with the assistance of an AI coding tool.
2026-07-21 21:08:21 +03:00
Petru Conduraru 16b1b4e7b1 Bonsai: refresh the UI after renaming a material
theoryshaw tested #8843 and asked for the new name to show up right
away instead of needing a manual refresh. The Object Material panel
and the scene Materials list both already re-read live IFC data on
their next draw (tool.Ifc.Operator purges those caches after every
IFC-mutating operator), so the button text was correct on the next
redraw. What was missing was the redraw itself: the material name is
a plain button label, not an RNA property Blender tracks, so nothing
told the Properties editor to repaint after the rename dialog closed.
Tag every area for redraw once the rename completes, the same pattern
used elsewhere in Bonsai for popup-triggered edits that need an
immediate repaint.

Also adds core-layer test coverage for rename_material, which had
none.

Generated with the assistance of an AI coding tool.
2026-07-21 17:11:56 +03:00
Petru Conduraru 8c667b8ae0 Bonsai: right-click rename on a material name (#6680)
Adds a "Rename Material" entry to the context menu that already
extends every button in the properties editor (UI_MT_button_context_menu),
triggered when right-clicking a material name button
(bim.select_by_material) that points to a real IfcMaterial. This
gives a quick entry point to renaming from the Object Material panel
without navigating to the scene Materials list.

This follows the pattern that #6680's thread converged on: theoryshaw
requested a right-click entry (rather than a pencil icon or
double-click) that keeps the existing single-click select-by-material
behaviour intact. falken10vdl is the issue's assignee; this is offered
as a starting point for that discussion, not a replacement for it.

Generated with the assistance of an AI coding tool.
2026-07-21 15:54:59 +03:00
Ryan Schultz e52e5e2e58 Bonsai: add category-level select-all to the Drawings list (#8826)
Add an "Is Selected" checkbox to each target-view category header in
BIM_UL_drawinglist that toggles selection for all drawings in the
category. The toggle only affects drawings currently visible in the
list (honoring the show_drawings_on_sheets_only filter), and the header
checkbox reflects the aggregate selection state of its drawings.

Also make category headers more obvious: wrap them in a box() for a
distinct inset background and make the header name clickable to
expand/contract the category (same as the disclosure triangle).

Ref: #8825

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:39:22 -05:00
Ryan Schultz 2d59ea1988 Bonsai: add toggle to show only drawings placed on sheets (#8824)
Adds a "Show Only Drawings on Sheets" toggle below the drawing list. When
enabled, the list is filtered to drawings referenced by at least one sheet
(target-view headers with no sheeted drawings are hidden too), and
bim.select_all_drawings only acts on the visible/filtered drawings.

A drawing is considered sheeted when its drawing document Location matches a
document reference Location on any SHEET-scoped IfcDocumentInformation.
Filtering is computed live so it reflects sheet edits without reloading.

Closes #8823

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:03:56 -05:00
Petru Conduraru 55a2430d71 docs: cover the Blender 5.1 / Python 3.13 transition in installation guides (#8781)
* docs: cover the Blender 5.1 / Python 3.13 transition in installation guides

The system requirements still listed Blender 4.3-4.5 with Python 3.11
only, and nothing documented the pitfall from issue 7623: importing
preferences into a Blender whose Python version changed carries over an
incompatible Bonsai build that silently fails to load. Document the two
Python generations, that Get Extensions picks the matching build
automatically while manual zip installs do not, and the
uninstall-reinstall step that resolves the upgrade case.

Generated with the assistance of an AI coding tool.

* docs: keep it simple, only Blender 5.1 and 5.2 with Python 3.13

Per review, drop the descriptive text and the Python 3.11 line.

Generated with the assistance of an AI coding tool.
2026-07-20 16:27:51 +10:00
Petru Conduraru 04a2535a98 Preserve the real cause when the ifcopenshell wrapper fails to load (#8785)
* Keep real cause in wrapper ImportError

When the compiled wrapper exists for the current interpreter but fails
to load (for example a glibc version mismatch, as on AWS Lambda in
issue 5927), the bare except rewrote the error into the misleading
"IfcOpenShell not built for '<platform>'" message. Environments such
as AWS Lambda or the Blender add-on dialog only surface the final
exception message, so the actual cause was invisible and undiagnosable.

Keep the "not built for" message only when no matching binary is
present, and otherwise include the original loader error, chaining the
cause in both branches.

This change was AI-generated.

Fixes #5927

* Simplify wrapper import failure to a single message

Per review feedback, drop the filesystem scan and the two message
variants. Always raise the classic "IfcOpenShell not built for
'<platform>'" message with the original exception appended in
parentheses, still chained as the cause. Environments that only show
the final exception message (AWS Lambda, the Blender add-on dialog)
now surface the real loader error, such as the glibc version mismatch
in issue 5927, without any extra logic.

This change was AI-generated.
2026-07-20 16:27:22 +10:00
Petru Conduraru 727b5f3475 Bonsai: add Hour zoom level to the interactive Gantt chart
The jsGantt-improved library that renders Bonsai's Gantt chart already
ships full support for an "Hour" granularity (column width, header
labels in every bundled language, hour-aware rendering math). Bonsai's
config only exposed Day/Week/Month/Quarter, with a comment claiming
Hour caused browser issues even with vUseSingleCell enabled.

Headless Chrome testing against the same library version shows that
claim no longer holds once vUseSingleCell is active (as Bonsai already
configures it at 10000): Hour-format charts render without errors from
typical schedules up through fairly extreme ones (5000 tasks across a
3 year span rendered in about 2.4s). The failure mode the old comment
described only reproduces with vUseSingleCell disabled, which is not
how Bonsai runs it.

Task start/finish times already flow through to the chart unmodified
as raw ISO datetimes (tool/sequence.py create_new_task_json), so any
schedule authored with real hour-level timestamps, for example an
imported MS Project/P6/Excel schedule or one written directly through
ifcopenshell-python, can now be viewed at hour granularity. Verified
live with a night shift schedule crossing midnight, rendered correctly
with no console errors.

Note: Bonsai's own "Edit Task Time" UI currently always snaps
ScheduleStart/ScheduleFinish to 09:00/17:00 regardless of the hour
entered (ifcopenshell/api/sequence/edit_task_time.py), and work
calendars only encode working days, not working hours. So authoring a
genuine hour-precision schedule through that UI is still not possible;
this change only unlocks viewing hour-level data that already exists
in the model. Fixing the editor and calendar model is a separate,
larger design decision for a maintainer.

Addresses #2772.

Generated with the assistance of an AI coding tool.
2026-07-20 10:19:42 +10:00
Bartok a45f2fae61 docs(ifc2ca): fix script paths in README
Point scriptSalome.py at templates/salome/ and the bonded scripts at
_deprecated/, matching the current tree so README links resolve.

Generated with the assistance of an AI coding tool.
2026-07-20 09:53:35 +10:00
dependabot[bot] 1ae50b8cce build(deps): bump ruff from 0.15.12 to 0.15.22 (#8212)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.22.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.22)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 09:52:46 +10:00
dependabot[bot] 9fda996ebe build(deps): bump ruff from 0.15.12 to 0.15.22 (#8497)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.22.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.22)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 09:52:38 +10:00
dependabot[bot] 7a7a250942 build(deps): bump ruff from 0.15.12 to 0.15.22
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.22.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.22)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 09:52:07 +10:00
Petru Conduraru bc1fb2a88d Bonsai: add one click copy of annotations to another drawing (#8719)
* Bonsai: move annotations between drawings when reassigning their group

Assigning an IfcAnnotation to a group that represents another drawing
previously left the annotation in both drawings at once: it stayed in
its old drawing group, its Blender object stayed in the old drawing
collection, and it kept the old camera depth, so the reassignment
appeared to do nothing useful. Issue #2966 documents the seven step
manual workaround users needed instead.

The assign group operator now detects when the target group represents
a drawing (via the new tool.Drawing.get_group_drawing, the inverse of
get_drawing_group), unassigns the annotation from its previous drawing
group, moves its object into the new drawing collection, and places it
on the new drawing camera plane. The target camera is imported on
demand when it has not been loaded yet, matching the pattern used by
the activate drawing operator.

Generated with the assistance of an AI coding tool.

* Bonsai: add one click copy of annotations to another drawing (#2966)

Duplicating an annotation into a different drawing used to require a
seven step manual process: loading groups in scene properties, copying
the object, fixing its group assignment by hand, and repositioning it
onto the target camera plane. A plain Blender duplicate is not enough
because the copy keeps pointing at the same IFC entity, and the Shift D
override, while it does create a genuine new entity through
root.copy_class, leaves the duplicate in the source drawing group,
collection, and camera depth.

The new copy annotation to drawing operator packages the proven recipe
already used by duplicate drawing into one action: duplicate through
tool.Geometry.duplicate_ifc_objects, unassign the copy from the source
drawing group, assign it to the chosen target group, place it on the
target camera plane at the same world XY, and file it into the target
drawing collection. The originals are left untouched and the user's
selection is restored. The target camera is imported on demand when it
has not been loaded yet.

The operator shows a target drawing dropdown and is reachable from the
annotation tool sidebar when an annotation is selected, and from the
drawings panel. Annotations already in the target drawing are skipped
and reported.

The orchestration lives in core.drawing.copy_annotations_to_drawing
with prophecy tests covering the copy, the skip, and the camera import
branches. Verified live in headless Blender 5.1: the copy is a new
IfcAnnotation with its own GlobalId and IfcTextLiteral, both texts are
editable independently, and everything survives save and reload with
each annotation loading in its own drawing.

Generated with the assistance of an AI coding tool.
2026-07-20 09:50:14 +10:00
Petru Conduraru c55a79b8b5 bonsai: allow overriding which classes join in section linework (#4395) (#8617)
Fixes #4395.

Root cause: the SVG cut-linework merge step that fuses adjacent
elements' cut polygons together (per the pset-driven JoinCriteria
setting) was hardcoded to only IfcWall and IfcSlab. IfcCovering cut
shapes were skipped unconditionally, so adjacent coverings never
joined, leaving a visible seam/broken corner in section drawings
regardless of JoinCriteria.

Fix: added an EPset_Drawing.JoinClasses property, following the
exact same user-overridable pattern already used by
EPset_Drawing.BringToFront - a comma-separated list of IFC classes
to join, defaulting to "IfcWall,IfcSlab" (unchanged behavior) when
unset. Users can override per-drawing to add IfcCovering (or any
other class) when they want it joined too. Kept this opt-in rather
than hardcoding IfcCovering into the default list, since joining a
thin finish layer the same way as a thick wall/slab could produce
unwanted mitring in some cases - the user decides per drawing.

Verified live against the reporter's own attached file
(ifcovering joining.ifc) and its cached section linework: with
JoinClasses unset, two separate closed paths reproduce the reported
seam exactly. With JoinClasses = "IfcWall,IfcSlab,IfcCovering", the
two coverings merge into a single closed polygon with the internal
seam removed. Confirmed IfcSlab join behavior is unchanged in both
runs.

Generated with the assistance of an AI coding tool.

Co-authored-by: Dion Moult <dionmoult@gmail.com>
2026-07-20 09:41:09 +10:00
sboddy b669baf793 Propagate deflection settings on reload (#8484)
reimport_element_representations() built a fresh
ifcopenshell.geom.settings() without copying deflection_tolerance /
angular_tolerance from the IfcImportSettings it had just
constructed, and never passed geometry_library to either the
iterator() or create_shape() calls it makes. As a result, exiting
Item/edit mode (which reaches this function via
switch_representation) silently fell back to IfcOpenShell's
hard-coded mesher defaults (0.001 linear deflection, ~50x finer than
the project's default of 0.05) and the default geometry kernel,
instead of the project's configured tolerance and Geometry Library.

This made geometry visibly change quality after a no-op Tab into and
back out of edit mode, since the reload path was unintentionally far
more precise (and used a different kernel) than the initial import.
Both settings, and geometry_library, are now taken from the
IfcImportSettings instance already built at the top of the function,
so a reload matches the original import.

Refs #5685.

Generated with the assistance of an AI coding tool.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 09:37:04 +10:00
Petru Conduraru 32ac20e8e3 Bonsai: refresh the arc/circle decorator immediately after duplicating a loop
theoryshaw's follow-up on #6944: after the profile/curve reconstruction fix
(previous commit), the arc/circle marker for a freshly Shift+D-duplicated
loop wouldn't appear until leaving and re-entering Edit Mode.

Root cause: ProfileDecorator groups arc/circle vertices purely by
IFCARCINDEX/IFCCIRCLE vertex-group index every draw call (it has no cache
to go stale, it fully recomputes from the live edit-mesh bmesh each frame).
Duplicating a loop copies its vertex-group weights onto the new geometry,
since Blender allocates no new group for a duplicate, so the source loop
and its live duplicate land in the same dict entry. That entry then fails
the "exactly 2 verts per circle / 3 per arc" check and is skipped entirely,
so BOTH the original and the duplicate stop being drawn until the mesh is
reimported and gets fresh, distinct groups.

Verified live in headless Blender: built a bmesh with an IFCCIRCLE loop and
an IFCARCINDEX loop, then ran bmesh.ops.duplicate on each (the same
bmesh-level operation underlying Shift+D) and called ProfileDecorator's
draw method directly. Before this change, duplicating either loop dropped
both the original and the duplicate from the decorator (0 circle/arc
batches drawn instead of 2). After, both draw immediately, with no change
to the non-duplicated case (still 1) or to genuinely distinct loops (5
independent circles still resolve to 5, not merged). 500-circle timing is
unchanged (~14.3ms/draw before and after), so the added connectivity split
is not a hot-path regression.

Added test/bim/module/model/test_profile_decorator_duplicate_loop.py
pinning the new _connected_components helper's behavior for single and
duplicated circle/arc loops.

This contribution was produced with the assistance of an AI coding tool.
2026-07-20 09:34:07 +10:00
Petru Conduraru 0d5ea02169 Bonsai: fix the same duplicate-loop vertex-group bug in auto_detect_curves
auto_detect_profiles had the identical defect fixed in the previous
commit: duplicating a circle/arc loop in Edit Mode reuses the same
IFCCIRCLE/IFCARCINDEX vertex group index for the new geometry, and this
sibling function (used for curve/annotation editing rather than profile
voids) tallied group membership across the whole mesh instead of per
loop, so it also rejected a legitimately duplicated loop as malformed.

Applied the identical fix: scope the group-count sanity check to each
connected edge loop, computed after the loops are built rather than in
the initial whole-mesh vertex pass. Kept the existing forked-loop check
(more than 2 edges per vertex) in the first pass since it is unrelated
to group counting.

Verified live in headless Blender: constructed two 2-vertex IFCCIRCLE
loops sharing one vertex group index (the exact state Blender's Edit
Mode duplicate produces) and called auto_detect_curves directly.
Before this change it returned (False, "CIRCLE"); after, it returns two
valid IfcCircle curves.

Generated with the assistance of an AI coding tool.
2026-07-20 09:34:07 +10:00
Petru Conduraru 0a027d47a3 Bonsai: fix profile reconstruction after duplicating a circle/arc in Edit Mode
Duplicating a circular or filleted-arc void in the profile CAD editor
(Shift+D on the loop's vertices) reused the same IFCCIRCLE/IFCARCINDEX
vertex group index for the new geometry, since Blender's mesh duplicate
copies vertex group weights but does not allocate a new group. On exit
from Edit Mode, auto_detect_profiles tallied group membership across the
whole mesh rather than per loop, so a group meant to hold exactly 2 (circle)
or 3 (arc) vertices ended up with double that, failing its sanity check
and blocking the edit with an "INVALID PROFILE" popup. Fixes #6944.

Scope the sanity check to each connected edge loop instead, matching how
the loops are actually converted into IfcCircle/arc segments below. Also
explicitly reject an arc/circle vertex tagged onto an isolated vertex with
no edges at all, which the old whole-mesh count also caught.

Verified live in headless Blender against the issue's repro file
(IfcFurniture "Slab.004", IfcArbitraryProfileDefWithVoids with three
IfcCircle voids): entering the profile editor, duplicating one void's
2-vertex loop and moving it produced an "INVALID PROFILE" popup before
this change, and now produces a valid profile (the original 3 voids
intact, plus the duplicate as a 4th void or a separate solid profile
depending on whether it still falls inside the outer boundary).
test/tool/test_model.py passes unchanged (32 passed, 1 pre-existing
unrelated failure present on both before and after).

Generated with the assistance of an AI coding tool.
2026-07-20 09:34:07 +10:00
Petru Conduraru 6014bbd877 ci-lint: black-format two files that drifted on v0.8.0
Both files were merged unformatted and fail the Black formatter step
on every branch, keeping ci-lint red repo-wide.

Generated with the assistance of an AI coding tool.
2026-07-20 09:24:34 +10:00
Petru Conduraru 6d6d92b849 Fix all remaining ty type-check failures on ci-lint
The ci-lint workflow's ty steps fail on every branch because base
v0.8.0 has four diagnostics.

ty check (bonsai):

- root/operator.py: bpy.data.objects.get() can return None, so
  UnlinkObject._execute could put None in its objects list and crash
  on the first attribute access when an unknown object name is passed.
  Handle the miss explicitly, which also satisfies the declared
  list[bpy.types.Object] type.
- tool/sequence.py: ty does not narrow Literal types through
  membership tests on list literals, so the assert_never() exhaustive
  check was flagged. Use tuple literals, which ty narrows, keeping the
  exhaustiveness check intact.

ty check (ios):

- draw.py: arrange_polygons was called through conditional argument
  splats that let the same call site work against pre-April-2026
  wrappers lacking arrange_polygon_settings and the logger parameter.
  No runtime bug for current builds, but the dynamic splats cannot be
  typed against the fixed 3-parameter signature. Drop the old-build
  workaround and call the current signature directly, following the
  precedent of 3d8115ebc5 which dropped similar old-build workarounds
  in ifcopenshell.file. Verified against a current wrapper build that
  the direct call arranges polygons and serializes to SVG, with and
  without a logger.
- Optimise.py: igraph is an optional dependency with a guarded import
  and a toposort fallback, but it was missing from the ios type-check
  venv so ty could not resolve it. Add it to type-check-requirements
  next to the toposort fallback that is already listed.

After this, poe ty-bonsai and poe ty-ios both pass cleanly.

Generated with the assistance of an AI coding tool.
2026-07-20 09:23:48 +10:00
Petru Conduraru bb49822f2e Bonsai: make dxf2ifc.py example script skip unsupported DXF entities
The script called Polyline.get_mode() on every modelspace entity, but
that method only exists on POLYLINE entities, so any typical DXF
containing lines, circles or text crashed with AttributeError before
converting anything. Test for POLYLINE polyface meshes with
dxftype()/is_poly_face_mesh instead and skip other entities with a
message, only create the spatial containment relation when products
exist, and take the input/output paths from the command line (matching
obj2ifc.py) instead of a hardcoded input.dxf/test.ifc.

Fixes #2151

This change was written with the assistance of an AI coding tool.
2026-07-20 09:22:44 +10:00
Bruno Postle 21ea58b0e6 Fix Bonsai polyline not enough values to unpack error
Typo was introduced in b35f99e
2026-07-19 23:50:19 +01:00
Ryan Schultz b66d8b2c4d Fix #6652: Extend grab selection to include BBIM_Array members (#7968)
When grabbing an array child, the selection now expands to include
the array parent and all sibling children before the move operator
runs. Mirrors existing behavior for aggregates and nests.

Generated with the assistance of an AI coding tool.
2026-07-19 14:00:44 -05:00
197 changed files with 10788 additions and 773 deletions
+2 -2
View File
@@ -21,12 +21,12 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -9,13 +9,13 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
path: IfcOpenShell
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ifcopenshell_build
+2 -2
View File
@@ -35,12 +35,12 @@ jobs:
aws --version
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -35,12 +35,12 @@ jobs:
aws --version
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ${{ matrix.deps_dir }}
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6 # https://github.com/actions/checkout
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7 # https://github.com/actions/checkout
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-tags: true
fetch-depth: 0
+3 -3
View File
@@ -65,8 +65,8 @@ jobs:
config:
short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout bonsai_unstable_repo repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/bonsai_unstable_repo
token: ${{ secrets.IFCOPENBOT_TOKEN }}
+2 -2
View File
@@ -48,8 +48,8 @@ jobs:
config:
short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -37,8 +37,8 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
@@ -21,7 +21,7 @@ jobs:
date: ${{ steps.date.outputs.date }}
verdate: ${{ steps.verdate.outputs.verdate }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set env
run: echo ok go
@@ -75,7 +75,7 @@ jobs:
echo "ARTIFACTS_DIR=/home/runner/work/artifacts" >> $GITHUB_ENV
fi
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-22.04
needs: activate
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
@@ -86,7 +86,7 @@ jobs:
name: Docker Build, Tag, Push
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
lfs: true
@@ -47,10 +47,10 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -38,10 +38,10 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
publish_website:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Checkout ifctester_org_static_html
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/ifctester_org_static_html
token: ${{ secrets.IFCOPENBOT_TOKEN }}
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+3 -3
View File
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
@@ -85,7 +85,7 @@ jobs:
cmake --build build-ifcopenshell --target install -j "$(nproc)"
- name: Set up Python 3.11
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.11
@@ -120,7 +120,7 @@ jobs:
PY
- name: Set up Python 3.12
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.12
+3 -3
View File
@@ -12,15 +12,15 @@ jobs:
MIN_BLENDER_PY_VERSION: "3.11"
steps:
- name: Action - checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Action - install python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ env.MIN_IOS_PY_VERSION }}
- name: Action - install python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout IfcOpenShell
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -25,7 +25,7 @@ jobs:
echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT
- name: Checkout wasm-wheels
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/wasm-wheels
path: wasm-wheels
+6 -4
View File
@@ -43,12 +43,12 @@ jobs:
CLICOLOR_FORCE: "1"
CMAKE_COLOR_DIAGNOSTICS: "ON"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.11
@@ -257,13 +257,15 @@ jobs:
cd ../ifcdiff && make test || ERROR=1
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifc5d --no-deps
pip install odfpy xlsxwriter
pip install odfpy openpyxl
cd ../ifc5d && make test || ERROR=1
pip install -e ../ifcquery --no-deps
cd ../ifcquery && make test || ERROR=1
pip install -e ../ifcedit --no-deps
cd ../ifcedit && make test || ERROR=1
pip install mcp
# Pinned <2: mcp 2.0.0 renamed mcp.server.fastmcp.FastMCP to
# mcp.server.mcpserver.MCPServer, which ifcmcp doesn't support yet.
pip install "mcp>=1.0,<2"
pip install -e ../ifcmcp --no-deps
cd ../ifcmcp && make test || ERROR=1
pip install -e ../ifctester --no-deps
@@ -11,10 +11,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.x'
+3 -3
View File
@@ -27,12 +27,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
ref: gh-pages
@@ -42,7 +42,7 @@ jobs:
run: |
rsync -av --delete --exclude='.git/' src/ifcchat/ output/
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.x"
- name: Download wheels
@@ -7,7 +7,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
@@ -27,12 +27,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
ref: gh-pages
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Install C++ dependencies
+1 -1
View File
@@ -3,7 +3,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>blenderbim-nightly</id>
<version>blenderbim_build_version-alpha</version>
<version>blenderbim_build_version</version>
<packageSourceUrl>https://github.com/IfcOpenShell/IfcOpenShell</packageSourceUrl>
<owners>fbpyr</owners>
<!-- == SOFTWARE SPECIFIC SECTION == -->
+14 -11
View File
@@ -3,11 +3,12 @@
apt update && apt install git wget curl ptpython mono-devel micro
mkdir -p /home/runner/work/IfcOpenShell && cd /home/runner/work/IfcOpenShell
git clone https://github.com/IfcOpenShell/IfcOpenShell
cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/
cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/
micro choco_release.py # paste this script, comment out push command
export CHOCO_TOKEN="secret_choco_release_token"
python3 choco_release.py
"""
import datetime
import hashlib
import os
@@ -28,7 +29,7 @@ def get_repo_tag_names() -> list[str]:
def request_repo_info(url: str):
req = request.Request(url)
req = request.Request(url)
resp = request.urlopen(req)
if not resp.status == 200:
print(f"[ERROR] could not contact server: {url}")
@@ -85,13 +86,15 @@ def run(command: str) -> None:
start = datetime.datetime.now()
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
URL_BLENDER_CMAKE = "https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake"
RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+<span>Blender (\d+\.\d+)\..+</span>"
RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+<span>Blender (\d+\.\d+\.\d+)</span>"
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
URL_BLENDER_CMAKE = (
"https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake"
)
RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+<span>Blender (\d+\.\d+)\..+</span>"
RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+<span>Blender (\d+\.\d+\.\d+)</span>"
RE_BLENDER_PYTHON_VERSION_MAJ_MIN = r"\(_PYTHON_VERSION_SUPPORTED (\d+\.\d+)\)"
BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/")
BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/")
print("_____ check choco release needed?")
@@ -148,7 +151,7 @@ print(f"{blender_python_version_maj_min=}")
python_version = f"py{found[0].replace('.', '')}"
print(f"{python_version=}")
blenderbim_build_version = target_release_tag.replace("blenderbim-", "")
blenderbim_build_version = target_release_tag.replace("bonsai-", "")
# url_blenderbim_py3x_win_zip
release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag)
@@ -166,15 +169,15 @@ topics = {
"path": HERE_DIR / "blenderbim.nuspec",
"key_values": {
"latest_blender_version_maj_min_pat": latest_blender_release_maj_min_pat,
"blenderbim_build_version" : blenderbim_build_version,
"blenderbim_build_version": blenderbim_build_version,
},
},
"install": {
"path": HERE_DIR / "tools" / "chocolateyinstall.ps1",
"key_values": {
"url_blenderbim_py3x_win_zip" : url_blenderbim_py3x_win_zip,
"url_blenderbim_py3x_win_zip": url_blenderbim_py3x_win_zip,
"sha256sum_blenderbim_py3x_win_zip": sha256sum_blenderbim_py3x_win_zip,
"latest_blender_version_maj_min" : blender_version_min_maj,
"latest_blender_version_maj_min": blender_version_min_maj,
},
},
"uninstall": {
+5 -2
View File
@@ -121,7 +121,7 @@ import tarfile
import threading
from datetime import datetime
ssl._create_default_https_context = ssl._create_unverified_context
ssl._create_default_https_context = ssl._create_unverified_context # ty:ignore[invalid-assignment]
import time
from collections.abc import Generator, Sequence
@@ -697,7 +697,10 @@ def build_dependency(
compr = "xz"
else:
raise RuntimeError("fix source for new download type")
download_tarfile = tarfile.open(name=download_tarfile_path, mode=f"r:{compr}")
# ty: false positive bug upstream.
download_tarfile = tarfile.open(
name=download_tarfile_path, mode=f"r:{compr}"
) # ty:ignore[no-matching-overload]
# tarfile seriously doesn't have a function to retrieve the root directory more easily
extract_dir_name = os.path.commonprefix([x for x in download_tarfile.getnames() if x != "."])
# run([tar, "--exclude=\"*/*\"", "-tf", download_name], cwd=build_dir).strip() no longer works
+2 -1
View File
@@ -156,7 +156,8 @@ exclude = [
[tool.poe.tasks]
dev-setup.sequence = [
{cmd = "uv sync"},
# 3.13 is chosen because it's the version used in the latest Bonsai.
{cmd = "uv sync --python 3.13"},
{cmd = "uv pip install -e ./src/bsdd/"},
{cmd = "uv pip install -e ./src/ifcopenshell-python/[advanced,dev]"},
{cmd = "uv pip install -e ./src/ifcedit/"},
+3 -3
View File
@@ -1,5 +1,5 @@
black==26.3.1
ruff==0.15.12
ruff==0.16.0
poethepoet
ty==0.0.61
gersemi==0.26.1
ty==0.0.63
gersemi==0.28.0
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36));
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36,#37));
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -41,5 +41,6 @@ DATA;
#34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#37=IFCSIMPLEPROPERTYTEMPLATE('1zM9sia2L8RQDnWZxgUwlZ',$,'JoinClasses','Comma separated list of IFC classes whose cut linework will be joined together when they meet (e.g. mitred at a corner).\X2\000A\X0\Defaults to ''IfcWall,IfcSlab'' if not set. Override to also join other classes, such as ''IfcWall,IfcSlab,IfcCovering''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
@@ -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;
+52 -6
View File
@@ -10,6 +10,7 @@ if bonsai_lib_path:
import argparse
import base64
import json
import urllib.parse
import xml.etree.ElementTree as ET
import pystache
@@ -18,8 +19,53 @@ from aiohttp import web
sio_port = 8080 # default port
def get_asset_version() -> str:
"""A cache-busting token appended to locally served static asset URLs.
Browsers otherwise keep serving a stale cached copy of static/js and
static/css after Bonsai ships a code change, until the user does a hard
refresh. Using the Bonsai version (which includes the build's commit
hash) means the token changes on every shipped update.
"""
if bonsai_version:
return urllib.parse.quote(bonsai_version, safe="")
# Fallback for standalone runs without BONSAI_VERSION set (e.g. running
# sioserver.py directly outside of Blender): derive a token from the
# newest mtime among the static assets, so it still changes whenever the
# shipped files change.
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
latest_mtime = 0
for root, _dirs, files in os.walk(static_dir):
for name in files:
latest_mtime = max(latest_mtime, int(os.path.getmtime(os.path.join(root, name))))
return f"dev-{latest_mtime}"
asset_version = get_asset_version()
@web.middleware
async def no_cache_static_middleware(request: web.Request, handler):
"""Force revalidation of locally served static assets.
Query-string version stamping (see `asset_version`) busts the cache for
the HTML-referenced entry points, but JS files that statically import
other local modules (e.g. cost.js/gantt.js importing utilities/costui.js)
reference those modules by an un-stamped relative path. Marking all
/static/ and /jsgantt/ responses as no-cache makes browsers always
revalidate with the server (a cheap conditional GET / 304 when nothing
changed), so nested imports also pick up shipped changes without
requiring a hard refresh.
"""
response = await handler(request)
if request.path.startswith("/static/") or request.path.startswith("/jsgantt/"):
response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response
sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp", max_http_buffer_size=10000000)
app = web.Application()
app = web.Application(middlewares=[no_cache_static_middleware])
sio.attach(app)
@@ -199,28 +245,28 @@ class BlenderNamespace(socketio.AsyncNamespace):
async def schedules(request):
with open("templates/index.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
async def costing(request):
with open("templates/costing.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
async def sequencing(request):
with open("templates/gantt.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
async def documentation(request):
with open("templates/drawings.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
@@ -229,7 +275,7 @@ async def documentation(request):
async def demo(request):
with open("templates/demo.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
@@ -244,7 +244,7 @@ function addGanttElement(blenderId, tasks, workSched, filename) {
vShowTaskInfoLink: 1, // Show link in tool tip (0/1)
vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily
vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data.
vFormatArr: ["Day", "Week", "Month", "Quarter"], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers,
vFormatArr: ["Hour", "Day", "Week", "Month", "Quarter"], // vUseSingleCell keeps Hour usable on large charts.
vShowRes: true, // Disable the resource column.
vShowComp: false, // Disable the completion column.
vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want.
@@ -250,6 +250,14 @@ export class CostUI {
},
});
CostUI.addRibbonButton({
text: "Download CSV",
icon: "fa-solid fa-file-csv",
callback: () => {
CostUI.downloadCsv();
},
});
CostUI.addRibbonButton({
text: "Hide Schedules",
icon: "fa-regular fa-eye-slash",
@@ -523,6 +531,81 @@ export class CostUI {
}
}
static downloadCsv() {
const tables = document.querySelectorAll("table[id^='cost-items-']");
if (tables.length === 0) {
alert("No cost schedule loaded to export!");
return;
}
tables.forEach((table) => {
const scheduleId = table.id.split("-").pop();
const csv = CostUI.tableToCsv(table);
if (csv === null) {
return;
}
const nameEl = document.querySelector(
"#cost-schedule-container-" + scheduleId + " .form-header span"
);
const scheduleName = nameEl
? nameEl.textContent
: "cost_schedule_" + scheduleId;
CostUI.triggerCsvDownload(csv, scheduleName + ".csv");
});
}
static tableToCsv(table) {
const escapeCsvCell = (value) => {
const text = (value === null || value === undefined ? "" : value)
.toString()
.trim();
if (/[",\n]/.test(text)) {
return '"' + text.replace(/"/g, '""') + '"';
}
return text;
};
const cellText = (cell) => {
const input = cell.querySelector("input");
return input ? input.value : cell.innerText;
};
// The Actions column only holds buttons (edit/delete/etc), not data.
const isDataColumn = (column) => column && column !== "Actions";
const headerCells = Array.from(table.querySelectorAll("thead th")).filter(
(th) => isDataColumn(th.getAttribute("data-column"))
);
if (headerCells.length === 0) {
return null;
}
const rows = [headerCells.map((th) => escapeCsvCell(th.textContent)).join(",")];
table.querySelectorAll("tbody tr").forEach((row) => {
const cells = Array.from(row.children).filter((cell) =>
isDataColumn(cell.getAttribute("data-column"))
);
if (cells.length === 0) {
return; // e.g. the "No cost items found" placeholder row.
}
rows.push(cells.map((cell) => escapeCsvCell(cellText(cell))).join(","));
});
return rows.join("\n");
}
static triggerCsvDownload(csvContent, filename) {
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
static createCostTable({ costSchedule, currency, callbacks }) {
const preferences = CostUI.getColumnPreferences();
const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES";
@@ -7,7 +7,7 @@
<link
rel="stylesheet"
type="text/css"
href="/static/css/cost.css"
href="/static/css/cost.css?v={{v}}"
id="index-stylesheet"
/>
<link
@@ -21,7 +21,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -34,7 +34,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script type="module" defer src="./static/js/cost.js"></script>
<script type="module" defer src="./static/js/cost.js?v={{v}}"></script>
</head>
<body>
<nav>
@@ -12,14 +12,14 @@
/>
<!-- here we request the CSS file from the server, -->
<!-- using registered static path in the server -->
<link rel="stylesheet" href="/static/css/demo.css" id="demo-stylesheet" />
<link rel="stylesheet" href="/static/css/demo.css?v={{v}}" id="demo-stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -33,7 +33,7 @@
</script>
<!-- here we request the JS file from the server-->
<!-- using registered static path in the server -->
<script defer src="./static/js/demo.js"></script>
<script defer src="./static/js/demo.js?v={{v}}"></script>
</head>
<body>
<!-- the navigation bar at the top of the page. -->
@@ -15,7 +15,7 @@
/>
<link
rel="stylesheet"
href="/static/css/drawings.css"
href="/static/css/drawings.css?v={{v}}"
id="drawings-stylesheet"
/>
<link
@@ -24,7 +24,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -45,7 +45,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script defer src="./static/js/drawings.js"></script>
<script defer src="./static/js/drawings.js?v={{v}}"></script>
</head>
<body>
<nav>
@@ -9,25 +9,25 @@
type="image/x-icon"
href="https://bonsaibim.org/assets/images/favicon-blender.png"
/>
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css" />
<link rel="stylesheet" href="/static/css/gantt.css" id="gantt-stylesheet" />
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css?v={{v}}" />
<link rel="stylesheet" href="/static/css/gantt.css?v={{v}}" id="gantt-stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
src="https://cdn.socket.io/4.0.0/socket.io.min.js"
></script>
<script type="text/javascript" src="./jsgantt/jsgantt.js"></script>
<script type="text/javascript" src="./jsgantt/jsgantt.js?v={{v}}"></script>
<script>
var SOCKET_PORT = {{port}};
</script>
<script type="module" defer src="./static/js/gantt.js"></script>
<script type="module" defer src="./static/js/gantt.js?v={{v}}"></script>
</head>
<body>
<nav class="no-print">
@@ -9,7 +9,7 @@
type="image/x-icon"
href="https://bonsaibim.org/assets/images/favicon-blender.png"
/>
<link rel="stylesheet" href="/static/css/index.css" id="index-stylesheet" />
<link rel="stylesheet" href="/static/css/index.css?v={{v}}" id="index-stylesheet" />
<link
rel="stylesheet"
id="tabulator-stylesheet"
@@ -21,7 +21,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -34,7 +34,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script defer src="./static/js/index.js"></script>
<script defer src="./static/js/index.js?v={{v}}"></script>
</head>
<body>
<nav>
+4 -3
View File
@@ -418,9 +418,10 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None
def viewport_shading_changed_callback(area: bpy.types.Area) -> None:
shading = area.spaces.active.shading.type
if shading == "RENDERED":
tool.Style.get_style_props().active_style_type = "External"
shading_type = area.spaces.active.shading.type
tool.Style.restore_material_style_types(shading_type)
if shading_type == "SOLID":
area.spaces.active.shading.color_type = "MATERIAL"
def subscribe_to_viewport_shading_changes():
-4
View File
@@ -133,10 +133,6 @@ class MaterialCreator:
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
continue
tool.Loader.load_indexed_map(coords, self.mesh)
elif tool.Style.get_texture_style(material):
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
tool.Loader.load_generated_uv_map(self.mesh)
def assign_material_slots_to_faces(self) -> None:
if not self.mesh["ios_materials"]:
@@ -32,6 +32,7 @@ classes = (
operator.ActivateModel,
operator.AddAnnotation,
operator.AddAnnotationType,
operator.AddElevationAnnotation,
operator.AddDrawing,
operator.AddDrawingStyle,
operator.AddDrawingToSheet,
@@ -47,6 +48,7 @@ classes = (
operator.CleanWireframes,
operator.ContractSheet,
operator.ConvertSVGToDXF,
operator.CopyAnnotationToDrawing,
operator.CopyTextToSelection,
operator.CreateDrawing,
operator.CreateSheets,
@@ -107,8 +109,19 @@ classes = (
operator.SelectAssignedProduct,
operator.SelectSimilarTextLiteralValue,
operator.ToggleTargetView,
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,
@@ -170,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:
@@ -194,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:
@@ -209,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:
@@ -409,6 +420,12 @@ class DocProperties(PropertyGroup):
options=set(),
)
is_editing_drawings: BoolProperty(name="Is Editing Drawings", default=False)
show_drawings_on_sheets_only: BoolProperty(
name="Show Only Drawings on Sheets",
description="Only show drawings that are placed on a sheet",
default=False,
options=set(),
)
is_editing_schedules: BoolProperty(name="Is Editing Schedules", default=False)
is_editing_references: BoolProperty(name="Is Editing References", default=False)
target_view: EnumProperty(
@@ -439,6 +456,7 @@ class DocProperties(PropertyGroup):
should_use_annotation_cache: bool
should_draw_linked_projects: bool
is_editing_drawings: bool
show_drawings_on_sheets_only: bool
is_editing_schedules: bool
is_editing_references: bool
target_view: Literal["PLAN_VIEW", "ELEVATION_VIEW", "SECTION_VIEW", "REFLECTED_PLAN_VIEW", "MODEL_VIEW"]
@@ -1031,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
@@ -1044,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,
)
+69 -2
View File
@@ -332,6 +332,8 @@ class BIM_PT_drawings(Panel):
row3.separator(factor=0.5, type="SPACE")
row3.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="")
row3.operator("bim.select_all_drawings", icon="CHECKBOX_HLT", text="")
row3.operator("bim.create_drawing", text="", icon="OUTPUT")
row3.operator("bim.convert_svg_to_dxf", text="", icon="SEQ_PREVIEW").view = active_drawing.name
@@ -339,6 +341,7 @@ class BIM_PT_drawings(Panel):
self.layout.template_list(
"BIM_UL_drawinglist", "", self.props, "drawings", self.props, "active_drawing_index"
)
self.layout.prop(self.props, "show_drawings_on_sheets_only")
class BIM_PT_schedules(Panel):
@@ -552,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:
@@ -569,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 = {
@@ -871,8 +887,8 @@ class BIM_UL_drawinglist(bpy.types.UIList):
layout.label(text="", translate=False)
return
row = layout.row(align=True)
if item.is_drawing:
row = layout.row(align=True)
row.label(text="", icon="BLANK1")
selected_icon = "CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT"
row.prop(item, "is_selected", text="", icon=selected_icon, emboss=False)
@@ -893,6 +909,9 @@ class BIM_UL_drawinglist(bpy.types.UIList):
item.ifc_definition_id
)
else:
# Give category headers a distinct inset background so they stand out from drawing rows.
box = layout.box()
row = box.row(align=True)
if item.target_view == "PLAN_VIEW":
icon = "UV_FACESEL"
elif item.target_view == "ELEVATION_VIEW":
@@ -913,7 +932,55 @@ class BIM_UL_drawinglist(bpy.types.UIList):
op = row.operator("bim.toggle_target_view", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT")
op.target_view = item.target_view
op.option = "EXPAND"
row.prop(item, "name", text="", icon=icon, emboss=False)
group = tool.Drawing.get_visible_drawings_in_category(item.target_view)
all_selected = bool(group) and all(d.is_selected for d in group)
row.operator(
"bim.toggle_drawing_category_selection",
text="",
icon="CHECKBOX_HLT" if all_selected else "CHECKBOX_DEHLT",
emboss=False,
).target_view = item.target_view
row.separator(factor=0.5, type="SPACE")
# Clicking the header name toggles expand/contract, same as the disclosure triangle.
op = row.operator("bim.toggle_target_view", text=item.name, icon=icon, emboss=False)
op.target_view = item.target_view
op.option = "CONTRACT" if item.is_expanded else "EXPAND"
def filter_items(self, context, data: DocProperties, propname: str):
drawings = getattr(data, propname)
helper_funcs = bpy.types.UI_UL_list
flt_flags = []
flt_neworder = []
if self.filter_name:
flt_flags = helper_funcs.filter_items_by_name(
self.filter_name,
self.bitflag_filter_item,
drawings,
"name",
reverse=self.use_filter_sort_reverse,
)
if not flt_flags:
flt_flags = [self.bitflag_filter_item] * len(drawings)
props = tool.Drawing.get_document_props()
if props.show_drawings_on_sheets_only:
ifc_file = tool.Ifc.get()
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids()
# Target view headers are only shown if they contain a sheeted drawing.
sheeted_target_views = {
tool.Drawing.get_drawing_target_view(ifc_file.by_id(drawing_id)) for drawing_id in sheeted_ids
}
for i, item in enumerate(drawings):
if item.is_drawing:
is_visible = item.ifc_definition_id in sheeted_ids
else:
is_visible = item.target_view in sheeted_target_views
if not is_visible:
flt_flags[i] &= ~self.bitflag_filter_item
return flt_flags, flt_neworder
class BIM_UL_sheets(bpy.types.UIList):
@@ -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,10 +225,67 @@ 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):
@@ -248,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")
@@ -330,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
+39 -1
View File
@@ -163,14 +163,52 @@ class AssignGroup(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
if not self.is_assigning:
return bpy.ops.bim.unassign_group(group=self.group)
ifc_file = tool.Ifc.get()
group = ifc_file.by_id(self.group)
products = [
element
for o in tool.Blender.get_selected_objects(include_active=False)
if (element := tool.Ifc.get_entity(o))
]
ifcopenshell.api.group.assign_group(tool.Ifc.get(), products=products, group=tool.Ifc.get().by_id(self.group))
relocated_annotations = self.unassign_from_previous_drawing(ifc_file, group, products)
ifcopenshell.api.group.assign_group(ifc_file, products=products, group=group)
self.relocate_annotations_to_drawing(relocated_annotations, group)
self.report({"INFO"}, f"Assigned {len(products)} objects to group.")
def unassign_from_previous_drawing(self, ifc_file, group, products) -> list[ifcopenshell.entity_instance]:
"""Assigning an annotation to a group that represents a drawing means the
annotation should belong to that drawing only, so it needs to leave
whichever drawing it was previously part of, instead of ending up
visible in both at once.
"""
new_drawing = tool.Drawing.get_group_drawing(group)
if not new_drawing:
return []
relocated = []
for product in products:
if not product.is_a("IfcAnnotation") or product.ObjectType == "DRAWING":
continue
old_drawing = tool.Drawing.get_annotation_drawing(product)
if not old_drawing or old_drawing.id() == new_drawing.id():
continue
if old_group := tool.Drawing.get_drawing_group(old_drawing):
ifcopenshell.api.group.unassign_group(ifc_file, products=[product], group=old_group)
relocated.append(product)
return relocated
def relocate_annotations_to_drawing(self, products, group) -> None:
"""Move the relocated annotations into the new drawing's collection and
depth, now that they have actually been assigned to its group.
"""
if not products:
return
new_drawing = tool.Drawing.get_group_drawing(group)
new_camera = tool.Ifc.get_object(new_drawing) or tool.Drawing.import_drawing(new_drawing)
for product in products:
if obj := tool.Ifc.get_object(product):
tool.Drawing.ensure_annotation_in_drawing_plane(obj, camera=new_camera)
tool.Collector.assign(obj)
class UnassignGroup(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_group"
@@ -56,6 +56,7 @@ classes = (
operator.RemoveMaterial,
operator.RemoveMaterialSet,
operator.RemoveProfile,
operator.RenameMaterial,
operator.ReorderMaterialSetItem,
operator.SelectByMaterial,
operator.SelectMaterialInMaterialsUI,
@@ -102,6 +102,26 @@ class EditMaterial(bpy.types.Operator, tool.Ifc.Operator):
core.edit_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material))
class RenameMaterial(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.rename_material"
bl_label = "Rename Material"
bl_description = "Rename an IfcMaterial"
bl_options = {"REGISTER", "UNDO"}
material: bpy.props.IntProperty()
name: bpy.props.StringProperty(name="Name")
def invoke(self, context, event):
material = tool.Ifc.get().by_id(self.material)
self.name = material.Name or ""
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
self.layout.prop(self, "name")
def _execute(self, context):
core.rename_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material), name=self.name)
class DisableEditingMaterial(bpy.types.Operator):
bl_idname = "bim.disable_editing_material"
bl_label = "Disable Editing Material"
@@ -814,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:
@@ -93,6 +93,30 @@ def _stroke_lines_alpha(
gpu.state.blend_set("NONE")
def _connected_components(
vertex_groups: dict[int, list[bmesh.types.BMVert]],
) -> list[list[bmesh.types.BMVert]]:
"""Split each vertex group's members into their connected components,
since a duplicated arc/circle loop shares its source loop's group index."""
components = []
for verts in vertex_groups.values():
remaining = set(verts)
while remaining:
seed = remaining.pop()
stack = [seed]
component = [seed]
while stack:
v = stack.pop()
for edge in v.link_edges:
other = edge.other_vert(v)
if other in remaining:
remaining.discard(other)
stack.append(other)
component.append(other)
components.append(component)
return components
class ProfileDecorator:
installed = None
@@ -265,7 +289,7 @@ class ProfileDecorator:
# Draw arcs
arc_centroids = []
arc_segments = []
for arc in arcs.values():
for arc in _connected_components(arcs):
if len(arc) != 3:
continue
sorted_arc = [None, None, None]
@@ -292,7 +316,7 @@ class ProfileDecorator:
# Draw circles
circle_centroids = []
circle_segments = []
for circle in circles.values():
for circle in _connected_components(circles):
if len(circle) != 2:
continue
p1 = obj.matrix_world @ circle[0].co
@@ -772,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))
@@ -839,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)
+1 -1
View File
@@ -162,7 +162,7 @@ class ProjectLibraryData:
library_file = IfcStore.library_file
if library_file is None or library_file.schema == "IFC2X3":
return results
root = tool.Project.get_root_context(library_file)
root = library_file.by_type("IfcProject")[0]
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
for library_id, data in cls.data["project_libraries"].items():
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
@@ -281,7 +281,7 @@ class RefreshLibrary(bpy.types.Operator):
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
root_context = tool.Project.get_root_context(library_file)
root_context = library_file.by_type("IfcProject")[0]
hierarchy = tool.Project.get_project_hierarchy(library_file)
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
return {"FINISHED"}
@@ -761,21 +761,22 @@ class EditProjectLibrary(bpy.types.Operator):
attributes = bonsai.bim.helper.export_attributes(props.project_library_attributes)
ifcopenshell.api.attribute.edit_attributes(library_file, project_library, attributes)
# Update parent library.
# Update parent library. Tear down the old IfcRelDeclares/IfcRelNests before
# creating the new one; a library must have exactly one of the two, never both.
previous_parent_library = tool.Project.get_parent_library(project_library)
new_parent_library = library_file.by_id(int(props.parent_library))
if previous_parent_library != new_parent_library:
if previous_parent_library is None:
# Edited library was a root in a library-only file; nest it under the new parent.
if previous_parent_library is not None:
if previous_parent_library.is_a("IfcProject"):
ifcopenshell.api.project.unassign_declaration(
library_file, [project_library], previous_parent_library
)
else:
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
if new_parent_library.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], new_parent_library)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
elif previous_parent_library.is_a("IfcProject"):
# Then new one is IfcProjectLibrary.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
else: # Previous is IfcProjectLibrary.
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
# If new one is IfcProject, then it's already assigned by default.
if new_parent_library.is_a("IfcProjectLibrary"):
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
props.is_editing_project_library = False
bpy.ops.bim.refresh_library()
@@ -809,12 +810,9 @@ class AddProjectLibrary(bpy.types.Operator):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
root_context = tool.Project.get_root_context(library_file)
root_context = library_file.by_type("IfcProject")[0]
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
if root_context.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
@@ -1301,6 +1299,10 @@ class LoadProjectElements(bpy.types.Operator):
tool.Project.set_default_modeling_dimensions()
tool.Root.reload_grid_decorator()
bonsai.bim.handler.refresh_ui_data()
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
bonsai.bim.handler.viewport_shading_changed_callback(area)
return {"FINISHED"}
def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]:
@@ -1578,10 +1580,13 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
def should_clear_cache() -> bool:
if not self.use_cache:
return True
# Nothing to clear if the cache file was never created (e.g. a
# fresh link). Check this first so os.remove below is never
# called on a non-existent path, regardless of use_cache.
if not blend_filepath.exists():
return False
if not self.use_cache:
return True
data = json.loads(json_filepath.read_text())
# Empty 'query' - model loaded without custom query.
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
@@ -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()
+17 -3
View File
@@ -104,7 +104,7 @@ def get_footing_length(o: bpy.types.Object) -> float:
return get_length(o)
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
return get_z(o)
elif predefined_type == "PAD_FOOTING":
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
return max(get_x(o), get_y(o))
else:
return get_length(o)
@@ -197,12 +197,26 @@ def get_footing_height(o: bpy.types.Object) -> float:
return get_height(o)
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
return get_y(o)
elif predefined_type == "PAD_FOOTING":
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
return get_z(o)
else:
return get_height(o)
def get_footing_width(o: bpy.types.Object) -> float:
element = tool.Ifc.get_entity(o)
assert element
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
if not predefined_type:
return get_width(o)
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
return get_x(o)
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
return get_width(o)
else:
return get_width(o)
def get_height(o: bpy.types.Object) -> float:
"""_summary_: Returns the height of the object bounding box
@@ -225,7 +239,7 @@ def get_opening_depth(obj: bpy.types.Object) -> float:
if is_opening_horizontal(obj):
return get_height(obj)
else:
return get_width(obj)
return get_y(obj)
def get_opening_mapping_area(obj: bpy.types.Object) -> float:
@@ -413,12 +413,13 @@ class UnlinkObject(bpy.types.Operator, tool.Ifc.Operator):
skip_invoke: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
def _execute(self, context):
objects: list[bpy.types.Object]
if self.obj:
objects = [bpy.data.objects.get(self.obj)]
requested_obj = bpy.data.objects.get(self.obj)
objects = [requested_obj] if requested_obj is not None else []
else:
objects = context.selected_objects
objects: list[bpy.types.Object]
for obj in objects:
was_active_object = obj == context.active_object
+8 -1
View File
@@ -132,5 +132,12 @@ class SelectSimilarData:
if pset.endswith("Common"):
keys.extend([f'/.*Common/."{name}"' for name in properties.keys() if name != "id"])
else:
keys.extend([f"{pset}.{name}" for name in properties.keys() if name != "id"])
pset_part = f'"{pset}"' if " " in pset else pset
keys.extend(
[
f'{pset_part}."{name}"' if " " in name else f"{pset_part}.{name}"
for name in properties.keys()
if name != "id"
]
)
return [(k, k, "") for k in keys]
@@ -12,7 +12,7 @@ function create_gantt_chart(json_data) {
vShowTaskInfoLink: 1, // Show link in tool tip (0/1)
vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily
vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data.
vFormatArr: ['Day', 'Week', 'Month', 'Quarter'], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers,
vFormatArr: ['Hour', 'Day', 'Week', 'Month', 'Quarter'], // vUseSingleCell keeps Hour usable on large charts.
vShowRes: true, // Disable the resource column.
vShowComp: false, // Disable the completion column.
vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want.
@@ -45,6 +45,7 @@ class SpatialTool(WorkSpaceTool):
("bim.spatial_hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}),
("bim.spatial_hotkey", {"type": "G", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_G")]}),
("bim.spatial_hotkey", {"type": "H", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_H")]}),
("bim.spatial_hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}),
)
def draw_settings(context, layout, ws_tool):
@@ -184,3 +185,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
def hotkey_S_G(self):
bpy.ops.bim.generate_space()
def hotkey_S_Q(self):
# Mirrors BimTool.hotkey_S_Q so quantities can be (re)calculated without switching tools.
if not bpy.context.selected_objects:
return
bpy.ops.bim.perform_quantity_take_off()
@@ -45,7 +45,9 @@ classes = (
operator.SelectByStyle,
operator.SelectStyleInStylesUI,
operator.SetAssetMaterialToExternalStyle,
operator.SuggestShadeFromExternalStyle,
operator.UnlinkStyle,
operator.TogglePreferIfcShading,
operator.UpdateCurrentStyle,
operator.UpdateStyleColours,
operator.UpdateStyleTextures,
+229 -10
View File
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import colorsys
import os
from pathlib import Path
from typing import Any, Union
@@ -238,13 +239,15 @@ class UpdateCurrentStyle(bpy.types.Operator):
if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)):
continue
for mat in obj.data.materials:
if (
mat
and mat not in updated_materials
and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0
):
msprops_.active_style_type = current_style_type
updated_materials.add(mat)
if not mat:
continue
msprops_ = tool.Style.get_material_style_props(mat)
if msprops_.ifc_definition_id == 0:
continue
if mat in updated_materials:
continue
msprops_.active_style_type = current_style_type
updated_materials.add(mat)
return {"FINISHED"}
@@ -457,10 +460,14 @@ class ActivateExternalStyle(bpy.types.Operator):
self.report({"ERROR"}, f"Error loading external style for \"{material.name}\" - {db['msg']}")
return {"CANCELLED"}
self.copy_material_attributes(db["data_block"], material)
ext_mat = db["data_block"]
self.copy_material_attributes(ext_mat, material)
if tool.Style.get_use_nodes(material):
tool.Blender.copy_node_graph(material, db["data_block"])
bpy.data.materials.remove(db["data_block"])
if material.get("bim_dual_branch"):
tool.Style.update_external_branch(material, ext_mat)
else:
tool.Style.setup_dual_branch(material, ext_mat)
bpy.data.materials.remove(ext_mat)
return {"FINISHED"}
def copy_material_attributes(self, source, target):
@@ -503,6 +510,218 @@ class ActivateExternalStyle(bpy.types.Operator):
set_prop(prop_name)
class TogglePreferIfcShading(bpy.types.Operator):
bl_idname = "bim.toggle_prefer_ifc_shading"
bl_label = "Toggle Flat/Pretty"
bl_description = (
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style) for ALL styles.\n\n"
"SHIFT+CLICK to apply to this style only"
)
bl_options = {"REGISTER", "UNDO"}
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
single_only: bpy.props.BoolProperty(name="Single Only", default=False, options={"SKIP_SAVE"})
def invoke(self, context, event):
if event.shift:
self.single_only = True
return self.execute(context)
def execute(self, context):
wm = context.window_manager
space = tool.Blender.get_view3d_space()
is_solid = space and space.shading.type == "SOLID"
if is_solid:
if space.shading.color_type == "TEXTURE":
space.shading.color_type = "MATERIAL"
else:
meshes_needing_uv = []
for obj in bpy.context.scene.objects:
if not isinstance(obj.data, bpy.types.Mesh):
continue
for slot in obj.material_slots:
mat = slot.material
if not mat or not tool.Blender.get_ifc_definition_id(mat):
continue
style_elements = tool.Style.get_style_elements(mat)
if style_elements.get("IfcSurfaceStyleWithTextures") and not obj.data.uv_layers:
meshes_needing_uv.append(obj.data)
break
wm.progress_begin(0, max(len(meshes_needing_uv), 1))
try:
for i, mesh in enumerate(meshes_needing_uv):
tool.Loader.load_generated_uv_map(mesh)
wm.progress_update(i)
finally:
wm.progress_end()
space.shading.color_type = "TEXTURE"
return {"FINISHED"}
if self.single_only:
mat = bpy.data.materials.get(self.material_name)
if not mat:
return {"CANCELLED"}
msprops = tool.Style.get_material_style_props(mat)
msprops.prefer_ifc_shading = not msprops.prefer_ifc_shading
else:
# Default: apply to all IFC materials
source_mat = bpy.data.materials.get(self.material_name)
new_value = not tool.Style.get_material_style_props(source_mat).prefer_ifc_shading if source_mat else True
ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)]
for mat in ifc_mats:
tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value
return {"FINISHED"}
class SuggestShadeFromExternalStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.suggest_shade_from_external_style"
bl_label = "Suggest Shade from External Style"
bl_description = (
"Generate a Shade style (Surface Colour + Transparency) from the external .blend style.\n\n"
"ALT+CLICK to apply to all styles with an external .blend style"
)
bl_options = {"REGISTER", "UNDO"}
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
all_styles: bpy.props.BoolProperty(name="All Styles", default=False, options={"SKIP_SAVE"})
value_offset: bpy.props.FloatProperty(
name="Value",
description="Offset added to the colour's value (-1 = fully dark, 0 = unchanged, +1 = fully light)",
default=0.0,
min=-1.0,
max=1.0,
step=1,
precision=2,
options={"SKIP_SAVE"},
)
saturation_factor: bpy.props.FloatProperty(
name="Saturation",
description="Scale applied to the colour's saturation (0 = greyscale, 1 = unchanged, >1 = more saturated)",
default=1.0,
min=0.0,
max=2.0,
step=1,
precision=2,
options={"SKIP_SAVE"},
)
def invoke(self, context, event):
if event.alt:
self.all_styles = True
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
layout.prop(self, "value_offset", slider=True)
layout.prop(self, "saturation_factor", slider=True)
if self.all_styles:
layout.label(text="Will apply to all external styles", icon="INFO")
def _execute(self, context):
if self.all_styles:
candidates = [
(mat, tool.Style.get_style_elements(mat))
for mat in bpy.data.materials
if tool.Blender.get_ifc_definition_id(mat)
]
candidates = [(mat, se) for mat, se in candidates if tool.Style.has_blender_external_style(se)]
wm = context.window_manager
wm.progress_begin(0, max(len(candidates), 1))
count = 0
color_cache: dict[tuple[str, str, str], tuple | None] = {}
try:
for i, (mat, style_elements) in enumerate(candidates):
wm.progress_update(i)
if self._apply_to_material(
mat, style_elements, self.value_offset, self.saturation_factor, color_cache
):
count += 1
finally:
wm.progress_end()
self.report({"INFO"}, f"Shade style generated for {count} style(s).")
else:
mat = bpy.data.materials.get(self.material_name)
if not mat:
return {"CANCELLED"}
style_elements = tool.Style.get_style_elements(mat)
if not tool.Style.has_blender_external_style(style_elements):
self.report({"ERROR"}, "No external .blend style assigned. Please assign an external style first.")
return {"CANCELLED"}
self._apply_to_material(mat, style_elements, self.value_offset, self.saturation_factor)
props = tool.Style.get_style_props()
if props.is_editing:
core.load_styles(tool.Style, style_type=props.style_type)
def _apply_to_material(
self,
material: bpy.types.Material,
style_elements: dict,
value_offset: float = 0.0,
saturation_factor: float = 1.0,
color_cache: "dict[tuple[str, str, str], tuple | None] | None" = None,
) -> bool:
external_style = style_elements["IfcExternallyDefinedSurfaceStyle"]
style_path = Path(tool.Ifc.resolve_uri(external_style.Location))
data_block_type, data_block = external_style.Identification.split("/")
cache_key = (str(style_path), data_block_type, data_block)
if color_cache is not None and cache_key in color_cache:
cached = color_cache[cache_key]
if cached is None:
return False # previously failed for this path
surface_colour, transparency = cached
else:
try:
db = tool.Blender.append_data_block(str(style_path), data_block_type, data_block)
except OSError as e:
self.report({"WARNING"}, f'Could not open blend file for "{material.name}": {e}')
if color_cache is not None:
color_cache[cache_key] = None
return False
if not db["data_block"]:
self.report({"WARNING"}, f'Could not load external style for "{material.name}": {db["msg"]}')
if color_cache is not None:
color_cache[cache_key] = None
return False
ext_mat = db["data_block"]
surface_colour, transparency = tool.Style.get_representative_material_color(ext_mat)
bpy.data.materials.remove(ext_mat)
if color_cache is not None:
color_cache[cache_key] = (surface_colour, transparency)
if value_offset != 0.0 or saturation_factor != 1.0:
h, s, v = colorsys.rgb_to_hsv(*surface_colour)
v = max(0.0, min(1.0, v + value_offset))
s = max(0.0, min(1.0, s * saturation_factor))
surface_colour = colorsys.hsv_to_rgb(h, s, v)
ifc_style = tool.Ifc.get_entity(material)
attributes: dict = {
"SurfaceColour": {
"Name": None,
"Red": surface_colour[0],
"Green": surface_colour[1],
"Blue": surface_colour[2],
},
}
if tool.Ifc.get_schema() != "IFC2X3":
attributes["Transparency"] = transparency
shading_style = style_elements.get("IfcSurfaceStyleShading")
if shading_style:
tool.Ifc.run("style.edit_surface_style", style=shading_style, attributes=attributes)
else:
tool.Ifc.run(
"style.add_surface_style",
style=ifc_style,
ifc_class="IfcSurfaceStyleShading",
attributes=attributes,
)
material.diffuse_color = (*surface_colour, 1.0 - transparency)
tool.Style.sync_flat_branch_shading(material, surface_colour, transparency)
return True
class DisableEditingStyles(bpy.types.Operator):
bl_idname = "bim.disable_editing_styles"
bl_options = {"REGISTER", "UNDO"}
@@ -372,6 +372,16 @@ def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context)
tool.Style.switch_shading(blender_material, self.active_style_type)
def update_prefer_ifc_shading(self: "BIMStyleProperties", context: bpy.types.Context) -> None:
style_elements = tool.Style.get_style_elements(self.id_data)
has_external = tool.Style.has_blender_external_style(style_elements)
if self.prefer_ifc_shading or not has_external:
self.active_style_type = "Shading"
else:
self.active_style_type = "External"
self.id_data.update_tag()
class BIMStyleProperties(PropertyGroup):
ifc_definition_id: IntProperty(name="IFC Definition ID")
active_style_type: EnumProperty(
@@ -381,9 +391,19 @@ class BIMStyleProperties(PropertyGroup):
default="Shading",
update=update_shading_style,
)
prefer_ifc_shading: BoolProperty(
name="Flat / Pretty",
description=(
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style). "
"When set to Flat, viewport switches to Material Preview or Rendered will not activate the external style."
),
default=False,
update=update_prefer_ifc_shading,
)
is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False)
if TYPE_CHECKING:
ifc_definition_id: int
active_style_type: tool.Style.StyleType
prefer_ifc_shading: bool
is_renaming: bool
+65
View File
@@ -110,6 +110,11 @@ class BIM_PT_styles(Panel):
op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="")
op.style_id = style.ifc_definition_id
if active_style and self.props.style_type == "IfcSurfaceStyle":
if material := style.blender_material:
msprops = tool.Style.get_material_style_props(material)
self.draw_style_status_row(material, msprops)
if self.props.style_type == "IfcSurfaceStyle":
self.layout.label(text="Surface Style Element:")
col = self.layout.column(align=True)
@@ -161,6 +166,66 @@ class BIM_PT_styles(Panel):
edit_label = "Save Lighting Style"
self.draw_edit_ui(edit_label)
def draw_style_status_row(self, material: bpy.types.Material, msprops) -> None:
space = tool.Blender.get_view3d_space()
box = self.layout.box()
obj = bpy.context.active_object
parts = []
if space:
shading_type = space.shading.type
shading_labels = {
"SOLID": "Solid",
"MATERIAL": "Material Preview",
"RENDERED": "Rendered",
"WIREFRAME": "Wireframe",
}
parts.append(f"Viewport: {shading_labels.get(shading_type, shading_type)}")
else:
parts.append("No 3D viewport")
shading_type = None
if obj:
obj_has_uv = isinstance(obj.data, bpy.types.Mesh) and bool(obj.data.uv_layers)
uv_label = "UV \u2713" if obj_has_uv else "UV \u2717"
parts.append(f"Selected Object: {obj.name} {uv_label}")
else:
parts.append("Selected Object: None")
if shading_type == "SOLID":
is_flat = space.shading.color_type != "TEXTURE"
mode_label = "Flat"
dep_label = "Shade"
if not is_flat:
mode_label = "Pretty"
dep_label = "Texture \u2192 Shade"
elif shading_type in ("MATERIAL", "RENDERED"):
is_flat = msprops.prefer_ifc_shading
mode_label = "Flat"
dep_label = "Render+Texture \u2192 Render \u2192 Shade"
if not is_flat:
mode_label = "Pretty"
dep_label = "External \u2192 Render+Texture \u2192 Render \u2192 Shade"
else:
is_flat = False
mode_label = ""
dep_label = ""
row1 = box.row(align=True)
row1.label(text=" | ".join(parts))
if mode_label:
row2 = box.row(align=True)
row2.label(text=f"Current Mode: {mode_label} \u2014 {dep_label}")
row3 = box.row(align=True)
row3.alignment = "RIGHT"
op = row3.operator("bim.suggest_shade_from_external_style", text="", icon="BRUSHES_ALL")
op.material_name = material.name
op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT")
op.material_name = material.name
def draw_surface_style_shading(self):
row = self.layout.row()
row.prop(self.props, "surface_colour")
@@ -117,9 +117,7 @@ class UnassignType(bpy.types.Operator, tool.Ifc.Operator):
related_object: str
@staticmethod
def _reattach_styles(
file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance]
) -> None:
def _reattach_styles(file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance]) -> None:
"""copy_deep only follows forward references, so IfcStyledItem (an inverse,
``StyledByItem``) is not carried onto the copied geometry. Re-create a
styled item on each copy that points at the same presentation styles as
+16
View File
@@ -41,6 +41,7 @@ import bonsai.bim.helper
import bonsai.tool as tool
from bonsai.bim.ifc import is_cache_locked_by_other_process
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
from bonsai.bim.module.material.operator import SelectByMaterial
from bonsai.bim.module.model import prop as _model_prop
from bonsai.bim.module.model import ui as _model_ui
from bonsai.bim.module.pset.prop import IfcProperty
@@ -1854,6 +1855,21 @@ def draw_statusbar(self, context):
def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) -> None:
# https://blender.stackexchange.com/a/275555/86891
# Context menu for material name buttons (e.g. `bim.select_by_material`),
# offering a quick "Rename Material" entry instead of having to look up
# the material in the scene Materials panel to rename it.
button_operator = getattr(context, "button_operator", None)
if button_operator is not None and button_operator.bl_rna.identifier == SelectByMaterial.bl_rna.identifier:
ifc_file = tool.Ifc.get()
material = ifc_file.by_id(button_operator.material) if ifc_file else None
if material is not None and material.is_a("IfcMaterial"):
assert self.layout
self.layout.separator()
op = self.layout.operator("bim.rename_material", text="Rename Material", icon="GREASEPENCIL")
op.material = material.id()
return
if (
not hasattr(context, "button_pointer")
or not hasattr(context, "button_prop")
+57
View File
@@ -411,6 +411,37 @@ def duplicate_drawing(
return new_drawing
def copy_annotations_to_drawing(
ifc: type[tool.Ifc],
collector: type[tool.Collector],
drawing_tool: type[tool.Drawing],
geometry: type[tool.Geometry],
annotations: list[ifcopenshell.entity_instance],
target_drawing: ifcopenshell.entity_instance,
) -> list[ifcopenshell.entity_instance]:
"""Duplicate annotations into another drawing, leaving the originals untouched."""
target_group = drawing_tool.get_drawing_group(target_drawing)
if not target_group:
return []
annotations = [a for a in annotations if drawing_tool.get_annotation_drawing(a) != target_drawing]
annotation_objs = [obj for a in annotations if (obj := ifc.get_object(a))]
if not annotation_objs:
return []
camera = ifc.get_object(target_drawing) or drawing_tool.import_drawing(target_drawing)
old_to_new, _ = geometry.duplicate_ifc_objects(annotation_objs)
copied: list[ifcopenshell.entity_instance] = []
for new_elements in old_to_new.values():
for new_element in new_elements:
if old_group := drawing_tool.get_drawing_group(new_element):
ifc.run("group.unassign_group", group=old_group, products=[new_element])
ifc.run("group.assign_group", group=target_group, products=[new_element])
new_obj = ifc.get_object(new_element)
drawing_tool.ensure_annotation_in_drawing_plane(new_obj, camera)
collector.assign(new_obj, should_clean_users_collection=True)
copied.append(new_element)
return copied
def remove_drawing(
ifc: type[tool.Ifc], drawing_tool: type[tool.Drawing], drawing: ifcopenshell.entity_instance
) -> None:
@@ -489,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:
@@ -511,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)
@@ -519,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"))
+9
View File
@@ -107,6 +107,15 @@ def disable_editing_material(material_tool: type[tool.Material]) -> None:
material_tool.disable_editing_material()
def rename_material(
ifc: type[tool.Ifc], material_tool: type[tool.Material], material: ifcopenshell.entity_instance, name: str
) -> None:
ifc.run("material.edit_material", material=material, attributes={"Name": name})
if material_tool.is_editing_materials():
material_tool.import_material_definitions(material_tool.get_active_material_type())
material_tool.refresh()
def assign_material(
ifc: type[tool.Ifc],
material_tool: type[tool.Material],
-1
View File
@@ -222,7 +222,6 @@ def generate_space(
if element and element.is_a("IfcSpace"):
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
spatial.translate_obj_to_z_location(active_obj, z)
else:
if relating_type:
name = model.generate_occurrence_name(relating_type, "IfcSpace")
+5
View File
@@ -354,6 +354,7 @@ class Drawing:
def enable_editing_schedules(cls): pass
def enable_editing_sheets(cls): pass
def enable_editing_text(cls, obj): pass
def ensure_annotation_in_drawing_plane(cls, obj, camera=None): pass
def ensure_drawings_parent_document(cls): pass
def ensure_drawings_parent_group(cls): pass
def ensure_unique_drawing_name(cls, name): pass
@@ -367,6 +368,7 @@ class Drawing:
def generate_reference_attributes(cls, reference, **attributes): pass
def generate_sheet_identification(cls): pass
def get_annotation_context(cls, target_view, object_type=None): pass
def get_annotation_drawing(cls, element): pass
def get_annotation_representation(cls, element_type): pass
def get_assigned_product(cls, element): pass
def get_assigned_product_workaround(cls, element): pass
@@ -384,6 +386,7 @@ class Drawing:
def get_drawing_group(cls, drawing): pass
def get_drawing_references(cls, drawing): pass
def get_drawing_target_view(cls, drawing): pass
def get_group_drawing(cls, group): pass
def get_group_elements(cls, group): pass
def get_ifc_representation_class(cls, object_type): pass
def get_name(cls, element): pass
@@ -397,6 +400,7 @@ class Drawing:
def get_unit_system(cls): pass
def import_assigned_product(cls, obj): pass
def import_documents(cls, document_type): pass
def import_drawing(cls, drawing): pass
def import_drawings(cls): pass
def import_sheets(cls): pass
def import_text_attributes(cls, obj): pass
@@ -663,6 +667,7 @@ class Material:
def is_editing_materials(cls): pass
def is_material_used_in_sets(cls, material): pass
def load_material_attributes(cls, material): pass
def refresh(cls): pass
def replace_material_with_material_profile(cls, element): pass
def update_elements_using_material(cls, material): pass
+34
View File
@@ -803,6 +803,40 @@ class Blender(bonsai.core.tool.Blender):
# restore shader editor settings
shader_editor.pin = previous_pin_setting
@classmethod
def copy_node_graph_additive(
cls, material_to: bpy.types.Material, material_from: bpy.types.Material
) -> bpy.types.ShaderNodeOutputMaterial | None:
"""Paste nodes from material_from alongside the existing nodes in material_to.
Unlike copy_node_graph this does NOT clear the existing node tree first.
Returns the OUTPUT_MATERIAL node that was added from material_from, or None.
"""
temp_override = cls.get_shader_editor_context()
shader_editor = temp_override["space"]
before_names = {n.name for n in material_to.node_tree.nodes}
previous_pin_setting = shader_editor.pin
shader_editor.pin = True
shader_editor.node_tree = material_from.node_tree
for node in material_from.node_tree.nodes:
node.select = True
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_copy()
shader_editor.node_tree = material_to.node_tree
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_paste(offset=(0, 0))
shader_editor.pin = previous_pin_setting
for node in material_to.node_tree.nodes:
if node.name not in before_names and node.type == "OUTPUT_MATERIAL":
return node
return None
@classmethod
def get_material_node(
cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {}
-1
View File
@@ -138,7 +138,6 @@ class Bsdd(bonsai.core.tool.Bsdd):
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
prefs = tool.Blender.get_addon_preferences()
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
cls.client = bsdd.Client()
if hasattr(cls.client, "baseurl"):
cls.client.baseurl = baseurl
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
+11
View File
@@ -1270,6 +1270,17 @@ class ClipBox:
def on_depsgraph_update_caps(cls, scene, depsgraph) -> None:
"""Depsgraph entry-point — guard, then delegate to the
modal-aware debounce in :meth:`_handle_cap_tick`."""
# Same file-load danger window as on_depsgraph_update: a real
# depsgraph tick during load (between load_pre and the new file's
# first paint) must not re-arm a cap-rebuild timer. _on_load_pre
# already cancels any in-flight timer via _cancel_pending_cap_rebuild;
# without this gate, a depsgraph_update_post event firing later in
# the same load (Blender fires these while building the new file's
# scene) would immediately reschedule one via _handle_cap_tick,
# undoing that cancellation and re-arming against regions whose GPU
# state is not yet wired.
if cls._file_loading:
return
if getattr(bpy.context, "screen", None) is None:
return
if cls._active_scene_props(scene) is None:
+336 -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)
@@ -756,6 +770,17 @@ class Drawing(bonsai.core.tool.Drawing):
if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING":
return rel.RelatingGroup
@classmethod
def get_group_drawing(cls, group: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Get the drawing that owns this group, if the group represents a drawing."""
if group.ObjectType != "DRAWING":
return None
for rel in group.IsGroupedBy or []:
for related_object in rel.RelatedObjects:
if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING":
return related_object
return None
@classmethod
def get_drawing_document(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
for rel in drawing.HasAssociations:
@@ -1621,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(
@@ -1953,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,
@@ -2799,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)
@@ -2876,6 +3167,50 @@ class Drawing(bonsai.core.tool.Drawing):
break
return sheet_references
@classmethod
def get_sheeted_drawing_ids(cls) -> set[int]:
"""Get the IFC ids of all drawings that are placed on at least one sheet."""
ifc_file = tool.Ifc.get()
sheet_locations: set[Union[str, None]] = set()
for sheet in ifc_file.by_type("IfcDocumentInformation"):
if sheet.Scope != "SHEET":
continue
for reference in cls.get_document_references(sheet):
sheet_locations.add(reference.Location)
if not sheet_locations:
return set()
result: set[int] = set()
for drawing in ifc_file.by_type("IfcAnnotation"):
if drawing.ObjectType != "DRAWING":
continue
drawing_document = cls.get_drawing_document(drawing)
if drawing_document and drawing_document.Location in sheet_locations:
result.add(drawing.id())
return result
@classmethod
def get_visible_drawings_in_category(cls, target_view: str) -> list[DrawingProperties]:
"""Get the drawing items in a target view category that are currently visible in the drawing list.
Grouping is positional: individual drawing items don't carry their own ``target_view``, they belong to
the most recent header item above them. Only expanded categories contribute drawing items to the
collection, so a collapsed category yields an empty list. Respects the ``show_drawings_on_sheets_only``
filter so that select-all only affects visible drawings.
"""
props = cls.get_document_props()
drawings: list[DrawingProperties] = []
in_category = False
for item in props.drawings:
if not item.is_drawing:
# Header row: we're inside the requested category until the next header.
in_category = item.target_view == target_view
elif in_category:
drawings.append(item)
if props.show_drawings_on_sheets_only:
sheeted_ids = cls.get_sheeted_drawing_ids()
drawings = [d for d in drawings if d.ifc_definition_id in sheeted_ids]
return drawings
@classmethod
def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix:
matrix_world = camera.matrix_world.copy().normalized()
+11 -2
View File
@@ -1151,6 +1151,9 @@ class Geometry(bonsai.core.tool.Geometry):
settings.set("layerset-first", True)
settings.set("keep-bounding-boxes", True)
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
settings.set("mesher-linear-deflection", ifc_import_settings.deflection_tolerance)
settings.set("mesher-angular-deflection", ifc_import_settings.angular_tolerance)
geometry_library = ifc_import_settings.geometry_library
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = tool.Ifc.get()
@@ -1162,7 +1165,11 @@ class Geometry(bonsai.core.tool.Geometry):
shape = None
if elements:
iterator = ifcopenshell.geom.iterator(
settings, tool.Ifc.get(), multiprocessing.cpu_count(), include=elements
settings,
tool.Ifc.get(),
multiprocessing.cpu_count(),
include=elements,
geometry_library=geometry_library,
)
else:
iterator = None # For example, when switching representation of a type with no occurrences
@@ -1217,7 +1224,9 @@ class Geometry(bonsai.core.tool.Geometry):
for element in element_types:
if obj := tool.Ifc.get_object(element):
if representation := ifcopenshell.util.representation.get_representation(element, context):
geometry = ifcopenshell.geom.create_shape(settings, representation)
geometry = ifcopenshell.geom.create_shape(
settings, representation, geometry_library=geometry_library
)
mesh_name = tool.Loader.get_mesh_name_from_shape(geometry)
mesh = meshes.get(mesh_name)
if mesh is None:
+6
View File
@@ -146,6 +146,12 @@ class Material(bonsai.core.tool.Material):
MaterialsData.data["material_styles_data"] = MaterialsData.material_styles_data()
@classmethod
def refresh(cls) -> None:
from bonsai.bim.module.material.data import refresh as refresh_material_data
refresh_material_data()
@classmethod
def is_editing_materials(cls) -> bool:
props = tool.Material.get_material_props()
+48 -34
View File
@@ -2292,32 +2292,18 @@ class Model(bonsai.core.tool.Model):
deform_layer = bm.verts.layers.deform.active
# Sanity check
group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
if deform_layer:
for vert in bm.verts:
vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer)
is_circle = False
for group_index in vert_group_indices:
group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE"
group_verts[group_type].setdefault(group_index, 0)
group_verts[group_type][group_index] += 1
if group_type == "IFCCIRCLE":
is_circle = True
is_circle = any(gi in groups["IFCCIRCLE"] for gi in vert_group_indices)
is_arc = any(gi in groups["IFCARCINDEX"] for gi in vert_group_indices)
if (is_circle or is_arc) and not vert.link_edges:
return (False, "CIRCLE" if is_circle else "3POINT_ARC")
if is_circle:
pass # Circles are allowed to be unclosed
elif len(vert.link_edges) != 2: # Unclosed loop or forked loop
return (False, "UNCLOSED_LOOP")
for group_type, group_counts in group_verts.items():
if group_type == "IFCARCINDEX":
for group_count in group_counts.values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
elif group_type == "IFCCIRCLE":
for group_count in group_counts.values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
loop_edges = list(bm.edges)
# Create loops from edges
@@ -2340,6 +2326,28 @@ class Model(bonsai.core.tool.Model):
has_found_connected_edge = True
loops.append(loop)
# Sanity check, per loop rather than across the whole mesh
if deform_layer:
for loop in loops:
loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
loop_verts = {v for edge in loop for v in edge.verts}
for vert in loop_verts:
for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer):
if group_index in groups["IFCARCINDEX"]:
group_type = "IFCARCINDEX"
elif group_index in groups["IFCCIRCLE"]:
group_type = "IFCCIRCLE"
else:
continue
loop_group_counts[group_type].setdefault(group_index, 0)
loop_group_counts[group_type][group_index] += 1
for group_count in loop_group_counts["IFCARCINDEX"].values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
for group_count in loop_group_counts["IFCCIRCLE"].values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
tmp = ifcopenshell.file(schema=tool.Ifc.get().schema)
def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool:
@@ -2520,27 +2528,11 @@ class Model(bonsai.core.tool.Model):
deform_layer = bm.verts.layers.deform.active
# Sanity check
group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
if deform_layer:
for vert in bm.verts:
vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer)
for group_index in vert_group_indices:
group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE"
group_verts[group_type].setdefault(group_index, 0)
group_verts[group_type][group_index] += 1
if len(vert.link_edges) > 2: # Forked loop
return (False, "FORKED_LOOP")
for group_type, group_counts in group_verts.items():
if group_type == "IFCARCINDEX":
for group_count in group_counts.values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
elif group_type == "IFCCIRCLE":
for group_count in group_counts.values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
loop_edges = list(bm.edges)
# Create loops from edges
@@ -2563,6 +2555,28 @@ class Model(bonsai.core.tool.Model):
has_found_connected_edge = True
loops.append(loop)
# Sanity check, per loop rather than across the whole mesh
if deform_layer:
for loop in loops:
loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
loop_verts = {v for edge in loop for v in edge.verts}
for vert in loop_verts:
for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer):
if group_index in groups["IFCARCINDEX"]:
group_type = "IFCARCINDEX"
elif group_index in groups["IFCCIRCLE"]:
group_type = "IFCCIRCLE"
else:
continue
loop_group_counts[group_type].setdefault(group_index, 0)
loop_group_counts[group_type][group_index] += 1
for group_count in loop_group_counts["IFCARCINDEX"].values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
for group_count in loop_group_counts["IFCCIRCLE"].values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
tmp = ifcopenshell.file(schema=tool.Ifc.get().schema)
def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool:
+9 -1
View File
@@ -47,7 +47,15 @@ class Nest(bonsai.core.tool.Nest):
return False
if relating_object == related_object:
return False
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
# IfcRelNests.RelatingObject/RelatedObjects are typed as the general
# IfcObjectDefinition, so nesting is schema-legal both between element
# occurrences (the common case, e.g. a faucet nested into a sink) and
# between element types (e.g. an assembly type nesting its component
# types). Mixing an occurrence with a type isn't a real modeling
# pattern, so only allow same-kind pairs. See #2283.
is_compatible_class = (relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")) or (
relating_object.is_a("IfcTypeProduct") and related_object.is_a("IfcTypeProduct")
)
if not is_compatible_class:
return False
# Prevent cyclic references: walk up the full hierarchy from the
+3 -3
View File
@@ -168,12 +168,12 @@ class Polyline(bonsai.core.tool.Polyline):
distance = (mouse_vector - last_point).length
if distance < 0:
return
angle, orientation_angle, angle_round_threshold = None, None
angle, orientation_angle = None, None
angle_round_threshold = 1000 # Avoids rounding when distance is too big
if distance > 0:
angle = tool.Cad.angle_3_vectors(
second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True
)
angle_round_threshold = 1000 # Avoids rounding when distance is too big
# Round angle to the nearest 0.05
angle = round(angle / 0.05) * 0.05 if distance < angle_round_threshold else angle
@@ -189,7 +189,7 @@ class Polyline(bonsai.core.tool.Polyline):
angle = 0
orientation_angle = 0
if input_ui:
assert angle is not None and orientation_angle is not None and angle_round_threshold is not None
assert angle is not None and orientation_angle is not None
if should_round:
angle_snap = tool.Snap.get_angle_snap_value(context)
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle

Some files were not shown because too many files have changed in this diff Show More