Compare commits

...

170 Commits

Author SHA1 Message Date
CyrilWaechter 0e084f2830 Port space/boundary code to v0.9.0 API changes
v0.9.0 changed two APIs used by the space generation feature:

1. geom.tree.select_ray no longer accepts length as a keyword argument;
   pass it positionally.

2. ifcopenshell.file no longer exposes wrapped_data; serialize with
   file.to_string() directly.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter dd5bd58916 Add footprint corner sampling for roof detection
get_vertical_bounding_planes now also casts rays from the footprint
polygon's corner vertices, offset slightly inward toward the centroid.
This catches bounding elements (e.g. sloped roofs) that only cover a
corner of the space.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter c3abe0b3c7 Fix space regeneration determinism and caching bugs
Fix three issues in generate_space:

1. Z location drift: z was derived from the Blender bounding box, which
   changes after every regeneration. Use active_obj.location.z instead.

2. Cache invalidation for moved roofs/slabs: commit placements for
   HEIGHT_DETECTION_CLASSES in addition to BOUNDING_CLASSES so the
   geometry cache reflects recent moves.

3. Non-deterministic regeneration: the old Body representation was still
   present in the IFC file when get_space_volume_strategy built the
   geometry tree, so ray hits from get_vertical_bounding_planes hit the
   space's own body. Since each regeneration produced a different Body
   (BooleanClippingResult/FacetedBrep), the strategy alternated between
   EXTRUDE_CLIP and BREP. Remove all Body representations before
   strategy detection so the tree only contains bounding elements.

Also clean up stale IfcRelSpaceBoundary relationships before each
regeneration to prevent old boundary references from contaminating
subsequent runs. Remove ALL existing Body representations (not just
the first one found) to prevent duplicate half-space clipping chains.

Add regression tests including a 5-iteration stability check.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter 7017d5400d Fix rotated space placement localization during regeneration
set_space_representation_from_polygon was localising the footprint and
clipping planes by subtracting only the object origin. For spaces with a
rotated ObjectPlacement (e.g. Space 5710 in the test IFC) the footprint
was not rotated into the space's local coordinate system, so the
regenerated mesh was rotated by the placement angle and appeared at the
wrong world location.

Now the polygon and planes are transformed with the full inverse of the
object's placement matrix, and the plane normals are also rotated. The
local mesh is therefore aligned with the object's local axes and appears
in the correct world position when the placement is applied.

Added regression tests for Space 5710 (rotated placement) and Space 2363
(identity placement) using the real HouseWithGarage_AC22_IFC2X3.ifc
fixture.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter fb3cd09d6d Fix space generation location and IFC2X3 B-rep
Two fixes for space generation:

1. IFC2X3 schema: build_brep_space now falls back to plain
   IfcRelSpaceBoundary because IfcRelSpaceBoundary1stLevel does not exist in
   IFC2X3.

2. Geometry location: set_space_representation_from_polygon now aligns the
   IFC ObjectPlacement with the Blender object, converts base_z/planes and
   the footprint polygon to the object's local coordinate system before
   building, and fixes the base_z unit scale. The centred-cube regeneration
   test was updated to check world bounds because the mesh is now placed
   relative to the object placement.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter 332435416a Fix clipped space top reaching sloped planes
The extrusion height was capped at the top/bottom plane anchor z (the mean
of the ray-cast hits, near the footprint centre), so a sloped ceiling's
high side stopped short of the plane (e.g. 5.5 m instead of 6.88 m for the
shed roof test). Extend the extrusion to the plane's z at every footprint
vertex before clipping, falling back to the base z as before. Also correct
the mirrored profile-to-world mapping comment in the shed roof test helper
(the ridge is at world y=-5, not y=+5).

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter f0c6de4bdf Add regression tests for sloped slab, curved wall
Cover a curved vertical wall (EXTRUDE_CLIP strategy) in test_space.py and a
sloped slab (clipped extrusion) in test_spatial.py.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter 665c5fa77e Pass bounding walls into space representation
Thread the footprint-query bounding walls and container into the
set_space_representation_from_polygon dispatcher and add an end-to-end
test for a space under a shed roof with a sloped underside.

Generated with the assistance of an AI coding tool.
2026-08-18 00:11:00 +02:00
CyrilWaechter de13379162 Wire space volume strategy detection
Dispatch on EXTRUDE_CLIP vs B-rep when building space volumes, and
fix fixture placement and visibility bugs in the spatial tests.

Generated with the assistance of an AI coding tool.
2026-08-18 00:09:56 +02:00
CyrilWaechter 3b16356181 Add B-rep fallback space builder
Build a faceted B-rep space from auto-generated boundary faces when
sloped or curved bounding elements make a clipped extrusion
unsuitable.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 44860cd615 Match sloped faces in boundary generation
Allow sloped roof/slab/wall faces to bound space faces when the
strict anti-parallel rule leaves a face uncovered, using a
footprint-scaled distance tolerance. Existing matching behaviour
is preserved (fallback-only).

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 5651cd6494 Add clipped extrusion space builder
Build IfcExtrudedAreaSolid clipped by top/bottom half-space planes.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 29b9d8807e Add space volume strategy detection
Detect whether a space can be built as a clipped extrusion or needs
a B-rep fallback, based on wall face orientation and top/bottom
bounding planes.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 37f557b4b5 Align vertical bounding plane strategy contract with caller
get_vertical_bounding_planes always returns EXTRUDE_CLIP; the
EXTRUDE_CLIP-vs-BREP decision belongs to the calling layer.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 881fb10fe6 Add vertical bounding plane detection for space generation
Implement get_vertical_bounding_planes using ray-casting from the RL
cut elevation with nearest-hit and coplanar grouping.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 1949adda44 Expand space regeneration design spec with prior art
Add prior-art references, known limitations, and non-goals
identified during self-review.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 87193ac323 Add space regeneration sloped-roof design spec
Design for extending generate_space with a hybrid parametric
extrusion + clipping / B-rep fallback strategy supporting sloped
roofs, sloped walls, sloped slabs, and curved walls.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter c34a6ddac6 Bonsai: add regression test for closed IfcPolyline loop conversion
Verifies that convert_curve_to_mesh produces the closing edge instead of overwriting the last segment, matching the fix from PR #8043.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
Petru Conduraru 92cc601a85 Bonsai: close IfcPolyline loops by appending the closing edge (#8043)
convert_curve_to_mesh built the edge chain of a polyline with extend, then for a
closed polyline overwrote the last edge with the closing edge instead of
appending it. That discarded the final real segment, so every closed IfcPolyline
loop came back one edge short and open. On the edit mode round trip the inner
void loop of an IfcArbitraryProfileDefWithVoids was then lost or misclassified,
and the profile was rewritten without its void, collapsing the extrusion to a
bounding box.

Append the closing edge instead, matching the IfcIndexedPolyCurve branch. Live
tested: the Tab round trip now keeps both loops closed and re-exports the
IfcArbitraryProfileDefWithVoids with its inner void intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 00:06:27 +02:00
CyrilWaechter adf01be1d0 ifcopenshell.util.boundary: make face matrix robust against collinear first vertices
_face_matrix_from_verts used only the first 3 vertices and sb.np_normal, which divides by zero when they are collinear. Triangulated meshes from generated spaces often start with collinear boundary vertices, producing NaN matrices and a shapely LinearRing error. Walk the polygon to find a non-degenerate normal and edge.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter c8f8196b09 Bonsai: commit moved bounding objects before IFC-based space generation
The auto-generate-ifc-based-space-boundaries path builds a geometry cache from the IFC file. If a user (or a BDD helper) only moves the Blender object matrix, the cache still sees the old IFC placement and the space footprint is open. Commit any moved visible bounding objects and clear the cache before generating the space.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter cb22dd7a43 Use space's own container for regeneration instead of requiring default
When regenerating an existing IfcSpace, the default container is no longer required. Instead, the space's container is found via get_parent(element), which walks the full spatial hierarchy (aggregation, containment, nesting). For new space creation, the default container is still required.

Add optional container parameter to get_space_polygon_from_context_visible_objects so regeneration can pass the resolved container directly.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 243f13de09 Optimize coplanar face reconstruction and add tests
Vectorize the coplanarity prefilter in _union_coplanar_face_polygon and
compute per-triangle normals once instead of per space face. Add
regression tests for the SmallHouse and Triangle boundary test models.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 0d5c9a0ce2 Keep shaft holes in generated space boundaries
dissolve_faces with merge_coplanar drops interior rings, so the shaft
opening in a ceiling was lost and replaced by spurious wall-cap
boundaries. Reconstruct the space face from its raw coplanar triangles,
preserve interior rings in the assigned boundary, absorb redundant
candidates by plane offset, and raise the full-face tolerance so walls
offset by their half thickness get a single boundary.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter ea6f03409f Fix space boundary generation regressions
When several elements match the same space face, offset matches that only
duplicate coplanar coverage are now skipped, and a single bounding element
within a small plane offset gets the full space face instead of a clipped
polygon. Existing boundaries are removed before regeneration so stale 2nd
level boundaries are not left behind, and the Bonsai operator delegates
element filtering to auto_generate_boundaries.

Regenerates SmallHouse boundaries to match the reference output and keeps
the ExternalEarth opening unioning intact.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 21b4cd2403 Deduplicate opening boundaries in auto_generate_boundaries
When a building element has multiple ngons matching the same space face,
_process_openings was called multiple times for the same opening/filling,
producing duplicate boundaries (e.g. two boundaries for the same door).

Fix: pass a set of processed filling IDs to _process_openings and skip
already-processed openings.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter a543022bba Fix dissolve_faces polygon reconstruction with merge_coplanar
When merge_coplanar merges two sub-faces that share an edge from the
original BRep (e.g. two rectangles forming an L-shape cap), that shared
edge remained in boundary_edges via original_edges filtering, causing
the edge_adjacency walk to produce wrong polygons.

Fix: after coplanar merging, use edge frequency (edges used by exactly
1 triangle = boundary) instead of original_edges filtering, which
correctly identifies only outer boundary edges.

Also add safety checks: edge_adjacency emptiness guard, infinite loop
protection, and minimum polygon length check.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 6f9d5c4005 Fix axis/ref_direction swap in connection geometry
The a2p placement matrix stores col[0]=X (edge direction) and
col[2]=Z (face normal), but assign_connection_geometry expects
axis=Z (normal) and ref_direction=X (edge).

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter a7b6c66f77 Extract boundary generation to ifcopenshell.util.boundary
Move Blender-independent boundary generation algorithm from Bonsai
(GPL) to ifcopenshell.util.boundary (LGPL):

- ifcopenshell.util.shape.dissolve_faces: reconstruct polygonal faces
  from triangulated mesh using original edges from get_edges() + Union-Find
- ifcopenshell.util.boundary.auto_generate_boundaries: full boundary
  generation algorithm using IFC geometry (numpy, shapely) without
  Blender — replaces bmesh, matrix_world, tool.Cad.is_x, mathutils with
  numpy equivalents
- Uses existing ifcopenshell.api.boundary.assign_connection_geometry
  for connection geometry creation
- Uses existing ifcopenshell.util.placement.a2p + np_normal for face
  matrix construction
- BOUNDARY_ELEMENT_CLASSES expanded to include IfcColumn and
  IfcCurtainWall

Bonsai's boundary/operator.py auto_generate_boundaries is now a thin
adapter handling Blender-specific preprocessing (flushing moved
objects, building iterator + tree) then delegating to the util module.

Added 12 tests: 3 for dissolve_faces, 3 for auto_generate_boundaries.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 6dc671f24d Fix shapely topology crash in boundary generation
Add buffer(0) validation for space_face_polygon and face_polygon
before intersection, following the same pattern as tool/cad.py.

Wrap the intersection in try/except for shapely.errors.GEOSException
to catch remaining topology errors. On exception, set
bonsai.last_error (so the 'Copy Error Message To Clipboard' button
appears in the UI), report an ERROR to the operator, and continue
processing other face pairs instead of crashing.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 9b28444255 Fix space regen Z placement for centered representations
get_x_y_z_h_mat_from_obj computes z from bound_box[0], which differs from obj.location.z when the representation is centered at origin (e.g., a PolygonalFaceSet unit cube). The previous fix (24f7629f) removed translate_obj_to_z_location to prevent doubling Z for extrusion representations, but that broke centered ones where z != location.z.

Replace the removed relative translate with an absolute active_obj.location.z = z. This is a no-op for extrusion representations (z == location.z) and corrects the position for centered ones.

Add test_regenerate_space_from_centered_cube_representation to cover the regeneration path with a centered mesh representation.

Generated with the assistance of an AI coding tool.
2026-08-18 00:06:27 +02:00
CyrilWaechter 7c04a0d533 Fix pyright possibly-missing-submodule in covering test
Add explicit import bonsai.core.tool to satisfy pyright's type checker,
which requires submodules to be explicitly imported rather than relying
on transitive imports from import bonsai.
2026-08-17 23:03:42 +02:00
CyrilWaechter 02126a8d82 Fix ruff import-ordering in covering and spatial tests
ruff check flagged unsorted imports in test/core/test_covering.py
and test/tool/test_spatial.py. Fix by reorganising import blocks.
2026-08-17 23:03:42 +02:00
CyrilWaechter c9fcabef65 Rework TestGenerateSpace to use IFC representations instead of Blender cubes
All 8 space-generation tests now create IFC walls/slabs with real
solid-block representations using IfcExtrudedAreaSolid, instead of
relying on the old Blender-mesh bisection path (broken since 79ee88da5
switched to IFC-geometry-only for boundary detection).

- _BlockHelper provides create_wall (10x10xheight block) and create_slab
  (12x12x1.0 block) helpers using standard IfcOpenShell API calls.
- The wall block bisects to a 10x10 polygon at the cutting plane
  (matching the old cube-behaviour), and auto-height detects wall_top_z.
- Pre-existing height assertions (z=10) now pass correctly because
  auto-height = wall_top_z - base_z = 10 - 0 = 10 (the old values were
  wrong for the Blender path where h defaulted to 3).
- test_regenerate_after_wall_height_change modifies the IFC extrusion
  depth directly and bumps the geom cache token via
  _bump_geom_cache_token() instead of relying on Blender depsgraph.
- No Blender cubes are created except when absolutely needed for
  selection/active-object flow (regeneration, apply-height).
- Added ifcopenshell.util.representation to imports.
- Import _bump_geom_cache_token from bonsai.tool.spatial.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter 99c89c3f44 Add covering core tests verifying tuple-unpack fix
Covers all three covering operators with success and error-path tests
using the Prophecy mocking framework. The key assertion verifies that
get_space_polygon_from_context_visible_objects' return value is unpacked
so the polygon (not the tuple of polygon+bounding_elements) reaches
set_covering_representation_from_polygon.

Shapely geometry objects are not JSON-serialisable (Prophecy call
serialisation), so we use the plain integer 42 as a stand-in for the
polygon value.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter b78396051d Fix covering operators to unpack tuple return from get_space_polygon_from_context_visible_objects
Three covering core functions (add_instance_flooring_covering_from_cursor,
add_instance_ceiling_covering_from_cursor, regen_selected_covering_object)
used the old single-value assignment from
get_space_polygon_from_context_visible_objects, which now returns a
(polygon, bounding_walls) tuple. The isinstance(str) guard never fired,
causing the tuple to flow into set_covering_representation_from_polygon
and raise a shapely error.

Fix by unpacking space_polygon, _ at all three call sites.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter 6715e684a8 Extract space generation algorithms to ifcopenshell.util
Move Blender-independent space generation algorithms from Bonsai
(GPL) to ifcopenshell.util (LGPL):

- ifcopenshell.util.shape.bisect_mesh_plane_vf: vectorized numpy
  triangle/plane intersection for mesh bisection
- ifcopenshell.util.element.iter_top_connections: walker for
  IfcRelConnectsElements(TOP) relationships
- ifcopenshell.util.space: new module with get_boundary_lines,
  get_space_polygon, get_auto_space_height and height detection
  helpers — all operating on IFC geometry without Blender

Bonsai's tool/spatial.py now delegates to these utilities via
thin wrappers, keeping only Blender-specific concerns (cache
management with depsgraph invalidation, UI property reads).

tool/wall.py iter_wall_slab_connections delegates to
ifcopenshell.util.element.iter_top_connections.

Added 22 tests: 6 for bisect_mesh_plane_vf, 10 for space
generation algorithms, 4 for iter_top_connections, 2 Bonsai
integration tests for cache behavior.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:42 +02:00
CyrilWaechter 1695571256 Add auto-detect space height from elements above
Space height is now auto-detected using IFC geometry directly
(ifcopenshell.geom.create_shape + get_shape_bottom/top_elevation)
instead of Blender object bounding boxes. This fixes height detection
when the slab above is not loaded in Blender.

Detection priority:
1. IfcRelConnectsElements(TOP) connections on bounding walls
2. IfcSlab / IfcRoof elements above with XY overlap to space polygon
3. Minimum wall top Z of bounding walls
4. Fallback to space_height property (default 3m)

Added space_height and force_space_height properties to
BIMSpatialDecompositionProperties. The height field is synced to
the active space's height via active_object_callback (msgbus), not
in draw().

Added ApplySpaceHeightToSelection operator to modify
IfcExtrudedAreaSolid.Depth in place without regenerating footprint.

bounding_walls changed from list[tuple[element, obj]] to
list[entity_instance] since Blender objects are no longer needed.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:41 +02:00
CyrilWaechter 4f0e572e0f Add copy attribute to selection for boundaries
Add a paste button to IfcRelSpaceBoundary specific attributes
(RelatingSpace, RelatedBuildingElement, ParentBoundary,
CorrespondingBoundary, PhysicalOrVirtualBoundary,
InternalOrExternalBoundary) reusing the existing
copy_attribute_to_selection core function.

The core function value type hint is broadened from Union[str, None]
to Any since boundary relation attributes pass IFC entity instances.

Generated with the assistance of an AI coding tool.
2026-08-17 23:03:41 +02:00
Richard Brice 511584b36f Allows key point referents to be nested to the parent alignment in the reusing horizontal scenario 2026-08-17 08:03:00 +10:00
Richard Brice f65de78c46 Strengthens implementation of station_to_string. Adds alignment name to stationing referent. 2026-08-17 08:03:00 +10:00
myoualid 59b957daff fixes to sequence.create_baseline:
- assert isinstance(res, list) was wrong because duplicate_task returns a tuple not a list
- removed overkill assertion anyway as the usecase is already typed.
- setting optional name or reuse planned schedule name
- usecase now returns created baseline work schedule
2026-08-17 08:03:00 +10:00
Thomas Krijnen 81a0941d5a Apply suggestion from @aothms 2026-08-17 08:03:00 +10:00
BelGraDev dba735f1ee Fixed error when accessing the UnitType attribute in convert_file_length_units 2026-08-17 08:03:00 +10:00
Andrej730 e100cf5a34 Fix examples linking errors for shared build (incorrect attributes order)
E.g. IfcAdvancedHouse:
```
/usr/bin/x86_64-linux-gnu-ld.bfd: CMakeFiles/IfcAdvancedHouse.dir/IfcAdvancedHouse.cpp.o: in function `main':
IfcAdvancedHouse.cpp:(.text.startup.main+0x137): undefined reference to `hierarchy_helper<Ifc4x3_add2>::addBuilding(Ifc4x3_add2::IfcSite, Ifc4x3_add2::IfcOwnerHistory)'
/usr/bin/x86_64-linux-gnu-ld.bfd: IfcAdvancedHouse.cpp:(.text.startup.main+0x7c7): undefined reference to `hierarchy_helper<Ifc4x3_add2>::getRepresentationContext(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)'
/usr/bin/x86_64-linux-gnu-ld.bfd: IfcAdvancedHouse.cpp:(.text.startup.main+0x931): undefined reference to `hierarchy_helper<Ifc4x3_add2>::getRepresentationContext(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)'
```

Noticed by addressing gcc warning gcc warning that attribute order is incorrect:
```
//src/ifcparse/hierarchy_helper.i:721:31: warning: attribute ignored in explicit instantiation ‘class hierarchy_helper<Ifc2x3>’ [-Wattributes]
  721 | template IFC_SCHEMA_API class hierarchy_helper<IfcSchema>;
      |                               ^~~~~~~~~~~~~~~~~~~~~~~~~~~
//src/ifcparse/hierarchy_helper.i:721:31: note: no attribute can be applied to an explicit instantiation
```
2026-08-14 17:38:47 +05:00
Andrej730 665502cbc5 .gitignore: ignore compile_commands.json at root for clang convenience 2026-08-14 15:37:05 +05:00
Andrej730 252831d7f0 .clang-tidy: drop removed AnalyzeTemporaryDtors
Resolves the error below. This option was removed in clang 18.
```
.clang-tidy:4:1: error: unknown key 'AnalyzeTemporaryDtors'
AnalyzeTemporaryDtors: false
```
2026-08-14 15:34:42 +05:00
Andrej730 3a6055a558 build-all: document undocumented args 2026-08-14 13:03:20 +05:00
Andrej730 7b1b0b986c build-all: use global constants for flags consistently 2026-08-14 13:03:20 +05:00
Andrej730 cd34d92fdb build-all: add flag to build examples
Useful to reproduce issues with examples locally
2026-08-14 12:54:31 +05:00
Andrej730 1391c7d974 Bump pyodide version to fix the build
0.29.3 have an older version of micropip and is affected by https://github.com/pyodide/pyodide/issues/6177
2026-08-14 12:08:32 +05:00
Andrej730 223d6da3b1 Reapply "build_pyodide: try more recent pyodide-build"
This reverts commit 1a931ddfd9.
2026-08-14 12:03:29 +05:00
Andrej730 171e899eb0 Add script to quickly pack wasm wheel after local build-all 2026-08-14 12:03:29 +05:00
Andrej730 e2561ffa3b black, sort imports 2026-08-14 10:17:02 +05:00
Dion Moult 4b87ab5d0d Fix warnings in test suite due to undeclared wall pytest marker 2026-08-14 06:44:11 +10:00
Thomas Krijnen 8cc36f0d4d Add link dependency on native build to resole example failure 2026-08-13 05:13:35 +02:00
Dion Moult 13cc190849 Update georef tests to not hardcode the results of vert[0] used in auto origin detection.
Because vert[0] can change based on kernel output, we now assert that 1)
origins are on a vert, any vert, and 2) both blender coords and map
coords are what we expect. I manually visually verified all tests
against Blender 5.1 + stable 0.8.5 to check that actual behaviour hasn't
changed, only tests need updating.
2026-08-13 11:20:26 +10:00
Dion Moult b71354ce19 Fix assigning a plain material to an occurrence as a layer set
Assigning a material to an occurrence with a set material type has raised
"IfcMaterial cannot be assiged as a IfcMaterialLayerSetUsage" since the
default changed to assigning usages to occurrences. The type is upgraded to a
usage but the material is passed on unchanged, and material.assign_material
only accepts a material for a usage when that material is already the set,
whereas the Object Materials dropdown gives us a plain IfcMaterial. Pass
nothing in that case and let the API make the set, as it does when asked for
a usage with no material.

Look the set up past the usage afterwards, so the material the user picked is
added to it. get_material returns the usage, which is not a material set, so
neither branch of the repair below matched and the picked material was
dropped, leaving the set empty.

This is a stopgap and is commented as such: the real problem is that
assign_material builds sets with no items in them and ignores the material it
was given, which is not valid IFC and leaves callers patching up after it.

Also register "I evaluate expression" as a Then step. It has only ever been a
Given and a When, so the last line of the scenario covering this could never
run; it is the only Then of its kind in the suite.

test/bim goes from 16 failures to 15, with none introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:28:15 +10:00
Dion Moult 17042f6f80 ifc4d: make ScheduleIfcGenerator's boilerplate file actually work
create_boilerplate_ifc sets self.file and self.work_plan and returns
nothing, but create_ifc assigned its result back over self.file, so any
caller that did not supply a file got None and crashed on the next
create_entity. Call it for its side effects, as csv2ifc and csv4d2ifc
already do.

That alone only moved the failure along: the boilerplate builds a file and a
work plan but no IfcProject, and add_work_calendar looks for an IfcContext.
Create one, matching csv4d2ifc's copy of the same method, which has both
lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:30:27 +10:00
Dion Moult beb0db89e5 ifc4d: rewrite the MS Project importer onto ScheduleIfcGenerator
msp2ifc parsed the XML and built the IFC itself, so a programme read
differently depending on whether it came out of MS Project or P6. It now
parses only, and hands the parsed programme to ScheduleIfcGenerator the way
p62ifc does. Calendars, statuses, task times and resources are therefore the
shared ones, and a reader no longer has to know which tool planned the
schedule.

Three things MS Project does differently needed handling rather than sharing.

It has no work breakdown structure: there is one flat task list and an
OutlineLevel column, and a task with anything indented under it is a summary
whose dates are rolled up rather than planned. Those become IfcTasks without
an IfcTaskTime, as a P6 WBS node does. Summaries and leaves also interleave,
and a planner expects a summary to stay where they put it, so the tree is
walked in export order instead of through create_tasks, which sorts nodes
ahead of activities. And a link may hang off a summary, which P6 cannot do,
so create_rel_sequences resolves both ends against summaries too --
IfcRelSequence relates two IfcProcesses and does not require a time on
either.

Calendar handling flattens what MS Project stores as differences against a
base calendar, since IfcWorkCalendar has no such notion, and reads holidays
from whichever of the two spellings the export uses rather than both.
Recurring exceptions are skipped, because the recurrence is not readable from
the export and guessing wrong silently moves every date computed from the
calendar.

In common.py the UDF and activity-code property set names become class
attributes. They keep their P6 names by default, but MS Project's extended
attributes are not P6 user-defined fields and now land in
MSP_ExtendedAttribute rather than under a name that says P6. IsMilestone
likewise prefers a source that states it outright -- MS Project has a
Milestone flag -- and falls back to the zero-duration test, which is all P6
gives us, so the other importers are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:24:56 +10:00
Dion Moult 572f718007 Use tool.Blender to get selected objects 2026-08-11 17:06:37 +10:00
Dion Moult 6bab0603e6 by_type now returns tuple - update annotations and fix failing tests 2026-08-11 17:06:37 +10:00
Dion Moult b408e64e5e Fix failing test due to declaration() instead of declaration 2026-08-11 17:06:37 +10:00
Dion Moult f580f7255f Type elements are hidden after assignment by default now. So rewrite tests to either use non-types or explicitly select types. 2026-08-11 17:06:37 +10:00
Dion Moult b252cd25f8 Give the web viewer a federation: false origin and per-model transforms
Models now resolve to global coordinates, which alone would make things worse:
composed per-instance transforms are float32, and around six million metres
that quantises at roughly half a metre. So the first model to load also sets a
false origin, derived from where its geometry actually sits, unless a host has
set one itself.

WebFederation owns the concepts an .ifcfed carries — a federation unit, a false
origin, a per-model transform and display name — without the file format. The
desktop Federation class is a document model whose sources are local filesystem
paths, which mean nothing in a browser; a host page that wants .ifcfed can parse
the JSON and drive these calls.

Models are keyed by the JS source id rather than the session model id. The
source id exists the moment a File or URL is registered, whereas the session id
is minted inside the async range-read chain, so keying on it lets a transform be
set before the model has streamed and applied when it arrives — the model never
visibly jumps. loadSidecarMetadataWeb gained a completion callback to carry that
id back out, and addFile/addUrl now return the source id and fire onModelLoaded,
where before they were fire-and-forget with no handle and no completion signal.

The embedded sample bypasses the source registry, so it is bound separately;
otherwise the guess never runs for a page that only ever shows the sample.

georef-a and georef-b are the regression fixture: two boxes whose different map
conversions resolve to the same real-world point, so a viewer that applies them
draws one box's worth of scene and one that ignores them spans 707 m. They carry
two meshes each because reorderSidecarByMorton bails out below two and then
writes no chunk table, and a sidecar without one cannot stream over byte ranges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:06:37 +10:00
Dion Moult 935562142e Apply a model's coordinate operation when its sidecar loads
.ifcview has carried the model's CoordinateOperation since v11 and the
streaming reader has always parsed it, but applyCachedModel ignored it. The
matrix only ever reached the scene because BonsaiViewer pushes it after every
load via setModelCoordinateOperation. Nothing does that on web, so every model
rendered in its local coordinates and two federated models with differing map
conversions came out misaligned.

Seed the matrix and the unit scales from the sidecar, and recompose the model
afterwards. Seeding alone is not enough: the instance transforms in a sidecar
are baked with identity federation matrices, and applyCachedModel uploads them
as-is. The recompose also fixes a second case that had nothing to do with
georeferencing — a model loaded while a federated false origin was already in
force kept its unshifted transforms.

ModelGpuData gains the unit scales because composeModelTransformation needs
them to lift a transform's anchor point into metres, and on a sidecar-only load
there is no IFC to read them back from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:06:37 +10:00
Dion Moult d1d0fb4636 Move the federation transform math into IfcViewerCore
The value types and compose helpers in Federation.h were already Qt-free —
Eigen and std::string — but sat in the Qt half of the viewer, so the web build
could not reach them. Split them into FederationMath and add it to
IfcViewerCore, which the Emscripten build links.

What stays behind is what genuinely needs the dependencies: computeModelGeoref
reads an ifcopenshell::file, and the Federation class is a QObject that
persists .ifcfed. Federation.h includes the new header, so no caller changes.

FederationMath needs convert() to resolve a federation unit name to metres and
x_axis_to_angle_deg() to read grid north off a coordinate operation, hence the
helpers_math dependency added in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:06:37 +10:00
Dion Moult e0b226f4ca Split the schema-free half of the unit and geolocation helpers out
unit.h and geolocation.h both include ../ifcparse/express.h for the entity
walking they do, which puts the whole module out of reach of anything that
cannot link IfcParse. Most of what a viewer wants from them needs no IFC at
all: the unit conversion tables, and the Helmert parameters-to-matrix math.

Move those into unit_convert and geolocation_transform, and build them as a
new helpers_math target that `helpers` re-exports PUBLIC, so existing callers
keep working through the unchanged unit.h / geolocation.h includes. The new
target has no IfcParse or Qt dependency and so builds under Emscripten, where
the rest of this directory cannot.

One target rather than compiling the sources into each consumer: the glob in
this directory would otherwise put them in libhelpers.a as well, leaving two
copies of the same objects in any link that pulls both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:06:37 +10:00
Dion Moult 79bd3563de Refill the web chunk fetch pipeline from each load completion
Queued chunk loads waited for the next render frame to start, so streaming
advanced at frame cadence rather than as fast as the in-flight cap allowed.
driveStreamingLoads now queues whatever it could not start and every load
completion drains that queue, decoupling fetching from the render loop.

pumpWebChunkLoads is deliberately defined outside the __EMSCRIPTEN__ block
that holds the rest of the byte-range streaming code: driveStreamingLoads
calls it unconditionally and ViewportCore.h declares it unconditionally, so
desktop needs a definition to link against. The body guards itself instead
and compiles to a no-op off the web, where loads are not asynchronous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:06:37 +10:00
Andrej730 4e887e1c59 Reapply 253918c
Fixes pyodide build. Reverted in af58eaf by accident?
2026-08-10 20:54:36 +05:00
Andrej730 fa9f3b5cb7 pyodide/order: update script after dlls rename 2026-08-10 20:50:04 +05:00
Andrej730 77dc679a6e pytest: fix warnings from using non-collections 2026-08-10 18:39:07 +05:00
Andrej730 ce9e2b94d5 Update plugins gitignore 2026-08-10 18:13:45 +05:00
Andrej730 58dcaed89a ruff: some util rules 2026-08-10 17:06:56 +05:00
Andrej730 109bd58384 ruff: use readable rule names in ignores 2026-08-10 17:06:56 +05:00
Andrej730 b6dccce12a ruff: use readable rule names in selectors 2026-08-10 17:06:56 +05:00
Andrej730 daa7d98b3f ruff: remove unused noqa
Most of them are actually correct, but they're not enforced in general on the repo, so using them blocks us from flagging `unused-noqa` for rules that we actually do use.
2026-08-10 17:06:56 +05:00
Andrej730 321760cea4 bcf: bump required Python version to 3.10
3.9 is EOL
2026-08-10 16:54:37 +05:00
Andrej730 7b9615f4e5 ruff: fix unsorted-dunder-all 2026-08-10 16:54:37 +05:00
Andrej730 ff22a9d1f3 ruff: fix deprecated-import 2026-08-10 16:54:37 +05:00
Andrej730 055f64fa9b ruff: fix quoted-annotation 2026-08-10 16:54:37 +05:00
Andrej730 7370d07db1 ruff: sort imports 2026-08-10 16:54:37 +05:00
Andrej730 717d6aa2af ruff: fix pyprojects using select instead of extend-select by mistake 2026-08-10 16:33:01 +05:00
Andrej730 69b0409aa0 build_pyodide: normalize version added to meta.yaml
Prevents error below:
```
ValueError: Version mismatch in ifcopenshell: version in meta.yaml is '0.9.0alpha0' but version from wheel name is '0.9.0a0'
```
2026-08-10 16:33:01 +05:00
Andrej730 4095d5c8d6 pyodide/meta.yaml: better document version placeholder 2026-08-10 16:33:01 +05:00
Andrej730 19a3707f72 build-all: build swig natively for pyodide 2026-08-10 15:11:36 +05:00
Andrej730 262117c4f8 build-all: drop unused kwargs in build_dependency 2026-08-10 15:11:36 +05:00
Andrej730 246fa24be0 build-all: reuse WASM constant for consistency 2026-08-10 15:11:36 +05:00
Andrej730 35d2fb43e2 build_osx: use uv run 2026-08-10 15:11:24 +05:00
Andrej730 f10f7eba83 build-all: use assert_never instead of ValueError 2026-08-10 15:11:24 +05:00
Andrej730 785936000a ruff: sort imports 2026-08-10 13:35:22 +05:00
Thomas Krijnen 83fc219a8a publish-cpp-api-docs.yml 2026-08-10 06:15:49 +02:00
Thomas Krijnen 7a1dcd07c8 Handle version postfixes 2026-08-10 05:19:17 +02:00
Thomas Krijnen b63137e859 Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu 2026-08-10 03:31:02 +02:00
Thomas Krijnen 3d15500976 run black 2026-08-09 14:17:35 +02:00
Thomas Krijnen 64aed6a766 try fix stub 2026-08-09 14:11:49 +02:00
Thomas Krijnen a08eed7ac9 swig ignore ifcopenshell::detail::performance_scope 2026-08-09 14:04:04 +02:00
Thomas Krijnen 076f46cfeb Further propagate logger so that test succeeds 2026-08-09 14:00:42 +02:00
Thomas Krijnen a353edb9e0 check_call() so that init errors surface earlier 2026-08-09 13:59:44 +02:00
Thomas Krijnen 30fb379e32 Remove cwd from import in case you're running tests like I do 2026-08-09 13:16:24 +02:00
Thomas Krijnen dbea3f0362 Reapply skip type bitmap after field reordering changes 2026-08-09 12:56:15 +02:00
Thomas Krijnen b5eca83357 Adapt for namespaces changes 2026-08-09 12:48:35 +02:00
Thomas Krijnen e5aaf7c602 Adapt for namespaces changes 2026-08-09 12:45:00 +02:00
Thomas Krijnen 9e53d0dcc9 Don't bind to reference in order not to overwrite entity instance storage in case of IfcPropertySetDefinitionSet 2026-08-09 12:42:35 +02:00
Thomas Krijnen 17c4d8faff Skip unavailable shape-stat kernels
Generated with the assistance of an AI coding tool.
2026-08-09 11:51:06 +02:00
Thomas Krijnen c9c7edd4d6 Require SWIG 4.1 in CMake
Generated with the assistance of an AI coding tool.
2026-08-09 10:58:05 +02:00
Thomas Krijnen 96653029cf Upgrade standalone CI to SWIG 4.2.1
Generated with the assistance of an AI coding tool.
2026-08-09 10:49:49 +02:00
Thomas Krijnen e9fffc221b Silence final compiler warnings
Generated with the assistance of an AI coding tool.
2026-08-09 10:03:41 +02:00
Thomas Krijnen a441757080 Use underscore plugin artifact names
Generated with the assistance of an AI coding tool.
2026-08-09 09:52:17 +02:00
Thomas Krijnen c818f48a47 Own completed iterator results uniquely
Generated with the assistance of an AI coding tool.
2026-08-09 09:14:41 +02:00
Thomas Krijnen 28c9c1d34d Silence remaining compiler warnings
Generated with the assistance of an AI coding tool.
2026-08-09 09:04:28 +02:00
Robert Sigmundsson f05dd4aea5 Fix #9278. calculate_unit_scale raises the SI prefix to the length exponent for prefixed SQUARE_METRE/CUBIC_METRE units.
An SI prefix attaches to the base unit symbol and the prefixed symbol is
raised to the power as a whole: DECI CUBIC_METRE is dm3 = a litre = 1e-3 m3,
not 0.1 m3. The scale factor previously applied the prefix multiplier
linearly for all IfcSIUnits, inflating volumes x100 and areas x10 for such
declarations (produced e.g. by MagiCAD for Revit MEP exports).

Following the reviewer note in #9278, the exponent is taken from the
derived attribute IfcSIUnit.Dimensions rather than from substring matching
on the unit name: the multiplier is raised to LengthExponent only when the
unit's dimensions are a pure power of length, so prefixed derived units
(KILO PASCAL, MEGA NEWTON) and non-length units (KILO GRAM) correctly keep
the linear multiplier. This matches the exponent handling already present
in convert() and named_dimensions in the same module.

Adds regression tests for prefixed AREAUNIT/VOLUMEUNIT and for the
linear-prefix behaviour of PRESSUREUNIT/MASSUNIT.
2026-08-09 08:41:15 +02:00
Thomas Krijnen dcfc22e29e Transfer iterator result ownership
Generated with the assistance of an AI coding tool.
2026-08-09 04:54:51 +02:00
Thomas Krijnen be3c2ee770 Expose geometry types in snake case
Generated with the assistance of an AI coding tool.
2026-08-09 04:44:18 +02:00
Thomas Krijnen fbfa51c451 Fix MSVC geometry build errors
Generated with the assistance of an AI coding tool.
2026-08-09 04:15:58 +02:00
Thomas Krijnen 19f3261dc3 Fixed by @Moult 2026-08-09 03:46:25 +02:00
Richard Brice 7ed8584edc Revised update_alignment_parameter_segment_tags to make EndTag optional 2026-08-08 10:35:20 -07:00
Thomas Krijnen 61f30dd200 Silence obvious compiler warnings
Generated with the assistance of an AI coding tool.
2026-08-08 17:08:26 +02:00
Thomas Krijnen b706121f53 Replace Boost function callbacks
Generated with the assistance of an AI coding tool.
2026-08-08 16:28:47 +02:00
Thomas Krijnen 99a09a2a3c Use snake case conversion result APIs
Generated with the assistance of an AI coding tool.
2026-08-08 16:09:30 +02:00
Thomas Krijnen 2ba55ba984 Flatten the geometry representation namespace
Generated with the assistance of an AI coding tool.
2026-08-08 15:56:41 +02:00
Thomas Krijnen 616c7a00d5 Inline conversion result vectors
Generated with the assistance of an AI coding tool.
2026-08-08 15:45:04 +02:00
Thomas Krijnen 8c003110fe Replace Boost shared pointers
Generated with the assistance of an AI coding tool.
2026-08-08 15:37:53 +02:00
Thomas Krijnen 4e49b640a7 Own iterator geometry results
Return independent geometry copies with unique ownership, preserve parent lifetimes, and teach the Python wrapper to own derived results. Keep serializer inputs non-owning and replace Collada's deferred object with copied triangulation elements.\n\nGenerated with the assistance of an AI coding tool.
2026-08-08 15:18:51 +02:00
Thomas Krijnen c30841aad6 Remove unused adaptor element path
The optional adaptor element list was never assigned, so simplify IfcConvert to use its geometry iterator unconditionally.

Generated with the assistance of an AI coding tool.
2026-08-08 15:00:48 +02:00
Thomas Krijnen 4597929df9 Remove _t suffixes from public types
Rename header-scope aliases, enums, and helper types while retaining descriptive names where dropping the suffix would create a collision.

Generated with the assistance of an AI coding tool.
2026-08-08 14:58:26 +02:00
Thomas Krijnen 2859c1ef17 Use value serialization in sphere example
Update the stale pointer-form example and pass the IFC file required by the current serialization API.

Generated with the assistance of an AI coding tool.
2026-08-08 14:21:10 +02:00
Thomas Krijnen 7ae6bf4374 Rename geometry and serializer files
Apply the rename manifest, normalize serializer filenames to the classes they define, and update includes and CMake source lists.

Generated with the assistance of an AI coding tool.
2026-08-08 14:20:05 +02:00
Thomas Krijnen 02481b3247 Wrap more classes into ifcopenshell:: namespace 2026-08-08 13:58:39 +02:00
Thomas Krijnen 2c47c9d4fa Irrelevant comment 2026-08-08 13:30:47 +02:00
Thomas Krijnen c2abc3f844 Remove old Java Native Interface code 2026-08-08 13:29:29 +02:00
Thomas Krijnen 4dcd644a32 Deleted unmigrated examples 2026-08-08 13:10:01 +02:00
Thomas Krijnen 6fea72b045 Run black 2026-08-08 12:42:25 +02:00
Thomas Krijnen 1573730f18 Disambiguate naming 2026-08-08 12:35:44 +02:00
Thomas Krijnen 8f4832651a Track patch rename 2026-08-08 12:30:26 +02:00
Thomas Krijnen af58eaf79f Last minute refactoring 2026-08-08 07:42:45 +02:00
Thomas Krijnen 8870ffb018 Rework c++ docs 2026-08-08 03:44:56 +02:00
Richard Brice c5ba22451f Adds update_alignment_parameter_segment_tags function 2026-08-07 14:33:04 -07:00
Andrej730 1a931ddfd9 Revert "build_pyodide: try more recent pyodide-build"
This reverts commit f7876a97ee.

There's some emscripten mismatch, will try to bump it later.
2026-08-07 20:04:46 +05:00
Andrej730 f7876a97ee build_pyodide: try more recent pyodide-build 2026-08-07 17:53:18 +05:00
Andrej730 4d0e5f6aee ifcopenshell.file: improve missing attribute error msg 2026-08-07 17:53:18 +05:00
Andrej730 dfc60196ec ifcwrap/cmake: fix using python:abc feature on older swig 2026-08-07 16:00:33 +05:00
Andrej730 ef4bba8b33 IfcGeomWrapper: remove stale IfcGeom::Matrix reference
It was removed long time ago in c78b289
2026-08-07 16:00:33 +05:00
Andrej730 e044b865dd pyodide/test_wheel: make it support modular wheels 2026-08-06 19:52:49 +05:00
Andrej730 47812b2d32 gitignore: ignore non-plugins shared objects too (--ifcopenshell-shared) 2026-08-06 19:49:54 +05:00
Andrej730 16e58f7369 build_pyodide: push built dependencies as build finished
As we do in build_rocky/build_osx already, allowing to push dependencies in case build succeeds but some later tests fail.
2026-08-06 19:49:54 +05:00
Andrej730 253918c100 serialization/cmake: remove dependency on geometry_kernel_opencascade
To fix wrapper depending on `ifcopenshell.geometry.kernel.opencascade.so` plugin, which breaks wasm module import (wrapper imported first and it fails because occt kernel isn't loaded yet and loading kernel first since it needs some symbols from the wrapper).

`geometry_serializer` seems to need `OpenCascadeConversionResult.h` from the kernel just to be able to refer to structs (`ConversionResultShape`, `OpenCascadeShape`) and include with relative path already allows it.
2026-08-06 19:49:54 +05:00
Andrej730 c57554a07e ifcwrap/cmake: dont link against geom kernels
Produces errors in wasm builds - we want to be able to load the wrapper first and all other dependencies should be pluggable. But loading wrapper first failed, because it depended on the kernels. Loading kernels first would also fail, since OCCT kernel is using using some symbols from core (`ifcopenshell::exception`) and in wasm they have to be resolved all during `dlopen`.
2026-08-06 19:49:54 +05:00
Andrej730 2aff2adb42 gitattributes: normalize .i swig templates line endings
IfcGeomWrapper.i was stored on index as CRLF and on Linux it sometimes produced distracting diffs, when working on the file.
2026-08-06 19:49:54 +05:00
Andrej730 3d05ccbd59 gitattributes: ensure platform dependent files are stored using correct line endings 2026-08-06 19:49:38 +05:00
Andrej730 027203008d black . 2026-08-06 19:11:27 +05:00
Dion Moult 74b405a9f7 Write GlobalId index when serializing to RocksDB
rocks_db_file_storage already exposes a `g|`-prefixed guid -> instance
name map, but RocksDbSerializer never populated it, so by_guid() on a
converted file always threw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:20:03 +10:00
Dion Moult 38e0e0e297 Compare entity instances in one file by identity again
entity_instance.file is a property backed by a fresh SWIG wrapper on every
access, and ifcopenshell::file had no __eq__, so `self.file != other.file`
in entity_instance.__eq__ compared two throwaway wrappers and was always
true - even for an instance against itself. Every entity comparison
therefore took the deep get_info() branch, making distinct but structurally
identical instances compare equal and leaving the final `return False`
unreachable. Bonsai's TestAddRepresentationItemToShapeAspect showed this as
two separate IfcShapeAspects being treated as one, so the stale aspect was
never removed.

Restore the file_pointer() pair that was commented out on both
ifcopenshell::file and express::Base - IfcParseWrapper.i already described
it as the way to "trace file ownership of instances on the python side" -
and give file the __eq__/__hash__ it was missing. The express::Base one
needs $self->file() now that file_ lives on instance_data. This also
repairs rocksdb_lazy_instance.__eq__, which already called file_pointer().

EXPRESS `=` is value comparison and `:=:` is instance comparison, but
rule_compiler emits `==` for both (see the @todo on process_rel_op), and
derived attributes build their operands in the shared global file, so rules
compare same-file instances and need value semantics. Restore those for the
duration of rule execution with settings.compare_instances_by_value,
alongside the existing unpack_non_aggregate_inverses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:20:03 +10:00
Richard Brice 048242783e Updates update_key_point_referents to confirm to CT 4.1.4.4.3 2026-08-05 07:26:27 -07:00
Andrej730 d1027c5877 build-all: fix missing CXXFLAGS in manual build (f8f4725)
In the mentioned commit I've dropped workaround, because issue upstream was resolved, but forgot to load flags from `pyodide` fro manual build.
2026-08-05 18:37:46 +05:00
Andrej730 d65df3665a split_pyodide_ifcopenshell_wheel: print some logs 2026-08-05 18:37:46 +05:00
Andrej730 393640b121 split_pyodide_ifcopenshell_wheel: add doc-string 2026-08-05 18:37:46 +05:00
Andrej730 5d6e3fbdc8 order_pyodide_wheel_shared_objects: explain why 2026-08-05 18:37:46 +05:00
Andrej730 24c3d855de build-all: drop workaround for expired mpfr ssl 2026-08-05 18:37:46 +05:00
Andrej730 ae4f5c92fb build-all: remove unnecessary quoting from annotations and Union 2026-08-05 18:37:46 +05:00
Andrej730 75a3bf0175 build-all: accept truthy/falsy values in more consistent way 2026-08-05 18:37:46 +05:00
Andrej730 beb5c1f7ed build_pyodide: use uv 2026-08-05 18:37:46 +05:00
Andrej730 8bdaa8c7cb build-all: ifcopenshell-shared
As we're not currently bundling dlls for all other external dependencies.
It seems `--shared`in CI previously  worked sort of by accident - since it was relying on the cached build outputs that were previously built statically.
2026-08-04 22:13:25 +05:00
Andrej730 3340d88b95 ifcwrap/cmake: add missing geometry_serializer runtime target installation
Caused errors like so:
```
Traceback (most recent call last):
  File "/__w/IfcOpenShell/IfcOpenShell/build/Linux/x86_64/install/python-3.13.6/lib/python3.13/site-packages/ifcopenshell/__init__.py", line 87, in <module>
    from . import ifcopenshell_wrapper
  File "/__w/IfcOpenShell/IfcOpenShell/build/Linux/x86_64/install/python-3.13.6/lib/python3.13/site-packages/ifcopenshell/ifcopenshell_wrapper.py", line 10, in <module>
    from . import _ifcopenshell_wrapper
ImportError: libifcopenshell.geometry.writer.so: cannot open shared object file: No such file or directory
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    import inspect, ifcopenshell; print(inspect.getfile(ifcopenshell))
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/__w/IfcOpenShell/IfcOpenShell/build/Linux/x86_64/install/python-3.13.6/lib/python3.13/site-packages/ifcopenshell/__init__.py", line 89, in <module>
    raise ImportError("IfcOpenShell not built for '%s' (%s)" % (python_distribution, e)) from e
ImportError: IfcOpenShell not built for 'linux/64bit/python3.13' (libifcopenshell.geometry.writer.so: cannot open shared object file: No such file or directory)
```
2026-08-04 22:10:31 +05:00
Andrej730 0e6e3edaa6 build-all: small alignment fix 2026-08-04 20:57:45 +05:00
Andrej730 ccb9be0940 build_rocky: add comments to the script 2026-08-04 16:21:01 +05:00
Andrej730 82f0a74ab2 Build workflows: drop stale line
It's a stale line from original approach (b599ee1040) that was using `bin` as a stage area. Now zip is packed right to `~/output`.
2026-08-04 16:20:17 +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
638 changed files with 21080 additions and 20152 deletions
-1
View File
@@ -1,5 +1,4 @@
Checks: 'bugprone-*,cert-*,clang-analyzer-*,readability-*'
WarningsAsErrors: ''
HeaderFilterRegex: ''
AnalyzeTemporaryDtors: false
FormatStyle: none
+4 -2
View File
@@ -19,6 +19,8 @@
# normalize the line endings of the following files
*.bat text eol=crlf
*.cmd text eol=crlf
*.cpp text
*.css text
*.csv text
@@ -28,20 +30,20 @@
*.gitkeep
*.h text
*.html text
*.i text
*.ifc text
*.json text
*.md text
*.po text
*.pot text
*.py text
*.sh text eol=lf
*.txt text
# files not normalized ATM
# bat
# bnf
# blend
# i
# ico
# mo
# mpass
+7 -5
View File
@@ -35,6 +35,9 @@ jobs:
lfs: true
token: ${{ secrets.BUILD_REPO_TOKEN }}
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install Dependencies
run: |
brew update
@@ -61,7 +64,7 @@ jobs:
- name: Unpack Dependencies
run: |
cd build
python ../nix/cache_dependencies.py unpack
uv run ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
@@ -102,7 +105,7 @@ jobs:
# INSTALL_RPATH to "@loader_path" on Apple.
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release \
BUILD_BONSAIVIEWER=ON QT_DIR="${QT_DIR}" \
python3 ./nix/build-all.py -v --diskcleanup --shared ${MAC_INTEL} \
uv run ./nix/build-all.py -v --diskcleanup --ifcopenshell-shared ${MAC_INTEL} \
| tee build.log
- name: Upload Build Logs
@@ -119,7 +122,7 @@ jobs:
- name: Pack Dependencies
run: |
cd build
python ../nix/cache_dependencies.py pack
uv run ../nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
@@ -136,7 +139,7 @@ jobs:
# packaging/build.py stages the connector binary + connector.json
# into dist/autodesk/; the .app loop below copies that folder into
# the bundle. Same on-disk shape as the Linux and Windows builds.
python3 src/bonsaiviewer-autodesk/packaging/build.py
uv run src/bonsaiviewer-autodesk/packaging/build.py
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
test -d "$autodesk_connector_dir"
@@ -175,7 +178,6 @@ jobs:
popd > /dev/null
done
rm -f "$install_root"/bin/*.zip
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
exe=`basename "$exe_path"`
package_dir="$install_root/.package-${exe}"
+31 -27
View File
@@ -8,6 +8,13 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install Python
# Installs latest Python version so it's preferred by uv over system Python.
run: uv python install
- name: Checkout Repository
uses: actions/checkout@v7
with:
@@ -26,7 +33,7 @@ jobs:
- name: Unpack Dependencies
run: |
cd ifcopenshell_build
python ../IfcOpenShell/nix/cache_dependencies.py unpack
uv run ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.23
@@ -40,18 +47,6 @@ jobs:
NEW_FILE=`echo $FILE | sed "s/-/+${GITHUB_SHA:0:7}-/2"`
mv $FILE $NEW_FILE
- name: Order wheel shared objects
run: |
python ./IfcOpenShell/pyodide/order_pyodide_wheel_shared_objects.py dist/ifcopenshell-*.whl
- name: Split packages
run: |
VERSION=v`cat ./IfcOpenShell/VERSION`
mkdir -p dist-modular
python ./IfcOpenShell/pyodide/split_pyodide_ifcopenshell_wheel.py dist/ifcopenshell-*.whl ./dist-modular
cd dist-modular
zip -r -qq ifcopenshell-modular-${VERSION}-${GITHUB_SHA:0:7}-pyodide.zip *.whl
- name: Upload Build Logs
if: always()
uses: actions/upload-artifact@v7
@@ -61,23 +56,10 @@ jobs:
ifcopenshell_build/*/*/logs/*.log
retention-days: 30
- name: Run wheel tests
run: |
cp -r IfcOpenShell/pyodide/test test
# venv set up in build_pyodide.sh.
source .venv/bin/activate
uv pip install pytest-pyodide
PYODIDE_ROOT_DIST=`pyodide config get pyodide_root`/dist
# `pytest-pyodide` requires pyodide in 'pyodide' directory in cwd, when running `pytest`.
cp -r $PYODIDE_ROOT_DIST test/pyodide
cp dist/ifcopenshell-*.whl test/pyodide
cd test
pytest --capture=no
- name: Pack Dependencies
run: |
cd ifcopenshell_build
python ../IfcOpenShell/nix/cache_dependencies.py pack
uv run ../IfcOpenShell/nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
@@ -88,6 +70,28 @@ jobs:
git commit -m "Update build artifacts [skip ci]" || echo "No changes to commit"
git push || echo "Push failed"
- name: Order wheel shared objects
run: |
uv run ./IfcOpenShell/pyodide/order_pyodide_wheel_shared_objects.py dist/ifcopenshell-*.whl
- name: Split packages
run: |
VERSION=v`cat ./IfcOpenShell/VERSION`
mkdir -p dist-modular
uv run ./IfcOpenShell/pyodide/split_pyodide_ifcopenshell_wheel.py dist/ifcopenshell-*.whl ./dist-modular
cd dist-modular
zip -r -qq ifcopenshell-modular-${VERSION}-${GITHUB_SHA:0:7}-pyodide.zip *.whl
- name: Run wheel tests
run: |
# venv set up in build_pyodide.sh.
source .venv/bin/activate
ln -s "$PWD/dist" IfcOpenShell/dist
ln -s "$PWD/dist-modular" IfcOpenShell/dist-modular
cd IfcOpenShell/pyodide
./run_pytest.py setup
./run_pytest.py run
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
+23 -2
View File
@@ -80,7 +80,7 @@ jobs:
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON \
uv run --with aqtinstall ./nix/build-all.py \
-v --diskcleanup --shared 2>&1 \
-v --diskcleanup --ifcopenshell-shared 2>&1 \
| tee build.log
- name: Upload Build Logs
@@ -134,9 +134,12 @@ jobs:
done
fi
# Ensure that all shared libraries in provided dest `$1`
# are present using their SONAMEs (at least as symlinks).
ensure_soname_links() {
dest="$1"
find "$dest" -maxdepth 1 -type f -name "*.so*" | while IFS= read -r shared_object; do
# TODO: actual pattern is "Library soname" instead of "Shared library"?
soname=$(readelf -d "$shared_object" 2>/dev/null | sed -n 's/.*(SONAME).*Shared library: \[\(.*\)\].*/\1/p' | head -n 1)
[ -n "$soname" ] || continue
[ -e "$dest/$soname" ] && continue
@@ -144,6 +147,8 @@ jobs:
done
}
# Copy all libs from `install/ifcopenshell` to the provided `$1`.
# Set `$2` to `0` to skip including geometry writers.
stage_runtime_payload() {
dest="$1"
include_geometry_writers="${2:-1}"
@@ -161,18 +166,22 @@ jobs:
ensure_soname_links "$dest"
}
# Copy all libs from `QT_DIR` to the provided `$2`.
stage_qt_runtime_payload() {
exe_path="$1"
dest="$2"
[ -n "${QT_DIR:-}" ] && [ -d "$QT_DIR/lib" ] || return 0
# Skip executables that don't depend on QT (don't have `libQt6` referenced).
if ! LD_LIBRARY_PATH="$QT_DIR/lib:${LD_LIBRARY_PATH:-}" ldd "$exe_path" 2>/dev/null | grep -q "libQt6"; then
return 0
fi
# Copy all QT libs to `dest`.
find "$QT_DIR/lib" -maxdepth 1 \( -type f -o -type l \) -name "*.so*" -exec cp -P {} "$dest/" \;
ensure_soname_links "$dest"
# Copy QT plugins.
if [ -d "$QT_DIR/plugins" ]; then
pushd "$QT_DIR/plugins" > /dev/null
find . \( -type f -o -type l \) -name "*.so*" | while IFS= read -r plugin_file; do
@@ -180,6 +189,7 @@ jobs:
cp -P "$plugin_file" "$dest/plugins/$plugin_file"
done
popd > /dev/null
# Point plugins rpath to `$dest`.
if [ -d "$dest/plugins" ]; then
find "$dest/plugins" -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN/../..:$ORIGIN' {} \;
fi
@@ -190,17 +200,23 @@ jobs:
printf "[Paths]\nPrefix = .\n" > "$dest/qt.conf"
}
# Check all binaries in the dest `$1`
# and report if they're still missing dependencies or are static.
check_runtime_dependencies() {
package_dir="$1"
missing=0
# Iterate over all .so files.
while IFS= read -r binary_file; do
# Skip non-binaries.
readelf -h "$binary_file" >/dev/null 2>&1 || continue
# Report non-dynamic binaries.
if ! env -u LD_LIBRARY_PATH ldd "$binary_file" > "$package_dir/.ldd.out" 2>&1; then
echo "ldd failed for $binary_file"
cat "$package_dir/.ldd.out"
missing=1
continue
fi
# Report missing dependencies.
if grep -q "not found" "$package_dir/.ldd.out"; then
echo "Missing runtime dependencies for $binary_file"
grep "not found" "$package_dir/.ldd.out"
@@ -208,12 +224,15 @@ jobs:
fi
done < <(find "$package_dir" -type f \( -perm /111 -o -name "*.so" -o -name "*.so.*" \))
rm -f "$package_dir/.ldd.out"
# TODO: should error?
if [ "$missing" -ne 0 ]; then
echo "Runtime dependency check found issues; continuing packaging."
fi
return 0
}
# Iterate over all built Python wrappers in `install/ifcopenshell/python-x.y.z`.
# and zip them, bundling all dynamic libs from `lib`.
ls -d python-* | while read py_version; do
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
numbers=`echo $py_version | grep -oE '[0-9]+\.[0-9]+' | tr -d '.'`
@@ -227,13 +246,15 @@ jobs:
fi
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
find ifcopenshell -name "*.pyc" -delete
# TODO: packs qt libs also?
stage_runtime_payload ifcopenshell
zip -y -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell
mv *.zip ~/output
popd > /dev/null
done
rm -f "$install_root"/bin/*.zip
# Iterate over all executables in `install/ifcopenshell/bin` and zip them.
# Each zip bundles dynamic libs from `lib` and also qt libs.
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
exe=`basename "$exe_path"`
package_dir="$install_root/.package-${exe}"
+1 -2
View File
@@ -92,7 +92,7 @@ jobs:
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON \
uv run --with aqtinstall ./nix/build-all.py \
-v --diskcleanup --shared 2>&1 \
-v --diskcleanup --ifcopenshell-shared 2>&1 \
| tee build.log
- name: Upload Build Logs
@@ -245,7 +245,6 @@ jobs:
popd > /dev/null
done
rm -f "$install_root"/bin/*.zip
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
exe=`basename "$exe_path"`
package_dir="$install_root/.package-${exe}"
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
python-version: '3.11'
- name: Get current version
id: version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
run: echo "version=$(sed -E 's/[[:alpha:]]+[0-9]+$//' VERSION)" >> $GITHUB_OUTPUT
- name: Compile
run: |
cd src/bonsai && make dist PLATFORM=${{ matrix.config.short_name }} PYVERSION=${{ matrix.pyver }}
@@ -27,7 +27,9 @@ jobs:
- name: Get current version
id: version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
# Strip any trailing prerelease label and number; the dated alpha
# suffix is added below.
run: echo "version=$(sed -E 's/[[:alpha:]]+[0-9]+$//' VERSION)" >> $GITHUB_OUTPUT
- name: Get current date
id: date
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
python-version: '3.11'
- name: Get current version
id: version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
run: echo "version=$(sed -E 's/[[:alpha:]]+[0-9]+$//' VERSION)" >> $GITHUB_OUTPUT
- name: Get current date
id: date
run: echo "date=$(date +'%y%m%d')" >> $GITHUB_OUTPUT
+13 -2
View File
@@ -43,6 +43,7 @@ jobs:
sudo apt update
sudo apt-get install --no-install-recommends -y \
cmake \
bison \
gcc \
g++ \
libboost-date-time-dev \
@@ -61,13 +62,23 @@ jobs:
libocct-ocaf-dev \
libocct-visualization-dev \
libpcre3-dev \
libpcre2-dev \
libtbb-dev \
libxml2-dev \
libxi-dev \
occt-misc \
tcl-dev \
tk-dev \
swig
tk-dev
- name: Build SWIG
# IfcOpenShell requires SWIG 4.1+, ubuntu-22.04 ships 4.0.2.
run: |
sudo apt-get remove --purge -y swig swig4.0
git clone https://github.com/swig/swig --branch v4.2.1 --depth 1
cmake -S swig -B swig/build -DCMAKE_BUILD_TYPE=Release
cmake --build swig/build -j "$(nproc)"
sudo cmake --install swig/build
swig -version
- name: Configure minimal IfcOpenShell
run: |
+1 -1
View File
@@ -121,7 +121,7 @@ jobs:
cd OpenCOLLADA
git checkout v1.6.68
patch -p1 --batch --forward -i ../nix/patches/opencollada/pr622_and_disable_subdirs.patch
patch -p1 --batch --forward -i ../nix/patches/opencollada/allow_static_libraries_config_on_unix.patch
patch -p1 --batch --forward -i ../nix/patches/opencollada/config_select_libs_by_use_shared.patch
mkdir build && cd build
cmake .. \
-DCMAKE_BUILD_TYPE=Release \
@@ -0,0 +1,87 @@
# This file was generated with the assistance of an AI coding tool.
name: Publish C++ API documentation
on:
push:
branches:
- v0.9.0
paths:
- '.github/workflows/publish-cpp-api-docs.yml'
- 'docs/cpp-api/**'
- 'src/ifcgeom/**'
- 'src/ifcparse/**'
- 'src/serializers/**'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: publish-cpp-api-docs
cancel-in-progress: false
jobs:
publish:
if: github.repository == 'IfcOpenShell/IfcOpenShell'
runs-on: ubuntu-24.04
steps:
- name: Checkout IfcOpenShell
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Install documentation dependencies
run: |
sudo apt-get update
sudo apt-get install --yes doxygen graphviz
python -m pip install --requirement docs/cpp-api/requirements.txt
- name: Build C++ API documentation
working-directory: docs/cpp-api
run: |
export PROJECT_NUMBER="$(git rev-parse --short HEAD)"
python -m sphinx -M html . output -W --keep-going
- name: Checkout documentation repository
uses: actions/checkout@v7
with:
repository: IfcOpenShell/cpp_docs
ref: master
path: published-docs
token: ${{ secrets.BUILD_REPO_TOKEN }}
- name: Replace published documentation
run: |
publish_tree="${RUNNER_TEMP}/published-docs-tree"
mkdir -p "${publish_tree}/v0.9.0-latest"
rsync --archive docs/cpp-api/output/html/ "${publish_tree}/v0.9.0-latest/"
touch "${publish_tree}/.nojekyll"
if [[ -f published-docs/CNAME ]]; then
cp published-docs/CNAME "${publish_tree}/CNAME"
fi
rsync --archive --delete --exclude='.git/' "${publish_tree}/" published-docs/
- name: Commit and push if changed
working-directory: published-docs
env:
SOURCE_SHA: ${{ github.sha }}
run: |
git config user.name 'IfcOpenBot'
git config user.email 'IfcOpenBot@users.noreply.github.com'
git add --all
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update C++ API docs from ${SOURCE_SHA:0:7}"
git push origin master
+13 -3
View File
@@ -16,6 +16,9 @@
/src/ifcmax/out/
/src/ifcwrap/out/
/src/ifctester/webapp/public/pyodide/
# pyodide wheels
/dist/
/dist-modular/
/win/BuildDepsCache*.txt
@@ -108,9 +111,13 @@ src/ifcopenshell-python/ifcopenshell/_ifcopenshell_wrapper*.so
src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py
# plugins
src/ifcopenshell-python/ifcopenshell/ifcopenshell.document.*.so
src/ifcopenshell-python/ifcopenshell/ifcopenshell.geometry.*.so
src/ifcopenshell-python/ifcopenshell/ifcopenshell.parse.schema*.so
src/ifcopenshell-python/ifcopenshell/ifcopenshell_document_*.so
src/ifcopenshell-python/ifcopenshell/ifcopenshell_geometry_*.so
src/ifcopenshell-python/ifcopenshell/ifcopenshell_parse_schema*.so
src/ifcopenshell-python/ifcopenshell/libifcopenshell.geometry.so
src/ifcopenshell-python/ifcopenshell/libifcopenshell.geometry.writer.so
src/ifcopenshell-python/ifcopenshell/libifcopenshell.parse.so
src/ifcopenshell-python/ifcopenshell/libifcopenshell.plugin.so
# apple
.DS_Store
@@ -119,6 +126,9 @@ src/ifcopenshell-python/ifcopenshell/ifcopenshell.parse.schema*.so
.clangd
# clangd cache
.cache
# Useful for symlinking json compilation database from cmake,
# allowing clang commands without `-p path/to/build`.
/compile_commands.json
# Brickschema
src/bonsai/bonsai/bim/schema/Brick.ttl
+3 -3
View File
@@ -8,9 +8,6 @@
[submodule "src/ifcopenshell-python/test/Sample-BIM-Files"]
path = src/ifcopenshell-python/test/Sample-BIM-Files
url = https://github.com/IfcOpenShell/ids-test-files
[submodule "docs/cpp-api/assets/doxygen-awesome-css"]
path = docs/cpp-api/assets/doxygen-awesome-css
url = https://github.com/jothepro/doxygen-awesome-css.git
[submodule "src/ifcopenshell-python/ifcopenshell/simple_spf"]
path = src/ifcopenshell-python/ifcopenshell/simple_spf
url = https://github.com/IfcOpenShell/step-file-parser
@@ -20,3 +17,6 @@
[submodule "src/svgfill/3rdparty/svgpp"]
path = src/svgfill/3rdparty/svgpp
url = https://github.com/svgpp/svgpp
[submodule "src/ifcopenshell-python/test/IfcRelSpaceBoundary_TestFiles"]
path = src/ifcopenshell-python/test/IfcRelSpaceBoundary_TestFiles
url = https://github.com/CyrilWaechter/IfcRelSpaceBoundary_TestFiles
+1 -1
View File
@@ -1 +1 @@
0.8.6
0.9.0alpha0
+19 -14
View File
@@ -36,6 +36,14 @@ file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
# CMake's project(VERSION) only accepts numeric components. Keep the complete
# release identifier for build information, but use its numeric release part
# for PROJECT_VERSION, SOVERSION, and generated CMake package metadata.
string(REGEX MATCH "^[0-9]+\\.[0-9]+\\.[0-9]+" PROJECT_VERSION_NUMERIC "${RELEASE_VERSION}")
if(NOT PROJECT_VERSION_NUMERIC)
message(FATAL_ERROR "VERSION must start with a numeric major.minor.patch version: '${RELEASE_VERSION}'")
endif()
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
if(POLICY CMP0141) # 3.25+
@@ -55,9 +63,15 @@ endif()
# Include utility macros and functions
include(utilities.cmake)
# use extra version to make pre-release using eg semver
# Use a SemVer-compatible spelling for CPack artifact names. A trailing
# alphabetic label and number is separated from the numeric version by a
# hyphen: for example, 0.9.0alpha0 becomes 0.9.0-alpha0.
if(NOT DEFINED EXTRA_VERSION)
set(EXTRA_VERSION "-alpha.3")
if(RELEASE_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+([A-Za-z]+)([0-9]+)$")
set(EXTRA_VERSION "-${CMAKE_MATCH_1}${CMAKE_MATCH_2}")
else()
set(EXTRA_VERSION "")
endif()
endif()
option(MINIMAL_BUILD "The build is to make a minimal version of IFC converter from OCCT into IFC." OFF)
@@ -119,7 +133,7 @@ option(WITH_ZSTD "Use Zstd compression in RocksDB writes" OFF)
option(USERSPACE_PYTHON_PREFIX "Installs IfcPython for the current user only instead of system-wide." OFF)
option(USE_DEBUG_PYTHON "Use debug binaries when building Debug IfcPython on Windows." OFF)
option(ADD_COMMIT_SHA "Add commit sha and branch in version number, requires git" OFF)
option(VERSION_OVERRIDE "Override the version defined in buildinfo.cpp with the file VERSION in the repository root" OFF)
option(VERSION_OVERRIDE "Use VERSION as the branch label when commit information is embedded" OFF)
set(
PYTHON_MODULE_INSTALL_DIR
@@ -127,15 +141,7 @@ set(
"Directory to install IfcPython package to. By default package is installed in found Python's site-packages."
)
if (VERSION_OVERRIDE)
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
else()
set(RELEASE_VERSION "0.8.0")
endif()
project(IfcOpenShell VERSION ${RELEASE_VERSION})
project(IfcOpenShell VERSION ${PROJECT_VERSION_NUMERIC})
# Make sure CMake modules in this project are found first
list(PREPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR})
@@ -693,8 +699,7 @@ endif()
# Documentation
if(BUILD_DOCUMENTATION)
set(CMAKE_MODULE_PATH "../docs/cmake")
add_subdirectory(../docs docs)
add_subdirectory(../docs/cpp-api docs/cpp-api)
endif()
if(BUILD_EXAMPLES)
+1
View File
@@ -52,6 +52,7 @@ macro(SET_INSTALL_SELF_RPATH _target)
endmacro()
function(ifcopenshell_plugin_target TARGET)
# Plug-ins are loaded by exact filename and should not receive a platform library prefix.
set_target_properties(${TARGET} PROPERTIES PREFIX "")
if((NOT WIN32) AND BUILD_SHARED_LIBS AND NOT WASM_BUILD AND NOT CREATE_BUNDLE AND NOT CMAKE_INSTALL_RPATH AND COMMAND SET_INSTALL_SELF_RPATH)
SET_INSTALL_SELF_RPATH(${TARGET})
+13 -33
View File
@@ -1,35 +1,15 @@
#Look for an executable called sphinx-build
find_program(SPHINX_EXECUTABLE NAMES sphinx-build DOC "Path to sphinx-build executable")
include(FindPackageHandleStandardArgs)
#Handle standard arguments to find_package like REQUIRED and QUIET
find_package_handle_standard_args(Sphinx "Failed to find sphinx-build executable" SPHINX_EXECUTABLE)
find_package(Doxygen REQUIRED)
#find_package(Sphinx REQUIRED)
find_program(
SPHINX_EXECUTABLE
NAMES sphinx-build
REQUIRED
DOC "Path to the sphinx-build executable"
)
set(SPHINX_SOURCE ${CMAKE_CURRENT_SOURCE_DIR})
set(SPHINX_BUILD ${CMAKE_CURRENT_BINARY_DIR}/docs/sphinx)
message(STATUS "SPHINX BUILD ${CMAKE_CURRENT_BINARY_DIR}")
file(MAKE_DIRECTORY ./output/doxygen)
if(DOXYGEN_FOUND)
add_custom_target(
Sphinx
ALL
COMMAND ${SPHINX_EXECUTABLE} -v -T -b html ${SPHINX_SOURCE} ${CMAKE_CURRENT_SOURCE_DIR}/output
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/output
COMMENT "Generating documentation with Sphinx"
)
# add_custom_target(ifcopenshell_python_docs ALL
# COMMAND make html
# WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/../src/ifcblenderexport/docs
# OUTPUT ${CMAKE_CURRENT_SOURCE_DIR}/../src/ifcblenderexport/docs
# COMMENT "Generating documentation with Sphinx")
else(DOXYGEN_FOUND)
message("Doxygen need to be installed to generate the doxygen documentation")
endif(DOXYGEN_FOUND)
add_custom_target(
cpp_api_docs
COMMAND ${SPHINX_EXECUTABLE} -M html . output -W --keep-going
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Generating the IfcOpenShell C++ API documentation"
VERBATIM
)
+63 -22
View File
@@ -68,7 +68,7 @@ PROJECT_LOGO =
# entered, it will be relative to the location where doxygen was started. If
# left blank the current directory will be used.
OUTPUT_DIRECTORY = ./output
OUTPUT_DIRECTORY = ./output/doxygen
# If the CREATE_SUBDIRS tag is set to YES then doxygen will create up to 4096
# sub-directories (in 2 levels) under the output directory of each output format
@@ -852,7 +852,7 @@ WARNINGS = YES
# will automatically be disabled.
# The default value is: YES.
WARN_IF_UNDOCUMENTED = YES
WARN_IF_UNDOCUMENTED = NO
# If the WARN_IF_DOC_ERROR tag is set to YES, doxygen will generate warnings for
# potential errors in the documentation, such as documenting some parameters in
@@ -901,7 +901,7 @@ WARN_IF_UNDOC_ENUM_VAL = NO
# Possible values are: NO, YES, FAIL_ON_WARNINGS and FAIL_ON_WARNINGS_PRINT.
# The default value is: NO.
WARN_AS_ERROR = NO
WARN_AS_ERROR = FAIL_ON_WARNINGS
# The WARN_FORMAT tag determines the format of the warning messages that doxygen
# can produce. The string should contain the $file, $line, and $text tags, which
@@ -944,7 +944,6 @@ WARN_LOGFILE =
# Note: If this tag is empty the current directory is searched.
INPUT = ../../src/ifcgeom \
../../src/ifcgeom_schema_agnostic \
../../src/ifcparse \
../../src/serializers \
@@ -1001,7 +1000,7 @@ RECURSIVE = YES
# Note that relative paths are relative to the directory from which doxygen is
# run.
EXCLUDE =
EXCLUDE = ../../src/ifcparse/schemas
# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or
# directories that are symbolic links (a Unix file system feature) are excluded
@@ -1025,7 +1024,33 @@ EXCLUDE_PATTERNS =
# wildcard * is used, a substring. Examples: ANamespace, AClass,
# ANamespace::AClass, ANamespace::*Test
EXCLUDE_SYMBOLS =
EXCLUDE_SYMBOLS = "ifcopenshell::geom::opaque_number::*" \
ifcopenshell::entity::attribute_by_name_cmp \
ifcopenshell::impl::rocks_db_file_storage::rocksdb_types_iterator \
ifcopenshell::impl::in_memory_file_storage::type_iterator \
"util::string_buffer::*_item" \
util::string_buffer::item \
ifcopenshell::geom::layer_filter::wildcards_match \
ifcopenshell::paged_file_impl::entry \
ifcopenshell::token \
attribute_value::pointer_type \
INCLUDE_PARENT_PARENT_DIR \
POSTFIX_SCHEMA_ \
POSTFIX_SCHEMA__ \
STRINGIFY_ \
MAKE_INIT_FN_ \
MAKE_INIT_FN__ \
key_from_string \
add_ \
subtract_ \
multiply_ \
divide_ \
equals_ \
less_than_ \
negate_ \
ifcopenshell::geom::utils::create_cube \
ifcopenshell::geom::utils::create_polyhedron \
ifcopenshell::geom::utils::create_nef_polyhedron
# The EXAMPLE_PATH tag can be used to specify one or more files or directories
# that contain example code fragments that are included (see the \include
@@ -1236,7 +1261,7 @@ IGNORE_PREFIX =
# If the GENERATE_HTML tag is set to YES, doxygen will generate HTML output
# The default value is: YES.
GENERATE_HTML = YES
GENERATE_HTML = NO
# The HTML_OUTPUT tag is used to specify where the HTML docs will be put. If a
# relative path is entered the value of OUTPUT_DIRECTORY will be put in front of
@@ -1311,7 +1336,7 @@ HTML_STYLESHEET =
# documentation.
# This tag requires that the tag GENERATE_HTML is set to YES.
HTML_EXTRA_STYLESHEET = assets/doxygen-awesome-css/doxygen-awesome.css
HTML_EXTRA_STYLESHEET =
# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or
# other source files which should be copied to the HTML output directory. Note
@@ -2166,7 +2191,7 @@ MAN_LINKS = NO
# captures the structure of the code including all documentation.
# The default value is: NO.
GENERATE_XML = NO
GENERATE_XML = YES
# The XML_OUTPUT tag is used to specify where the XML pages will be put. If a
# relative path is entered the value of OUTPUT_DIRECTORY will be put in front of
@@ -2303,7 +2328,7 @@ ENABLE_PREPROCESSING = YES
# The default value is: NO.
# This tag requires that the tag ENABLE_PREPROCESSING is set to YES.
MACRO_EXPANSION = NO
MACRO_EXPANSION = YES
# If the EXPAND_ONLY_PREDEF and MACRO_EXPANSION tags are both set to YES then
# the macro expansion is limited to the macros specified with the PREDEFINED and
@@ -2311,7 +2336,7 @@ MACRO_EXPANSION = NO
# The default value is: NO.
# This tag requires that the tag ENABLE_PREPROCESSING is set to YES.
EXPAND_ONLY_PREDEF = NO
EXPAND_ONLY_PREDEF = YES
# If the SEARCH_INCLUDES tag is set to YES, the include files in the
# INCLUDE_PATH will be searched if a #include is found.
@@ -2344,7 +2369,17 @@ INCLUDE_FILE_PATTERNS =
# recursively expanded use the := operator instead of the = operator.
# This tag requires that the tag ENABLE_PREPROCESSING is set to YES.
PREDEFINED =
PREDEFINED = IFC_PARSE_API= \
IFC_SCHEMA_API= \
IFC_GEOM_API= \
IFC_GEOMLIBRARY_API= \
IFC_GEOMSERIALIZATION_API= \
SERIALIZERS_API= \
"POSTFIX_SCHEMA(name)=name##_Schema" \
"Handle(name):=opencascade::handle<name>" \
kernel_=kernel \
Simplekernel_=Simplekernel \
inline=
# If the MACRO_EXPANSION and EXPAND_ONLY_PREDEF tags are set to YES then this
# tag can be used to specify a list of macro names that should be expanded. The
@@ -2353,7 +2388,22 @@ PREDEFINED =
# definition found in the source code.
# This tag requires that the tag ENABLE_PREPROCESSING is set to YES.
EXPAND_AS_DEFINED =
EXPAND_AS_DEFINED = kernel_ \
cgal_shape \
cgal_kernel \
cgal_placement \
cgal_point \
cgal_direction \
cgal_vector \
cgal_plane \
cgal_curve \
cgal_wire \
cgal_face \
cgal_polyhedron \
cgal_vertex_descriptor \
cgal_face_descriptor \
create_cube \
create_polyhedron
# If the SKIP_FUNCTION_MACROS tag is set to YES then doxygen's preprocessor will
# remove all references to function-like macros that are alone on a line, have
@@ -2731,15 +2781,6 @@ DOT_GRAPH_MAX_NODES = 50
MAX_DOT_GRAPH_DEPTH = 0
# Set the DOT_MULTI_TARGETS tag to YES to allow dot to generate multiple output
# files in one run (i.e. multiple -o and -T options on the command line). This
# makes dot run faster, but since only newer versions of dot (>1.8.10) support
# this, this feature is disabled by default.
# The default value is: NO.
# This tag requires that the tag HAVE_DOT is set to YES.
DOT_MULTI_TARGETS = NO
# If the GENERATE_LEGEND tag is set to YES doxygen will generate a legend page
# explaining the meaning of the various boxes and arrows in the dot generated
# graphs.
+41 -18
View File
@@ -1,33 +1,56 @@
# IfcOpenShell C++ API documentation
This folder contains the setup to build the IfcOpenShell C++ API documentation from the source code.
This directory contains the Sphinx, Doxygen, Breathe, and Exhale configuration
for the IfcOpenShell C++ API reference. During a Sphinx build, Exhale runs
Doxygen, Breathe consumes the generated XML, and Exhale creates the API pages.
## Prerequisites
- Python 3.10 or newer
- [Doxygen](https://www.doxygen.nl/)
- [Graphviz](https://graphviz.org/)
Install the Python dependencies from this directory:
```shell
python -m pip install -r requirements.txt
```
Both `doxygen` and `dot` must be available on `PATH`. For the standard Windows
install locations, this can be done for the current PowerShell session with:
```powershell
$env:Path = "C:\Program Files\doxygen\bin;C:\Program Files\Graphviz\bin;$env:Path"
```
## Generating the documentation
> Prerequisites:
>
> Make sure to have [Doxygen](https://www.doxygen.nl) and [Graphviz](https://graphviz.org) installed into your `$PATH` variable.
>
> The documentation also use the [doxygen-awesome](https://jothepro.github.io/doxygen-awesome-css) theme as a git submodule.
Build with the command (from within the `/docs/cpp-api` folder):
From this directory, run:
```shell
$ doxygen
python -m sphinx -M html . output -W --keep-going
```
To include the current git commit hash into the build documentation, use the following command:
To include the current Git commit in Doxygen's project metadata, set
`PROJECT_NUMBER` before building. For example, in PowerShell:
```powershell
$env:PROJECT_NUMBER = git rev-parse --short HEAD
python -m sphinx -M html . output -W --keep-going
```
Or in a POSIX shell:
```shell
$ PROJECT_NUMBER=$(git rev-parse --short HEAD) doxygen
PROJECT_NUMBER=$(git rev-parse --short HEAD) python -m sphinx -M html . output -W --keep-going
```
This will extract the current commit hash in short version and sets the propper ENV variable used by doxygen.
Alternatively, configure the main CMake project with
`-DBUILD_DOCUMENTATION=ON` and build the `cpp_api_docs` target.
The generation of the documentation might take a while depending on your systems hardware, as it is configured to generate the Class graphs using .
The generated documentation is written to `output/html/index.html`. The
generated Doxygen XML and Exhale sources are kept under `output/` as build
artifacts.
The resulting documentation is located unter `/cpp-api/output/html` and can be directly accessed with your browser:
```shell
$ open ./output/html/index.html
```
The generated headers under `src/ifcparse/schemas` are intentionally excluded
from this documentation build.
+59
View File
@@ -0,0 +1,59 @@
# This file was generated with the assistance of an AI coding tool.
import warnings
from pathlib import Path
from shutil import rmtree
from sphinx.deprecation import RemovedInSphinx90Warning
warnings.filterwarnings("ignore", category=RemovedInSphinx90Warning, module=r"exhale\.configs")
generated_directories = (
Path(__file__).parent / "output" / "api",
Path(__file__).parent / "output" / "doxygen",
)
for generated_directory in generated_directories:
if generated_directory.is_dir():
rmtree(generated_directory)
project = "IfcOpenShell"
copyright = "2020, IfcOpenShell"
extensions = [
"breathe",
"exhale",
]
primary_domain = "cpp"
highlight_language = "cpp"
html_theme = "alabaster"
breathe_projects = {
"IfcOpenShell": "./output/doxygen/xml",
}
breathe_default_project = "IfcOpenShell"
exhale_args = {
"containmentFolder": "./output/api",
"rootFileName": "library_root.rst",
"rootFileTitle": "IfcOpenShell C++ API",
"doxygenStripFromPath": "../..",
"createTreeView": False,
"exhaleExecutesDoxygen": True,
"exhaleUseDoxyfile": True,
}
cpp_id_attributes = [
"IFC_PARSE_API",
"IFC_SCHEMA_API",
"IFC_GEOM_API",
"IFC_GEOMLIBRARY_API",
"IFC_GEOMSERIALIZATION_API",
"SERIALIZERS_API",
]
exclude_patterns = [
"output/doctrees",
"output/doxygen",
"output/html",
]
+9
View File
@@ -0,0 +1,9 @@
.. This file was generated with the assistance of an AI coding tool.
IfcOpenShell C++ API
====================
.. toctree::
:maxdepth: 2
output/api/library_root
+5
View File
@@ -0,0 +1,5 @@
# This file was generated with the assistance of an AI coding tool.
Sphinx==8.1.3
breathe==4.36.0
exhale==0.3.7
@@ -0,0 +1,168 @@
# Design Spec: Space Regeneration with Sloped Roofs, Walls, and Slabs
## Goal
Extend `bonsai.core.spatial.generate_space` so it produces correct `IfcSpace`
geometry for non-rectilinear envelopes:
- sloped roofs,
- sloped slabs,
- sloped walls,
- curved walls.
The existing footprint-based `IfcExtrudedAreaSolid` path is preserved for
ordinary vertical extrusions. A new hybrid path keeps the representation
parametric when possible and falls back to an `IfcFacetedBrep` only when the
boundary cannot be expressed as a clipped extrusion.
## Architecture
```
┌─────────────────────────────────────────┐
│ Existing footprint generation │
│ (get_space_polygon_from_*_objects) │
└──────────────┬────────────────────────────┘
v
┌─────────────────────────────────────────┐
│ Detect extrudability and bounding planes │
│ (pure-Python util, Blender-independent) │
└──────────────┬────────────────────────────┘
v
┌──────┴──────┐
v v
┌───────────────────┐ ┌───────────────────┐
│ Extrusion + clips │ │ B-rep fallback │
│ IfcExtrudedAreaSolid│ │ IfcFacetedBrep │
│ + IfcBooleanClippingResult│ │ (or IfcPolygonalFaceSet) │
└───────────────────┘ └───────────────────┘
```
## Prior art
- **CBIP** (Lilis et al.): constructive solid geometry approach that builds
space volumes as half-space intersections of bounding planes — the basis for
the parametric clipping path.
- **Fichter et al. 2021**: ray-tracing method for automatic boundary
generation; motivates the use of `geom.tree.select_ray` for top/bottom plane
detection.
- **Lilis et al. 2021**: semi-automatic boundary recognition; informs the
fallback to existing `boundary.auto_generate_boundaries` machinery.
- **Ying & Lee 2019**: faceting of curved walls; motivates the B-rep fallback
for curved-in-plan walls that cannot be represented as vertical extruded
profiles.
## Detection criteria
Use the parametric `IfcExtrudedAreaSolid` + `IfcBooleanClippingResult` path
when **all** are true:
1. Side walls are vertical extrusions (face normal is horizontal).
Curved-in-plan walls are allowed; their footprint is polygonized or
reconstructed as a curved profile.
2. The roof/top boundary is piecewise-planar.
3. The bottom slab/floor boundary is piecewise-planar.
4. The footprint is a single closed outer region, possibly with inner closed
regions for holes.
5. The resulting half-space intersection is non-empty and produces a single
solid.
Otherwise use the B-rep fallback.
## Parametric extrusion + clipping algorithm
1. **Build the profile**
- Outer ring from the footprint polygon → `IfcArbitraryClosedProfileDef`.
- Inner rings (holes, e.g., around columns) →
`IfcArbitraryProfileDefWithVoids`.
2. **Extrude**
- Create `IfcExtrudedAreaSolid` along local +Z, with a height large enough
to cover all bounding planes.
3. **Find top planes**
- Cast vertical rays upward from the footprint centroid and sample points
using `ifcopenshell.geom.tree.select_ray`.
- Check each hit face for planarity with
`ifcopenshell.util.shape.dissolve_faces(..., merge_coplanar=True)`.
- Group coplanar hits into distinct planes.
4. **Find bottom planes**
- Same as top, but downward.
5. **Clip**
- For each top plane: create `IfcHalfSpaceSolid` with normal pointing
upward (removed side), apply via `ifcopenshell.api.geometry.clip_solid`.
- For each bottom plane: create `IfcHalfSpaceSolid` with normal pointing
downward, apply via `clip_solid`.
6. **Output**
- `IfcExtrudedAreaSolid` wrapped in a chain of `IfcBooleanClippingResult`.
## B-rep fallback algorithm
For non-extrudable cases (sloped walls, curved roofs, etc.):
1. **Seed space**
- Create a temporary rough mesh (e.g., extruded footprint bounding box) as
a placeholder.
2. **Extract boundary faces**
- Run `ifcopenshell.util.boundary.auto_generate_boundaries` against the
seed to identify the faces of bounding elements that touch the space.
- Convert each boundary polygon from face-local back to 3D world
coordinates.
3. **Build closed shell**
- Collect the 3D boundary faces.
- Add narrow gap-closing faces if `auto_generate_boundaries` leaves
unmatched edges.
- Triangulate and produce `IfcClosedShell``IfcFacetedBrep` (or
`IfcPolygonalFaceSet` for IFC4+).
4. **Clean up**
- Assign the B-rep to the `IfcSpace` and remove the temporary seed
geometry.
## Files to touch
- `src/ifcopenshell-python/ifcopenshell/util/space.py`
- New: `detect_space_volume_strategy`
- New: `build_extruded_clipped_space`
- New: `build_brep_space`
- New helpers for ray-cast plane detection and face planarity checks.
- `src/bonsai/bonsai/tool/spatial.py`
- Extend `set_space_representation_from_polygon` to dispatch to the new
strategy.
- Extend footprint/profile creation to support inner rings for holes.
- `src/bonsai/bonsai/core/spatial.py`
- `generate_space` calls the dispatcher.
## Testing
- Add unit tests in `src/ifcopenshell-python/test/util/test_space.py` for pure
geometry helpers:
- simple shed roof,
- gable roof,
- sloped slab,
- L-shaped footprint with sloped roof,
- curved wall.
- Add Bonsai tests in `src/bonsai/test/tool/test_spatial.py` for end-to-end
`generate_space` with non-rectilinear geometry.
## Error handling
- If detection fails or half-space clipping produces an invalid result, fall
back to the B-rep path.
- If the B-rep path also fails, return an error string and leave the existing
space representation unchanged.
## Known limitations and non-goals
- **Curved (single/double-curvature) roofs and domes** are handled only via the
B-rep fallback; they are not expressible as `IfcExtrudedAreaSolid` +
`IfcBooleanClippingResult` in this design.
- The B-rep fallback produces **non-parametric** geometry: the resulting
`IfcFacetedBrep`/`IfcPolygonalFaceSet` cannot be re-edited parametrically by
the user afterwards. This is an accepted trade-off; the parametric path is
preferred whenever detection succeeds.
- The B-rep fallback depends on `boundary.auto_generate_boundaries`, so it
inherits its assumptions: bounding elements must be related to the space and
the seed volume must intersect them. Gap-closing faces may produce
non-manifold output for degenerate envelopes; we accept this for
non-extrudable edge cases.
- The parametric path requires a single closed outer footprint with optional
inner holes. Multi-region disconnected footprints are not supported and fall
back to B-rep.
+121 -90
View File
@@ -1,5 +1,8 @@
#!/usr/bin/python
# /// script
# dependencies = [
# "typing_extensions",
# ]
# ///
###############################################################################
# #
@@ -32,35 +35,45 @@ Example usage:
Available arguments:
``-py-313`` - build for specific Python version
(building for all supported Python version by default).
``-occt-xxx`` - use a specific OCCT version (e.g. ``-occt-7.8.1``) instead of the default
``-wasm`` - compile for wasm
``-without-xxx`` - do not build dependency ``xxx`` (e.g. ``--without-swig``)
``-mac-cross-compile-intel`` - cross compile for Intel Mac on Apple Silicon host
``-shared`` - build shared libraries. By default will build static.
``-ifcopenshell-shared`` - build only IfcOpenShell's own libraries as shared
(dependencies stay static). Redundant if ``-shared`` is also passed.
``-diskcleanup`` - clean up build directories after finishing building dependencies
``-build-examples`` - build IfcOpenShell examples
``-lto`` - enable link-time optimization (adds ``-flto`` to compiler flags)
``-v`` - enable verbose logs
Used environment variables:
Boolean-like env variables accept the following values (case-insensitive):
`opt-in` - `1`, `on`, `true`, `yes`; ``opt-out`` - `0`, `off`, `false`, `no`.
- ``CXXFLAGS``, ``CPPFLAGS``, ``CFLAGS``, ``LDFLAGS``
- ``BUILD_DIR`` - build directory. By default will use "build" folder in IfcOpenShell repository.
- ``DEPS_DIR`` - dependencies directory. By default will create automatic folder in build directory.
- ``BUILD_CFG`` - build configuration, 'RelWithDebInfo' by default.
- ``USE_CURRENT_PYTHON_VERSION`` - use current python config instead of compile from source
`off` by default.
- ``IFCOS_NUM_BUILD_PROCS`` - number of concurrent processes defaults to available cores + 1
- ``NO_CLEAN`` - do not clean `ifcopenshell` build directories but continue working on current build
(installed dependencies are never cleared).
By default option is disabled, to enable pass any value from `1`, `on`, `true`.
- ``IFCOS_SCHEMAS`` - schemas to be built; defaults to cmake default (8 schemas), to be supplied as `2x3;4;4x3_add2`
- ``USE_OCCT`` - whether to use official Open CASCADE instead of Community Edition
(`true` by default, any other value is considered `false`)
`on` by default
- ``WASM_PYTHON_PATH`` - path to WASM Python installation,
used to deduce `PYVERSION` (e.g. '3.13.2'), `PYTHONINCLUDE`,
`SIDE_MODULE_CFLAGS`, `SIDE_MODULE_LDFLAGS`.
Allows to build wasm without pyodide build environment, which can be useful for debugging build issues.
Example value: 'pyodide/cpython/installs/python-3.13.2'
- ``ADD_COMMIT_SHA`` - if defined with any non-empty value then
- ``ADD_COMMIT_SHA`` - `off` by default. If enabled
`ADD_COMMIT_SHA` and `VERSION_OVERRIDE` will be set to `ON` while configuring IfcOpenShell
- ``BUILD_BONSAIVIEWER`` - enable building BonsaiViewer, value of the env variable has to be truthy.
- ``BUILD_BONSAIVIEWER`` - enable building BonsaiViewer, `off` by default.
- ``IFCOS_BUILD_PYTHON_WRAPPER`` - enable building the Python wrapper, `on` by default.
# This script builds IfcOpenShell and its dependencies #
# #
@@ -111,33 +124,44 @@ import multiprocessing
import os
import platform
import shutil
# @todo temporary for expired mpfr.org certificate on 2023-04-08
import ssl
import subprocess as sp
import sys
import sysconfig
import tarfile
import threading
from datetime import datetime
ssl._create_default_https_context = ssl._create_unverified_context # ty:ignore[invalid-assignment]
import time
from collections.abc import Generator, Sequence
from datetime import datetime
from pathlib import Path
from typing import Literal, Union
from typing import Literal
from urllib.request import urlretrieve
from typing_extensions import assert_never
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
logger.addHandler(ch)
def is_on_off(value: str | None, *, default: bool) -> bool:
if value is None:
return default
lowered = value.lower()
if lowered in {"1", "on", "true", "yes"}:
return True
if lowered in {"0", "off", "false", "no"}:
return False
return default
PROJECT_NAME = "IfcOpenShell"
USE_CURRENT_PYTHON_VERSION = os.getenv("USE_CURRENT_PYTHON_VERSION")
ADD_COMMIT_SHA = os.getenv("ADD_COMMIT_SHA")
BUILD_BONSAIVIEWER = os.getenv("BUILD_BONSAIVIEWER", "").lower() in {"1", "on", "true", "yes"}
USE_CURRENT_PYTHON_VERSION = is_on_off(os.getenv("USE_CURRENT_PYTHON_VERSION"), default=False)
ADD_COMMIT_SHA = is_on_off(os.getenv("ADD_COMMIT_SHA"), default=False)
IFCOS_BUILD_PYTHON_WRAPPER = is_on_off(os.getenv("IFCOS_BUILD_PYTHON_WRAPPER"), default=True)
BUILD_BONSAIVIEWER = is_on_off(os.getenv("BUILD_BONSAIVIEWER"), default=False)
USE_OCCT = is_on_off(os.getenv("USE_OCCT"), default=True)
PYTHON_VERSIONS = ["3.10.3", "3.11.8", "3.12.1", "3.13.6", "3.14.0"]
JSON_VERSION = "3.11.3"
@@ -201,6 +225,11 @@ def cecho(message, color=NO_COLOR):
# Flags.
BUILD_EXAMPLES = "build-examples" in flags
DISK_CLEANUP = "diskcleanup" in flags
LTO = "lto" in flags
VERBOSE = "v" in flags
APPLE = platform.system() == "Darwin"
MAC_CROSS_COMPILE_INTEL = "mac-cross-compile-intel" in flags
assert platform.system() == "Darwin" or not MAC_CROSS_COMPILE_INTEL
@@ -218,6 +247,7 @@ if WASM:
cecho("WARNING. Couldn't find 'PYODIDE_ROOT' in environment variables.", YELLOW)
cecho("Assuming building wasm outside pyodide build environment and resetting necessary variables.", YELLOW)
os.environ["SIDE_MODULE_CFLAGS"] = get_pyodide_config_var("cflags")
os.environ["SIDE_MODULE_CXXFLAGS"] = get_pyodide_config_var("cxxflags")
os.environ["SIDE_MODULE_LDFLAGS"] = get_pyodide_config_var("ldflags")
# Override cmake toolchain for all `emcmake` calls,
# needed for shared libraries (resulting .so wrapper)
@@ -225,13 +255,14 @@ if WASM:
os.environ["CMAKE_TOOLCHAIN_FILE"] = get_pyodide_config_var("cmake_toolchain_file")
required_vars = (
"SIDE_MODULE_CFLAGS",
"SIDE_MODULE_CXXFLAGS",
"SIDE_MODULE_LDFLAGS",
"CMAKE_TOOLCHAIN_FILE",
)
missing_vars = [v for v in required_vars if v not in os.environ]
assert not missing_vars, f"Some variables required for WASM compilation are missing: {', '.join(missing_vars)}"
def get_pyodide_build_version() -> "tuple[int, ...]":
def get_pyodide_build_version() -> tuple[int, ...]:
pyodide_build_suffix = "pyodide-build version:"
output = sp.check_output(["pyodide", "--version"], encoding="utf-8").strip()
assert pyodide_build_suffix in output, output
@@ -249,10 +280,6 @@ if WASM:
# 0.31 is required for SIDE_MODULE_CXXFLAGS to be provided.
assert get_pyodide_build_version() >= (0, 31)
# Set defaults for missing empty environment variables
USE_OCCT = os.environ.get("USE_OCCT", "true").lower() == "true"
TOOLSET = None
if platform.system() == "Darwin":
# C++11 features used in OCCT 7+ need a more recent stdlib
@@ -320,13 +347,12 @@ if BUILD_CFG == "MinSizeRel":
cecho(" WARNING: MinSizeRel build can suffer from a significant performance loss.", RED)
cecho(f"* IFCOS_NUM_BUILD_PROCS = {IFCOS_NUM_BUILD_PROCS}", MAGENTA)
cecho(""" - How many compiler processes may be run in parallel.
""")
cecho(f" * IFCOS_SCHEMAS = '{os.environ.get('IFCOS_SCHEMAS')}'", MAGENTA)
cecho(""" - How many compiler processes may be run in parallel.""")
cecho(f"* IFCOS_SCHEMAS = '{os.environ.get('IFCOS_SCHEMAS')}'", MAGENTA)
cecho(""" - IFC Schemas to compile. If not provided, fallback to default provided in cmake.
""")
dependency_tree: "dict[str, tuple[str, ...]]" = {
dependency_tree: dict[str, tuple[str, ...]] = {
"IfcParse": ("boost", "libxml2", "rocksdb"),
"IfcGeom": ("IfcParse", "occ", "manifold", "json", "cgal", "eigen", "OpenCOLLADA"),
"IfcConvert": ("IfcGeom",),
@@ -351,7 +377,7 @@ dependency_tree: "dict[str, tuple[str, ...]]" = {
}
def gather_dependencies(dep: str) -> "Generator[str]":
def gather_dependencies(dep: str) -> Generator[str]:
yield dep
for d in dependency_tree[dep]:
if f"without-{d.lower()}" not in flags:
@@ -359,7 +385,7 @@ def gather_dependencies(dep: str) -> "Generator[str]":
yield x
if "v" in flags:
if VERBOSE:
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
ch.setFormatter(formatter)
@@ -381,6 +407,9 @@ else:
OFF_ON = ["OFF", "ON"]
BUILD_STATIC = "shared" not in flags
"""Whether dependencies are built static."""
IFCOPENSHELL_STATIC = BUILD_STATIC and "ifcopenshell-shared" not in flags
"""Whether IfcOpenShell's own libraries are built static."""
ENABLE_FLAG = "--enable-static" if BUILD_STATIC else "--enable-shared"
DISABLE_FLAG = "--disable-shared" if BUILD_STATIC else "--disable-static"
LINK_TYPE = "static" if BUILD_STATIC else "shared"
@@ -410,13 +439,12 @@ if BUILD_BONSAIVIEWER:
# ifcopenshell.document.rdb.dylib but the CREATE_BUNDLE install rule does
# not actually drop the dylib next to the wrapper in site-packages — see
# the commit message that introduced this gate.
if os.environ.get("IFCOS_BUILD_PYTHON_WRAPPER", "on").lower() in {"0", "off", "false", "no"}:
if not IFCOS_BUILD_PYTHON_WRAPPER:
targets.discard("IfcOpenShell-Python")
if WASM:
SKIP_TARGETS_FOR_WASM = {
"rocksdb",
"opencollada",
"swig",
"pcre",
"IfcGeom",
"IfcConvert",
@@ -436,14 +464,10 @@ print("Building:", *sorted(targets, key=lambda t: len(list(gather_dependencies(t
yacc = "yacc" # Used during swig building process, installed with `bison` on Debian / `byacc` on Red Hat.
bison = "bison"
missing_commands: "list[str]" = []
missing_commands: list[str] = []
required_commands = [git, bunzip2, tar, cc, cplusplus, autoconf, automake, make, "patch", "cmake", yacc, xz, bison]
if "wasm" in flags:
# Skip swig build for WASM.
required_commands.append("swig")
if WASM:
required_commands.append("pyodide")
required_commands.remove(yacc)
required_commands.remove(bison)
if platform.system() == "Linux" and "BonsaiViewer" in targets:
required_commands.append("patchelf")
@@ -480,14 +504,14 @@ except:
pass
def restore_env(var_name: str, old_value: Union[str, None]) -> None:
def restore_env(var_name: str, old_value: str | None) -> None:
if old_value is None:
del os.environ[var_name]
else:
os.environ[var_name] = old_value
def run(cmds: "Sequence[str]", cwd: "Union[str, None]" = None, can_fail: bool = False) -> str:
def run(cmds: Sequence[str], cwd: str | None = None, can_fail: bool = False) -> str:
"""
Wraps `subprocess.Popen.communicate()` and logs the command being executed,
sets up logging `stderr` to `LOG_FILE` (in append mode) and returns stdout
@@ -497,7 +521,7 @@ def run(cmds: "Sequence[str]", cwd: "Union[str, None]" = None, can_fail: bool =
def timestamp() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S,%f")[:-3] # same format as logging
def stream_reader(pipe, collector: "list[str]", log_file) -> None:
def stream_reader(pipe, collector: list[str], log_file) -> None:
for line in iter(pipe.readline, ""):
log_file.write(f"{timestamp()} {line}")
log_file.flush()
@@ -550,7 +574,7 @@ BOOST_LOCATION = f"https://github.com/boostorg/boost/releases/download/boost-{BO
# Helper functions
def run_autoconf(dependency_name: str, configure_args: "list[str]", cwd: str) -> None:
def run_autoconf(dependency_name: str, configure_args: list[str], cwd: str) -> None:
configure_path = os.path.realpath(os.path.join(cwd, "..", "configure"))
if not os.path.exists(configure_path):
run(
@@ -560,7 +584,7 @@ def run_autoconf(dependency_name: str, configure_args: "list[str]", cwd: str) ->
prefix = os.path.realpath(f"{DEPS_DIR}/install/{dependency_name}")
wasm = []
if "wasm" in flags:
if WASM:
wasm.append("emconfigure")
run(
@@ -568,7 +592,7 @@ def run_autoconf(dependency_name: str, configure_args: "list[str]", cwd: str) ->
*wasm,
"/bin/sh",
"../configure",
*(["--host=wasm32"] if "wasm" in flags and not any(s.startswith("--host") for s in configure_args) else []),
*(["--host=wasm32"] if WASM and not any(s.startswith("--host") for s in configure_args) else []),
*configure_args,
f"--prefix={prefix}",
],
@@ -576,18 +600,20 @@ def run_autoconf(dependency_name: str, configure_args: "list[str]", cwd: str) ->
)
def run_cmake(arg1, cmake_args: "list[str]", cmake_dir: Union[str, None] = None, cwd: Union[str, None] = None):
def run_cmake(
name, cmake_args: list[str], cmake_dir: str | None = None, cwd: str | None = None, native: bool = False
) -> None:
if cmake_dir is None:
P = ".."
else:
P = cmake_dir
wasm = []
if "wasm" in flags:
if WASM and not native:
wasm.append("emcmake")
cmake_flags: list[str] = []
if not WASM or not WASM_CMAKE_IS_USING_INIT_VARS:
if not native and (not WASM or not WASM_CMAKE_IS_USING_INIT_VARS):
# For WASM we provide flags using just environment variables.
# If we provide them using cmake vars, it will override emscripten toolchain flags.
# Unsure if we need this in general even for non-WASM builds.
@@ -603,6 +629,10 @@ def run_cmake(arg1, cmake_args: "list[str]", cmake_dir: Union[str, None] = None,
f"-DBUILD_SHARED_LIBS={OFF_ON[not BUILD_STATIC]}",
)
if WASM and native:
# Override emscripten cmake toolchain coming from environment variable.
cmake_flags.append("-DCMAKE_TOOLCHAIN_FILE=")
run(
[
*wasm,
@@ -611,13 +641,13 @@ def run_cmake(arg1, cmake_args: "list[str]", cmake_dir: Union[str, None] = None,
*cmake_flags,
*cmake_args,
f"-DCMAKE_BUILD_TYPE={BUILD_CFG}",
f"-DCMAKE_SHARED_LINKER_FLAGS={os.environ['LDFLAGS']}",
*([] if native else [f"-DCMAKE_SHARED_LINKER_FLAGS={os.environ['LDFLAGS']}"]),
],
cwd=cwd,
)
def git_clone_or_pull_repository(clone_url: str, target_dir: str, revision: Union[str, None] = None) -> None:
def git_clone_or_pull_repository(clone_url: str, target_dir: str, revision: str | None = None) -> None:
"""Lazily clones the `git` repository denoted by `clone_url` into
the `target_dir` or pulls latest changes if the `target_dir` exists (naively assumes
that a working clone exists there) and optionally checks out a revision
@@ -647,19 +677,19 @@ def build_dependency(
"autoconf",
"bjam",
],
build_tool_args: "list[str]",
build_tool_args: list[str],
download_url: str,
download_name: str,
*,
download_tool: Literal["py", "git"] = download_tool_default,
revision: "Union[str, None]" = None,
revision: str | None = None,
patch: list[str] | None = None,
shell=None,
pre_compile_subs: "Sequence[tuple[str, str, str]]" = (),
additional_files: "Union[dict[str, str], None]" = None,
pre_compile_subs: Sequence[tuple[str, str, str]] = (),
additional_files: dict[str, str] | None = None,
no_append_name=False,
cmake_dir=None,
**kwargs,
cmake_native: bool = False,
) -> None:
"""Handles building of dependencies with different tools (which are
distinguished with the `mode` argument. `build_tool_args` is expected to be
@@ -668,7 +698,8 @@ def build_dependency(
:param pre_compile_subs: A sequence of ``(fn, before, after)``
:param additional_files: Mapping path->url.
:param kwargs: Additional ``mode`` related kwargs.
:param cmake_native: For ``mode="cmake"``, force a native (host) build
even when building for WASM. Needed for build-time tools like swig.
"""
check_dir = os.path.join(DEPS_DIR, "install", name)
if os.path.exists(check_dir):
@@ -704,7 +735,7 @@ def build_dependency(
logger.info(f"\rChecking {name}... ")
git_clone_or_pull_repository(download_url, target_dir=os.path.join(build_dir, download_name), revision=revision)
else:
raise ValueError(f"download tool '{download_tool}' is not supported")
assert_never(download_tool)
download_dir = os.path.join(build_dir, download_name)
if os.path.isdir(download_dir):
@@ -765,9 +796,9 @@ def build_dependency(
if mode == "autoconf":
run_autoconf(name, build_tool_args, cwd=extract_build_dir)
elif mode == "cmake":
run_cmake(name, build_tool_args, cwd=extract_build_dir)
run_cmake(name, build_tool_args, cwd=extract_build_dir, native=cmake_native)
else:
raise ValueError()
assert_never(mode)
for fn, before, after in pre_compile_subs:
with open(os.path.join(extract_dir, fn), "r") as f:
s = f.read()
@@ -783,18 +814,18 @@ def build_dependency(
logger.info(f"\rConfiguring {name}...")
run([bash, "./bootstrap.sh"], cwd=extract_dir)
logger.info(f"\rBuilding {name}... ")
run(["./b2", f"-j{IFCOS_NUM_BUILD_PROCS}"] + build_tool_args, cwd=extract_dir, can_fail="wasm" in flags)
run(["./b2", f"-j{IFCOS_NUM_BUILD_PROCS}"] + build_tool_args, cwd=extract_dir, can_fail=WASM)
logger.info(f"\rInstalling {name}... ")
shutil.copytree(
os.path.join(extract_dir, "boost"), os.path.join(DEPS_DIR, "install", f"boost-{BOOST_VERSION}", "boost")
)
logger.info(f"\rInstalled {name} \n")
if "diskcleanup" in flags:
if DISK_CLEANUP:
shutil.rmtree(build_dir, ignore_errors=True)
def get_qt6_aqt_config() -> "tuple[str, str, str]":
def get_qt6_aqt_config() -> tuple[str, str, str]:
if platform.system() != "Linux":
raise ValueError("Automatic Qt6 installation with aqtinstall is only configured for Linux builds.")
@@ -896,7 +927,7 @@ ADDITIONAL_ARGS_STR = " ".join(ADDITIONAL_ARGS)
CXXFLAGS_MINIMAL = f"{CXXFLAGS} {PIC} {ADDITIONAL_ARGS_STR}"
CFLAGS_MINIMAL = f"{CFLAGS} {PIC} {ADDITIONAL_ARGS_STR}"
if "wasm" in flags:
if WASM:
# WASM `SIDE_MODULE_` are absorbed by `emcmake` automatically.
CXXFLAGS = CXXFLAGS_MINIMAL
CFLAGS = CFLAGS_MINIMAL
@@ -917,7 +948,7 @@ else:
CFLAGS = CFLAGS_MINIMAL
LDFLAGS = f"{LDFLAGS} {ADDITIONAL_ARGS_STR}"
if "lto" in flags:
if LTO:
for f in compiler_flags:
locals()[f] += f" -flto={IFCOS_NUM_BUILD_PROCS}"
@@ -994,23 +1025,24 @@ if "swig" in targets:
download_name="swig",
download_tool=download_tool_git,
revision=f"v{SWIG_VERSION}",
cmake_native=WASM,
)
if USE_OCCT and "occ" in targets:
occt_args: "list[str]" = []
patches: "list[str]" = []
occt_args: list[str] = []
patches: list[str] = []
if OCCT_VERSION < "7.4":
patches.append("./patches/occt/enable-exception-handling.patch")
# Skip ExpToCasExe as we don't need it and it requires additional dependencies.
# Before 7.7.2 ExpToCasExe is part of DataExchange, DETools doesn't exist yet.
# Since we do need DataExchange (used for IgesSerializer), we use a patch to skip only ExpToCasExe.
# Since we do need DataExchange (used for iges_serializer), we use a patch to skip only ExpToCasExe.
if "7.7.2" > OCCT_VERSION >= "7.7":
patches.append("./patches/occt/no_ExpToCasExe.patch")
elif OCCT_VERSION >= "7.7.2":
occt_args.append("-DBUILD_MODULE_DETools=OFF")
if "wasm" in flags:
if WASM:
patches.append("./patches/occt/no_em_js.patch")
build_dependency(
@@ -1094,7 +1126,7 @@ if "libxml2" in targets:
"--without-iconv",
"--without-lzma",
]
if "wasm" in flags:
if WASM:
build_tool_args.append("--without-threads")
build_dependency(
f"libxml2-{LIBXML2_VERSION}",
@@ -1116,7 +1148,7 @@ if "OpenCOLLADA" in targets:
# whether shared libs were actually built. We make it follow `USE_SHARED` instead.
patches.append("./patches/opencollada/config_select_libs_by_use_shared.patch")
if "wasm" in flags:
if WASM:
# This is necessary for the WASM build, because recent versions of
# clang don't have the tr1:: namespace anymore. However, it breaks
# some versions of gcc (9.4.0 at least) due to specializing std::hash
@@ -1146,7 +1178,7 @@ if "OpenCOLLADA" in targets:
revision=OPENCOLLADA_VERSION,
)
if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flags:
if "python" in targets and not USE_CURRENT_PYTHON_VERSION and not WASM:
# Python should not be built with -fvisibility=hidden, from experience that introduces segfaults
OLD_CPP_FLAGS = os.environ["CPPFLAGS"]
OLD_CXX_FLAGS = os.environ["CXXFLAGS"]
@@ -1157,7 +1189,7 @@ if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flag
# On OSX a dynamic python library is built or it would not be compatible
# with the system python because of some threading initialization
PYTHON_CONFIGURE_ARGS: "list[str]" = []
PYTHON_CONFIGURE_ARGS: list[str] = []
original_path = ""
if platform.system() == "Darwin":
PYTHON_CONFIGURE_ARGS = ["--enable-shared"]
@@ -1207,7 +1239,7 @@ if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flag
if "boost" in targets:
str_concat = lambda prefix: lambda postfix: "" if postfix.strip() == "" else "=".join((prefix, postfix.strip()))
toolset = []
if "wasm" in flags:
if WASM:
toolset.append("toolset=emscripten")
build_dependency(
f"boost-{BOOST_VERSION}",
@@ -1235,7 +1267,7 @@ if "boost" in targets:
# patch="./patches/boost/boostorg_regex_62.patch",
download_name=f"boost-{BOOST_VERSION}-b2-nodocs.tar.gz",
)
if "wasm" in flags:
if WASM:
# only supported on nix for now
run(
("find", ".", "-name", "*.bc", "-exec", "bash", "-c", "emar q ${1%.bc}.a $1", "bash", "{}", ";"),
@@ -1243,8 +1275,8 @@ if "boost" in targets:
)
if "cgal" in targets:
gmp_args: "list[str]" = []
mpfr_args: "list[str]" = []
gmp_args: list[str] = []
mpfr_args: list[str] = []
OLD_HOST_CC = None
if WASM:
@@ -1277,9 +1309,7 @@ if "cgal" in targets:
name=f"gmp-{GMP_VERSION}",
mode="autoconf",
build_tool_args=[ENABLE_FLAG, DISABLE_FLAG, "--with-pic", *gmp_args],
pre_compile_subs=(
[("build/config.h", "HAVE_OBSTACK_VPRINTF 1", "HAVE_OBSTACK_VPRINTF 0")] if "wasm" in flags else []
),
pre_compile_subs=([("build/config.h", "HAVE_OBSTACK_VPRINTF 1", "HAVE_OBSTACK_VPRINTF 0")] if WASM else []),
patch=gmp_patches,
# Sometimes ftp.gnu.org is very slow, use ftpmirror.gnu.org as a workaround.
download_url="https://ftpmirror.gnu.org/gnu/gmp/",
@@ -1411,24 +1441,24 @@ os.makedirs(ifcos_build_dir, exist_ok=True)
cmake_args = [
"-DUSE_MMAP=OFF",
"-DBUILD_EXAMPLES=OFF",
"-DBUILD_SHARED_LIBS=" + OFF_ON[not BUILD_STATIC],
f"-DBUILD_EXAMPLES={OFF_ON[BUILD_EXAMPLES]}",
"-DBUILD_SHARED_LIBS=" + OFF_ON[not IFCOPENSHELL_STATIC],
"-DGLTF_SUPPORT=ON",
"-DBoost_NO_BOOST_CMAKE=On",
"-DCREATE_BUNDLE=On",
"-DADD_COMMIT_SHA=" + ("On" if ADD_COMMIT_SHA else "Off"),
"-DVERSION_OVERRIDE=" + ("On" if ADD_COMMIT_SHA else "Off"),
"-DADD_COMMIT_SHA=" + OFF_ON[ADD_COMMIT_SHA],
"-DVERSION_OVERRIDE=" + OFF_ON[ADD_COMMIT_SHA],
*MAC_CROSS_COMPILE_INTEL_ARGS,
]
"""Default CMake args to use for all CMake configs."""
cmake_args_prefix_path: "list[str]" = [
cmake_args_prefix_path: list[str] = [
f"{DEPS_DIR}/install/boost-{BOOST_VERSION}",
f"{DEPS_DIR}/install/eigen-install-{EIGEN_VERSION}",
f"{DEPS_DIR}/install/json-{JSON_VERSION}",
]
def get_cmake_args_prefix_path(additional_paths: "Sequence[str]" = ()) -> "list[str]":
def get_cmake_args_prefix_path(additional_paths: Sequence[str] = ()) -> list[str]:
args_prefix_path = cmake_args_prefix_path.copy()
args_prefix_path.extend(additional_paths)
prefix_path = ";".join(args_prefix_path)
@@ -1441,7 +1471,7 @@ def get_cmake_args_prefix_path(additional_paths: "Sequence[str]" = ()) -> "list[
return [f"-DCMAKE_PREFIX_PATH={prefix_path}"]
if "wasm" in flags:
if WASM:
# Boost is built by the build script so should not be found
# inside of the sysroot set by the emscriptem toolchain
cmake_args.append("-DWASM_BUILD=On")
@@ -1507,24 +1537,25 @@ if "rocksdb" in targets:
)
if "swig" in targets:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/swig-{SWIG_VERSION}")
# `cmake_args_prefix_path` won't work on wasm
# because `find_program` in emscripten toolchain don't use `find_root_path`.
# As a workaround we provide executable path directly on all platforms.
cmake_args.append(f"-DSWIG_EXECUTABLE={DEPS_DIR}/install/swig-{SWIG_VERSION}/bin/swig")
if os.environ.get("QT_DIR"):
cmake_args_prefix_path.append(os.environ["QT_DIR"])
cmake_args.append(f"-DQT_DIR={os.environ['QT_DIR']}")
build_bonsaiviewer = BUILD_BONSAIVIEWER or "BonsaiViewer" in targets
ifcos_build_args = [
f"-DBUILD_IFCGEOM={OFF_ON['IfcGeom' in targets]}",
f"-DBUILD_GEOMSERVER={OFF_ON['IfcGeomServer' in targets]}",
f"-DBUILD_CONVERT={OFF_ON['IfcConvert' in targets]}",
f"-DBUILD_BONSAIVIEWER={OFF_ON[build_bonsaiviewer]}",
f"-DBUILD_BONSAIVIEWER={OFF_ON['BonsaiViewer' in targets]}",
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell",
]
if not WASM and (
build_bonsaiviewer
"BonsaiViewer" in targets
or not explicit_targets
or {"IfcGeom", "IfcConvert", "IfcGeomServer", "BonsaiViewer"} & set(explicit_targets)
):
@@ -1550,10 +1581,10 @@ if "IfcOpenShell-Python" in targets:
def compile_python_wrapper(
python_version: str,
python_include: Union[str, None] = None,
python_executable: Union[str, None] = None,
python_path: Union[Path, None] = None,
) -> Union[str, None]:
python_include: str | None = None,
python_executable: str | None = None,
python_path: Path | None = None,
) -> str | None:
"""
:return: Path to module dir if ``python_executable`` was provided, otherwise ``None``.
"""
@@ -1621,7 +1652,7 @@ if "IfcOpenShell-Python" in targets:
if platform.system() != "Darwin":
if BUILD_CFG == "Release":
for so in glob.glob(os.path.join(module_dir, "*.so")):
if "wasm" in flags:
if WASM:
run(["wasm-strip", so, "-k", "dylink.0"])
elif os.path.basename(so).startswith("_ifcopenshell_wrapper"):
# TODO: This symbol name depends on the Python version?
@@ -1631,7 +1662,7 @@ if "IfcOpenShell-Python" in targets:
return module_dir
if "wasm" in flags:
if WASM:
compile_python_wrapper(
run(["pyodide", "config", "get", "python_version"]),
run(["pyodide", "config", "get", "python_include_dir"]),
+10 -2
View File
@@ -28,5 +28,13 @@ since it's pure cmake without any additional moving parts.
- clone IfcOpenShell repo next to it to `IfcOpenShell` folder
- run `python nix/build-all.py -wasm -py-313` in `IfcOpenShell`
- it will produce Python package in `IfcOpenShell/ifcopenshell`
- run `pyodide build`
- it will produce a wheel in `IfcOpenShell/dist`
- run `python pyodide/build-all-pack-wheel-local.py`, it will
- clean up previous wheels
- run `pyodide build`
- prepare standalone and modular wheels
- produce final wheels in `IfcOpenShell/dist` and `IfcOpenshell/dist-modular`
- testing:
- ensure you're in pyodide environment
- `cd IfcOpenshell/pyodide`
- `./run_pytest.py setup`
- `./run_pytest.py run`
View File
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Intended to be run after nix/build-all.py has finished the wasm build."""
import shutil
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
output = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True)
return Path(output.strip())
def run(cmd: list[str], **kwargs) -> None:
print("$", " ".join(cmd))
subprocess.check_call(cmd, **kwargs)
def main() -> None:
repo_root = get_repo_root()
shutil.rmtree(repo_root / "dist", ignore_errors=True)
shutil.rmtree(repo_root / "dist_modular", ignore_errors=True)
run(["pyodide", "build"], cwd=repo_root)
shutil.rmtree(repo_root / "ifcopenshell", ignore_errors=True)
(repo_root / "setup.py").unlink(missing_ok=True)
run(["git", "restore", "pyproject.toml"], cwd=repo_root)
wheel = next((repo_root / "dist").glob("ifcopenshell-*.whl"))
run(["uv", "run", "pyodide/order_pyodide_wheel_shared_objects.py", str(wheel)], cwd=repo_root)
run(
["uv", "run", "pyodide/split_pyodide_ifcopenshell_wheel.py", str(wheel), "dist-modular/"],
cwd=repo_root,
)
if __name__ == "__main__":
main()
+9 -7
View File
@@ -1,10 +1,8 @@
#!/usr/bin/bash
set -ex
PYODIDE_VERSION=0.29.3
PYODIDE_BUILD_VERSION=0.33.0
PYODIDE_XBUILDENV_ROOT="${HOME}/.cache/.pyodide-xbuildenv-${PYODIDE_BUILD_VERSION}"
PYODIDE_XBUILDENV="${PYODIDE_XBUILDENV_ROOT}/${PYODIDE_VERSION}"
PYODIDE_VERSION=0.29.4
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# Script is assuming that it will be possible to execute it multiple times
# therefore we're clearing venv each time and ignoring existing 'emsdk' folder.
@@ -16,12 +14,14 @@ source .venv/bin/activate
# Install pyodide cross build environment.
# Instructions: https://pyodide.org/en/stable/development/building-packages.html
uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}"
uv pip install -r "${SCRIPT_DIR}/requirements.txt"
# `uv run` is required, so xbuildenv would skip using `pip`.
uv run pyodide xbuildenv install "${PYODIDE_VERSION}"
uv run pyodide xbuildenv install-emscripten
EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk"
# Cache path includes a hash segment that varies by pyodide-build version,
# so query it instead of constructing it manually.
EMSDK_ROOT=$(uv run pyodide config get emsdk_dir)
[ -f "${EMSDK_ROOT}/emsdk_env.sh" ] && source "${EMSDK_ROOT}/emsdk_env.sh"
[ -f "${EMSDK_ROOT}/../../emsdk_env.sh" ] && source "${EMSDK_ROOT}/../../emsdk_env.sh"
which emcc
@@ -29,8 +29,10 @@ emcc --version
mkdir -p packages/ifcopenshell
VERSION=`cat IfcOpenShell/VERSION`
# Normalize to the canonical PEP 440 form (e.g. 0.9.0alpha0 -> 0.9.0a0).
VERSION=`python3 -c "from packaging.version import Version; print(Version('$VERSION'))"`
cp IfcOpenShell/pyodide/meta.yaml packages/ifcopenshell
sed -i s/0.8.0/$VERSION/g packages/ifcopenshell/meta.yaml
sed -i s/9.9.9/$VERSION/g packages/ifcopenshell/meta.yaml
# Use custom build ifcopenshell directory in build-all to make caching simpler
# Otherwise pyodide build path typically includes package version, so cached cmake configs might break.
+2 -1
View File
@@ -1,6 +1,7 @@
package:
name: ifcopenshell
version: 0.8.0
# Placeholder, replaced by build_pyodide.sh with the actual version from VERSION file.
version: 9.9.9
source:
# meta.yaml is placed as `packages/ifcopenshell/meta.yaml`.
+17 -5
View File
@@ -1,6 +1,18 @@
#!/usr/bin/env python3
# /// script
# ///
# This file was generated with the assistance of an AI coding tool.
"""Order Pyodide wheel shared objects so wasm side modules load safely."""
"""Order Pyodide wheel shared objects so wasm side modules load safely.
Pyodide's package loader loads a wheel's bundled ``.so`` files in the order
they appear in the wheel's zip.
If a ``.so`` that depends on symbols from another ``.so`` is loaded first,
loading fails with errors like
- "Failed to load dynamic library"
- "Dynamic linking error: cannot resolve symbol"
This is a known issue upstream - https://github.com/pyodide/pyodide/issues/6020.
"""
from __future__ import annotations
@@ -22,10 +34,10 @@ SCHEMA_ORDER = {
}
MAIN_SHARED_OBJECT_RE = re.compile(r"^_ifcopenshell_wrapper(?:\.|$)")
SCHEMA_PLUGIN_RE = re.compile(r"^ifcopenshell\.parse\.schema\.([^.]+)\.so$")
MAPPING_PLUGIN_RE = re.compile(r"^ifcopenshell\.geometry\.mapping\.([^.]+)\.so$")
DOCUMENT_PLUGIN_RE = re.compile(r"^ifcopenshell\.document\.[^.]+\.([^.]+)\.so$")
GEOMETRY_SERIALIZATION_PLUGIN_RE = re.compile(r"^ifcopenshell\.geometry\.serialization\.([^.]+)\.so$")
SCHEMA_PLUGIN_RE = re.compile(r"^ifcopenshell_parse_schema_(.+)\.so$")
MAPPING_PLUGIN_RE = re.compile(r"^ifcopenshell_geometry_mapping_(.+)\.so$")
DOCUMENT_PLUGIN_RE = re.compile(r"^ifcopenshell_document_[a-z0-9]+(?:_(.+))?\.so$")
GEOMETRY_SERIALIZATION_PLUGIN_RE = re.compile(r"^ifcopenshell_geometry_writer_(.+)\.so$")
def schema_key(schema: str) -> tuple[int, str]:
+1
View File
@@ -0,0 +1 @@
pyodide-build==0.39.0
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
import argparse
import shlex
import shutil
import subprocess
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
DIST_DIRS = (
SCRIPT_DIR / "test/pyodide",
SCRIPT_DIR / "test/pyodide-modular",
)
WHEEL_SRCS = (
SCRIPT_DIR / "../dist",
SCRIPT_DIR / "../dist-modular",
)
def run(cmd: list, **kwargs) -> None:
print("$", shlex.join(str(part) for part in cmd))
subprocess.check_call(cmd, **kwargs)
def setup() -> None:
run(["uv", "pip", "install", "pytest-pyodide"])
# Copy pyodide installation so we can modify it locally just for tests.
pyodide_root = subprocess.check_output(["pyodide", "config", "get", "pyodide_root"], text=True).strip()
pyodide_root_dist = Path(pyodide_root) / "dist"
for dist_dir in DIST_DIRS:
if dist_dir.exists():
shutil.rmtree(dist_dir)
shutil.copytree(pyodide_root_dist, dist_dir)
def run_tests() -> None:
for dist_dir, wheel_src in zip(DIST_DIRS, WHEEL_SRCS):
if not wheel_src.exists():
raise RuntimeError(f"error: {wheel_src} does not exist")
# Clean up previous wheels.
for whl in dist_dir.glob("ifcopenshell*.whl"):
whl.unlink()
# Symlink new ones.
for whl in wheel_src.glob("ifcopenshell*.whl"):
(dist_dir / whl.name).symlink_to(whl.resolve())
for dist_dir in DIST_DIRS:
run(["pytest", f"--dist-dir={dist_dir}", "--capture=no"], cwd=SCRIPT_DIR)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["setup", "run"])
args = parser.parse_args()
if args.command == "setup":
setup()
else:
run_tests()
+16 -1
View File
@@ -1,5 +1,19 @@
#!/usr/bin/env python3
"""Split optional IfcOpenShell Pyodide payloads into separate wheels."""
# /// script
# ///
"""Split optional IfcOpenShell Pyodide payloads into separate wheels.
The main wheel bundles per-schema plugin ``.so`` files and pure Python
subpackages that most browser sessions probably don't need.
This splits each of those out into its own installable wheel,
so a Pyodide app can fetch just the base wheel plus whichever schema/plugin wheels it actually needs.
Resulting wheels (roughly):
- ifcopenshell.whl (main ifcopenshell.py files + _ifcopenshell_wrapper)
- ifcopenshell_pure_python.whl (api, express, python files only)
- splitted wheels with a single .so binary - e.g. `ifcopenshell_parse_schema_ifc4.whl`
"""
from __future__ import annotations
@@ -159,6 +173,7 @@ def build_wheel(
entries[record_name] = None
write_record(zf, entries, record_name)
print(f"Splitting wheel to '{out}'.")
return out
+2
View File
@@ -0,0 +1,2 @@
pyodide
pyodide-modular
View File
+22 -11
View File
@@ -1,18 +1,30 @@
import zipfile
from pathlib import Path
WHEEL_FILENAME = next(
p.name for p in (Path.cwd() / "pyodide").iterdir() if p.name.startswith("ifcopenshell-") and p.suffix == ".whl"
)
from ..order_pyodide_wheel_shared_objects import shared_object_sort_key
def test_ifcopenshell_import(selenium):
def _first_so_name(wheel_path: Path) -> str:
with zipfile.ZipFile(wheel_path) as zf:
for name in zf.namelist():
if name.endswith(".so"):
return Path(name).name
return wheel_path.name
def test_ifcopenshell_import(selenium, request):
dist_dir = Path(request.config.getoption("--dist-dir"))
wheel_paths = list(dist_dir.glob("ifcopenshell*.whl"))
wheel_paths.sort(key=lambda path: shared_object_sort_key(_first_so_name(path), 0))
WHEEL_NAMES = tuple(path.name for path in wheel_paths)
selenium.load_package("micropip")
# Important to test it with `micropip.install`
# without any dependencies loaded to ensure micropip will load them automatically.
selenium.run_async(
f"""
selenium.run_async(f"""
import micropip
await micropip.install(f"./{WHEEL_FILENAME}")
wheel_filenames = {WHEEL_NAMES!r}
for wheel_filename in wheel_filenames:
print(f"Loading {{wheel_filename}}...")
await micropip.install(f"./{{wheel_filename}}")
import ifcopenshell
from pathlib import Path
ifcopenshell.set_plugin_search_paths([str(Path(ifcopenshell.__file__).parent)])
@@ -24,5 +36,4 @@ def test_ifcopenshell_import(selenium):
wall.Name = "Test"
assert wall.Name == "Test", f"Entity name wasn't changed: {{wall}}"
print(wall)
"""
)
""")
+21 -10
View File
@@ -9,6 +9,7 @@ line-length = 120
include = '''
src/.*.pyi?$
|nix/.*.pyi?$
|pyodide/.*.pyi?$
'''
extend-exclude = '''
src/ifcopenshell-python/ifcopenshell/express/rules/*
@@ -62,20 +63,30 @@ select = [
#
"FA", # future annotations
"UP", # pyupgrade
"RUF015", # next() > list_comprehension[0]
"RUF022", # sort __all__
"unnecessary-iterable-allocation-for-first-element",
"unsorted-dunder-all",
"I", # import sorting
"unused-noqa",
"rule-codes-in-selectors",
"noqa-comments",
"rule-codes-in-suppression-comments",
# General util rules.
"invalid-rule-code",
"redirected-noqa",
"invalid-pyproject-toml",
"invalid-suppression-comment",
]
ignore = [
"FA100", # Conflicts with Blender using annotations for props definitions.
# Conflicts with Blender using annotations for props definitions.
"future-rewritable-type-annotation",
# Maybe will enable later:
"UP007", # Optional to X | Y
"UP045", # Optional to X | None
"UP015", # Unnecessary mode argument
"UP028", # yield for -> yield from
"UP030", # implicit references for positional format fields
"UP031", # Replace % with .format
"UP032", # Replace .format with f-string
"non-pep604-annotation-union", # Union[X,Y] to X | Y
"non-pep604-annotation-optional", # Optional to X | None
"redundant-open-modes", # Unnecessary mode argument
"yield-in-for-loop", # yield for -> yield from
"format-literals", # implicit references for positional format fields
"printf-string-formatting", # Replace % with .format
"f-string", # Replace .format with f-string
]
[tool.ty.rules]
+3 -3
View File
@@ -10,7 +10,7 @@ name = "bcf-client"
# author = "IfcOpenShell"
description = "BCF-XML file handler."
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.10"
keywords = ["IFC", "BCF", "BIM"]
dependencies = [
"xsdata>=24.4",
@@ -65,6 +65,6 @@ commands = pytest --cov --cov-report=term tests
[tool.ruff]
extend = "../../pyproject.toml"
lint.select = [
"F401", # unused imports
lint.extend-select = [
"unused-import", # unused imports
]
+13 -11
View File
@@ -42,10 +42,12 @@ endif
IS_STABLE:=FALSE
VERSION:=$(shell cat ../../VERSION)
VERSION_MAJOR:=$(shell cat '../../VERSION' | cut -d '.' -f 1)
VERSION_MINOR:=$(shell cat '../../VERSION' | cut -d '.' -f 2)
VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3)
VERSION_BASE:=$(shell sed -E 's/[[:alpha:]]+[0-9]+$$//' ../../VERSION)
VERSION_PYTHON:=$(shell sed 's/alpha/a/' ../../VERSION)
VERSION_SEMVER:=$(shell sed -E 's/([[:alpha:]]+)([0-9]+)$$/-\\1\\2/' ../../VERSION)
VERSION_DATE:=$(shell date '+%y%m%d')
VERSION_DAILY:=$(VERSION_BASE)a$(VERSION_DATE)
VERSION_SEMVER_DAILY:=$(VERSION_BASE)-alpha$(VERSION_DATE)
LAST_COMMIT_HASH:=$(shell git rev-parse HEAD)
LAST_COMMIT_DATE:=$(shell git show -s --format=%cI)
LAST_GIT_BRANCH:=$(shell git rev-parse --abbrev-ref HEAD)
@@ -260,14 +262,14 @@ endif
cp pyproject.toml build/
ifeq ($(IS_STABLE), TRUE)
$(SED) "s/0.0.0/$(VERSION)/" build/bonsai/blender_manifest.toml
$(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml
$(SED) "s/0.0.0/$(VERSION_SEMVER)/" build/bonsai/blender_manifest.toml
$(SED) 's/version = "0.0.0"/version = "$(VERSION_PYTHON)"/' build/pyproject.toml
else
$(SED) "s/0.0.0/$(VERSION)-alpha$(VERSION_DATE)/" build/bonsai/blender_manifest.toml
$(SED) "s/0.0.0/$(VERSION_SEMVER_DAILY)/" build/bonsai/blender_manifest.toml
$(SED) "s/8888888/$(LAST_COMMIT_HASH)/" build/bonsai/__init__.py
$(SED) "s/9999999/$(LAST_COMMIT_DATE)/" build/bonsai/__init__.py
$(SED) "s/7777777/$(LAST_GIT_BRANCH)/" build/bonsai/__init__.py
$(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml
$(SED) 's/version = "0.0.0"/version = "$(VERSION_DAILY)"/' build/pyproject.toml
endif
# Blender 5.1+ requires Python 3.13.
@@ -279,9 +281,9 @@ endif
# Provides bonsai Add-on functionality
ifeq ($(IS_STABLE), TRUE)
$(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml
$(SED) 's/version = "0.0.0"/version = "$(VERSION_PYTHON)"/' build/pyproject.toml
else
$(SED) 's/version = "0.0.0"/version = "$(VERSION)a$(VERSION_DATE)"/' build/pyproject.toml
$(SED) 's/version = "0.0.0"/version = "$(VERSION_DAILY)"/' build/pyproject.toml
endif
cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m build
cp build/dist/*.whl build/wheels/
@@ -315,9 +317,9 @@ endif
rm -rf build/bonsai/libs/
ifeq ($(IS_STABLE), TRUE)
cd build && zip -r bonsai_$(PYVERSION)-$(VERSION)-$(BLENDER_PLATFORM).zip ./bonsai
cd build && zip -r bonsai_$(PYVERSION)-$(VERSION_SEMVER)-$(BLENDER_PLATFORM).zip ./bonsai
else
cd build && zip -r bonsai_$(PYVERSION)-$(VERSION)-alpha$(VERSION_DATE)-$(BLENDER_PLATFORM).zip ./bonsai
cd build && zip -r bonsai_$(PYVERSION)-$(VERSION_SEMVER_DAILY)-$(BLENDER_PLATFORM).zip ./bonsai
endif
mv build/bonsai*.zip dist/
+19
View File
@@ -59,6 +59,7 @@ from bonsai.bim.module.model.decorator import (
)
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator
from bonsai.tool.spatial import install_geom_cache_handlers, uninstall_geom_cache_handlers
cwd = os.path.dirname(os.path.realpath(__file__))
global_subscription_owner = object()
@@ -121,9 +122,25 @@ def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -
def active_object_callback():
refresh_ui_data()
update_bim_tool_props()
update_spatial_tool_props()
tool.Geometry.sync_item_positions()
def update_spatial_tool_props():
"""Sync ``BIMSpatialDecompositionProperties.space_height`` with the
active object's height when it is an ``IfcSpace``, otherwise reset to
the 3m default. Called from the msgbus active-object callback so Scene
property writes happen outside ``draw()``."""
obj = tool.Blender.get_active_object()
props = tool.Spatial.get_spatial_props()
if obj:
element = tool.Ifc.get_entity(obj)
if element and element.is_a("IfcSpace"):
props.space_height = obj.dimensions.z
return
props.space_height = 3
def update_bim_tool_props():
"""Selection-driven BIM Tool sync: re-target user-intent enums
(ifc_class, relating_type_id) AND refresh header values
@@ -528,6 +545,7 @@ def _install_viewport_overlays() -> None:
ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers()
uninstall_geom_cache_handlers()
try:
if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context)
@@ -570,6 +588,7 @@ def _install_viewport_overlays() -> None:
ArrayPreviewDecorator.install(bpy.context)
finally:
install_decorator_cache_handlers()
install_geom_cache_handlers()
@persistent
+3 -8
View File
@@ -185,13 +185,10 @@ class IfcStore:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
IfcStore.cache_path = cache_path
cache_path = Path(IfcStore.cache_path)
cache_settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings = ifcopenshell.geom.settings()
cache_preexists = cache_path.exists()
try:
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
IfcStore.cache_path, cache_settings, serializer_settings
)
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(IfcStore.cache_path, settings)
if cache_preexists:
print(f"Successfully loaded existing cache: {cache_path.name}.")
else:
@@ -206,9 +203,7 @@ class IfcStore:
os.remove(IfcStore.cache_path)
try:
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
IfcStore.cache_path, cache_settings, serializer_settings
)
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(IfcStore.cache_path, settings)
print("New cache was created.")
except Exception as e:
print(f"Failed to create a cache: {str(e)}.")
+5 -5
View File
@@ -740,7 +740,7 @@ class IfcImporter:
self.update_progress((percent_average / 100 * progress_range) + start_progress)
shape = iterator.get()
if shape:
assert isinstance(shape, W.TriangulationElement)
assert isinstance(shape, W.triangulation_element)
product = self.file.by_id(shape.id)
self.create_product(product, shape)
results.add(product)
@@ -1079,9 +1079,9 @@ class IfcImporter:
def create_curve(
self,
element: ifcopenshell.entity_instance,
shape: Union[W.Triangulation, W.TriangulationElement],
shape: Union[W.triangulation, W.triangulation_element],
) -> bpy.types.Curve:
if isinstance(shape, W.TriangulationElement):
if isinstance(shape, W.triangulation_element):
geometry = shape.geometry
else:
geometry = shape
@@ -1112,11 +1112,11 @@ class IfcImporter:
def create_mesh(
self,
element: ifcopenshell.entity_instance,
shape: Union[W.Triangulation, W.TriangulationElement],
shape: Union[W.triangulation, W.triangulation_element],
cartesian_point_offset: Union[npt.NDArray[np.float64], Literal[False]] = None,
) -> Union[bpy.types.Mesh, None]:
try:
if isinstance(shape, W.TriangulationElement):
if isinstance(shape, W.triangulation_element):
# shape is ShapeElementType
geometry = shape.geometry
else:
@@ -23,6 +23,7 @@ from . import operator, prop, ui
classes = (
operator.AddBoundary,
operator.ColourByRelatedBuildingElement,
operator.CopyBoundaryAttributeToSelection,
operator.DecorateBoundaries,
operator.DisableEditingBoundary,
operator.DisableEditingBoundaryGeometry,
+52 -196
View File
@@ -18,7 +18,7 @@
import logging
import multiprocessing
from math import acos, degrees, inf, pi, radians
from math import inf, pi
from typing import Optional, Union
import bmesh
@@ -28,6 +28,7 @@ import ifcopenshell.api.boundary
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.boundary
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.shape
@@ -39,6 +40,7 @@ from ifcopenshell.util.shape_builder import ShapeBuilder
from mathutils import Matrix, Vector
import bonsai.bim.import_ifc as import_ifc
import bonsai.core.attribute as core
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
@@ -422,6 +424,32 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CopyBoundaryAttributeToSelection(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.copy_boundary_attribute_to_selection"
bl_label = "Copy Boundary Attribute To Selection"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty()
def _execute(self, context):
obj = tool.Blender.get_active_object()
assert obj
bprops = tool.Boundary.get_object_boundary_props(obj)
if self.name in EDITABLE_ATTRIBUTES:
blender_prop = EDITABLE_ATTRIBUTES[self.name]
blender_obj = getattr(bprops, blender_prop, None)
value = tool.Ifc.get_entity(blender_obj) if blender_obj else None
elif self.name == "PhysicalOrVirtualBoundary":
value = bprops.physical_or_virtual
elif self.name == "InternalOrExternalBoundary":
value = bprops.internal_or_external
else:
return
total = core.copy_attribute_to_selection(
tool.Ifc, tool.Blender, tool.Root, tool.Spatial, name=self.name, value=value
)
self.report({"INFO"}, f"Attribute was successfully copied to {total} elements.")
class UpdateBoundaryGeometry(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.update_boundary_geometry"
bl_label = "Update Boundary Geometry"
@@ -668,36 +696,30 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
def auto_generate_boundaries(
self, space: ifcopenshell.entity_instance, space_obj: bpy.types.Object
) -> Union[str, list[ifcopenshell.entity_instance]]:
"""
:return: list of created boundaries or a string with error description.
"""Generate boundaries by delegating to ifcopenshell.util.boundary.
This method handles Blender-specific preprocessing (flushing moved
objects, building the geometry cache + spatial tree) then delegates
the algorithm to the Blender-independent util module.
"""
ifc_file = tool.Ifc.get()
props = tool.Model.get_model_props()
boundaries: list[ifcopenshell.entity_instance] = []
assert isinstance(space_obj.data, bpy.types.Mesh)
# Identify all potential building elements
# TODO: don't select everything, use AABB culling in Blender
building_elements = (
tool.Ifc.get().by_type("IfcWall")
+ tool.Ifc.get().by_type("IfcSlab")
+ tool.Ifc.get().by_type("IfcVirtualElement")
)
building_elements = []
for ifc_class in ifcopenshell.util.boundary.BOUNDARY_ELEMENT_CLASSES:
building_elements.extend(ifc_file.by_type(ifc_class))
# Flush moved objects to IFC
for building_element in building_elements:
if obj := tool.Ifc.get_object(building_element):
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
if tool.Ifc.is_moved(space_obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=space_obj)
# Don't generate boundaries of building elements that we've already got bounaries for.
for boundary in space.BoundedBy:
if boundary.RelatedBuildingElement in building_elements:
building_elements.remove(boundary.RelatedBuildingElement)
# Create tree of gross shapes of all potential related building elements
# Build shapes dict with iterator (parallel, includes space + building elements)
include = building_elements + [space]
tree = ifcopenshell.geom.tree()
shapes = {}
@@ -708,193 +730,27 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
while True:
tree.add_element(iterator.get_native())
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
assert isinstance(shape, W.triangulation_element)
shapes[shape.id] = {
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
}
if not iterator.next():
break
# Spatially query all potential boundary elements via a 100mm extension of the space
building_elements = [e for e in tree.select(space, extend=0.1) if e != space]
# Pass all building element shapes to the auto-generation function.
# The function performs its own spatial filtering (coplanarity + overlap),
# so tree-adjacency filtering is not needed here.
filtered_shapes = {space.id(): shapes[space.id()]}
for element in building_elements:
if element.id() in shapes:
filtered_shapes[element.id()] = shapes[element.id()]
if not building_elements:
return "No building elements found to create boundaries."
# Create a dissolved bmesh for the space
space_bm = bmesh.new()
space_bm.from_mesh(space_obj.data)
bmesh.ops.dissolve_limit(space_bm, angle_limit=pi * 2 / 360, verts=space_bm.verts[:], edges=space_bm.edges[:])
# Create dissolved bmeshes for all boundary elements
building_element_bms = {}
for building_element in building_elements:
bm = bmesh.new()
shape = shapes[building_element.id()]
for vert in shape["verts"]:
bm.verts.new(Vector(vert))
bm.verts.ensure_lookup_table()
for face in shape["faces"]:
bm.faces.new([bm.verts[i] for i in face])
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
bm.normal_update() # Needed so that dissolve_limit will work.
bmesh.ops.dissolve_limit(bm, angle_limit=radians(1), verts=bm.verts[:], edges=bm.edges[:])
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
building_element_bms[building_element.id()] = bm
# Compare space faces and building element faces to see if they relate to one another
for space_face in space_bm.faces:
space_face_normal = space_obj.matrix_world.to_3x3() @ space_face.normal
space_face_vert = space_obj.matrix_world @ space_face.verts[0].co
for building_element in building_elements:
for face in building_element_bms[building_element.id()].faces:
building_obj = tool.Ifc.get_object(building_element)
face_normal = building_obj.matrix_world.to_3x3() @ face.normal
angle = degrees(acos(max(min(space_face_normal.dot(face_normal), 1), -1)))
if tool.Cad.is_x(angle, 180, tolerance=2):
pass # Faces need to be parallel and have opposite normals to be related.
elif building_element.is_a("IfcVirtualElement") and tool.Cad.is_x(angle, 0, tolerance=2):
pass # Virtual elements only need to be parallel to be related, since they are planes.
else:
continue
# Both faces should be close to one another. Say within 50mm.
space_vert = building_obj.matrix_world.inverted() @ space_face_vert
dist = mathutils.geometry.distance_point_to_plane(space_vert, face.verts[0].co, face.normal)
if abs(dist) > 0.05:
continue
# Project the building element face onto the space face
space_face_verts = [v.co.copy() for v in space_face.verts]
space_face_matrix = self.get_face_matrix(*[v.copy() for v in space_face_verts[0:3]])
space_face_matrix_i = space_face_matrix.inverted()
space_face_polygon = shapely.Polygon(
[tuple((space_face_matrix_i @ v).xy) for v in space_face_verts]
)
space_matrix_world_i = space_obj.matrix_world.inverted()
face_verts = [space_matrix_world_i @ building_obj.matrix_world @ v.co.copy() for v in face.verts]
face_polygon = shapely.Polygon([tuple((space_face_matrix_i @ v).xy) for v in face_verts])
gross_boundary_polygon = space_face_polygon.intersection(face_polygon)
if type(gross_boundary_polygon) == shapely.GeometryCollection:
for geom in gross_boundary_polygon.geoms:
if type(geom) == shapely.Polygon:
gross_boundary_polygon = geom
break
if (
not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid)
or gross_boundary_polygon.is_empty
):
continue
# The gross boundary polygon may not be a true gross boundary since it
# may have openings already removed, such as in IFC4 Reference View. So
# we cheat by using the exterior boundary to mean "gross".
exterior_boundary_polygon = shapely.Polygon(gross_boundary_polygon.exterior.coords)
parent_boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=props.boundary_class)
if building_element.is_a("IfcVirtualElement"):
parent_boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
else:
parent_boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
parent_boundary.InternalOrExternalBoundary = "NOTDEFINED"
if building_element.is_a("IfcWall"):
is_external = ifcopenshell.util.element.get_pset(
building_element, "Pset_WallCommon", "IsExternal"
)
if is_external is True:
parent_boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
parent_boundary.InternalOrExternalBoundary = "INTERNAL"
elif building_element.is_a("IfcSlab"):
predefined_type = ifcopenshell.util.element.get_predefined_type(building_element)
if predefined_type == "BASESLAB":
parent_boundary.InternalOrExternalBoundary = "EXTERNAL_EARTH"
else:
is_external = ifcopenshell.util.element.get_pset(
building_element, "Pset_SlabCommon", "IsExternal"
)
if is_external is True:
parent_boundary.InternalOrExternalBoundary = "EXTERNAL"
elif is_external is False:
parent_boundary.InternalOrExternalBoundary = "INTERNAL"
parent_boundary.RelatingSpace = space
parent_boundary.RelatedBuildingElement = building_element
parent_boundary.ConnectionGeometry = self.create_connection_geometry_from_polygon(
exterior_boundary_polygon, space_face_matrix
)
self.set_boundary_name(parent_boundary)
boundaries.append(parent_boundary)
for rel in getattr(building_element, "HasOpenings", []):
opening = rel.RelatedOpeningElement
filling = opening.HasFillings[0].RelatedBuildingElement if opening.HasFillings else None
# Create shape of opening as a dissolved BMesh
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, opening)
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
opening_bm = bmesh.new()
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
for vert in verts:
opening_bm.verts.new(Vector(vert))
opening_bm.verts.ensure_lookup_table()
faces = ifcopenshell.util.shape.get_faces(shape.geometry)
for face in faces:
opening_bm.faces.new([opening_bm.verts[i] for i in face])
opening_bm.verts.ensure_lookup_table()
opening_bm.faces.ensure_lookup_table()
opening_bm.normal_update() # Needed so that dissolve_limit will work.
bmesh.ops.dissolve_limit(
opening_bm, angle_limit=radians(1), verts=opening_bm.verts[:], edges=opening_bm.edges[:]
)
opening_bm.verts.ensure_lookup_table()
opening_bm.faces.ensure_lookup_table()
# Get relevant faces of BMesh that can turn into boundaries
opening_polygons = []
for opening_face in opening_bm.faces:
opening_face_normal = mat.to_3x3() @ opening_face.normal
angle = degrees(acos(max(min(opening_face_normal.dot(face_normal), 1), -1)))
if not tool.Cad.is_x(angle, 180, tolerance=2):
continue # Any non-parallel faces are not relevant
opening_face_verts = [space_matrix_world_i @ mat @ v.co.copy() for v in opening_face.verts]
polygon = shapely.Polygon([tuple((space_face_matrix_i @ v).xy) for v in opening_face_verts])
opening_polygons.append(polygon)
# Merge them all into a single opening polygon for our boundary
opening_polygon = shapely.ops.unary_union(opening_polygons)
# Only openings that are projected onto our exterior boundary are relevant.
if opening_polygon.intersection(exterior_boundary_polygon).area == 0:
continue
boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=props.boundary_class)
boundary.RelatingSpace = space
boundary.RelatedBuildingElement = filling or opening
boundary.ConnectionGeometry = self.create_connection_geometry_from_polygon(
opening_polygon, space_face_matrix
)
if filling:
boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
else:
boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
boundary.InternalOrExternalBoundary = parent_boundary.InternalOrExternalBoundary
if boundary.is_a() != "IfcRelSpaceBoundary":
boundary.ParentBoundary = parent_boundary
self.set_boundary_name(boundary)
boundaries.append(boundary)
return boundaries
return ifcopenshell.util.boundary.auto_generate_boundaries(
ifc_file, space, filtered_shapes, props.boundary_class
)
def create_element_boundary(
self,
+8 -2
View File
@@ -77,10 +77,14 @@ class BIM_PT_Boundary(Panel):
self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element")
self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary")
self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary")
row = self.layout.row()
row = self.layout.row(align=True)
row.prop(self.bprops, "physical_or_virtual")
row = self.layout.row()
op = row.operator("bim.copy_boundary_attribute_to_selection", text="", icon="COPYDOWN")
op.name = "PhysicalOrVirtualBoundary"
row = self.layout.row(align=True)
row.prop(self.bprops, "internal_or_external")
op = row.operator("bim.copy_boundary_attribute_to_selection", text="", icon="COPYDOWN")
op.name = "InternalOrExternalBoundary"
else:
row = self.layout.row()
row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit")
@@ -125,6 +129,8 @@ class BIM_PT_Boundary(Panel):
if hasattr(boundary, ifc_attribute):
row = self.layout.row(align=True)
row.prop(self.bprops, blender_property)
op = row.operator("bim.copy_boundary_attribute_to_selection", text="", icon="COPYDOWN")
op.name = ifc_attribute
class BIM_PT_SpaceBoundaries(Panel):
@@ -348,10 +348,7 @@ class AddClassificationReference(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
if self.obj_type == "Object":
if context.selected_objects:
objects = [o.name for o in context.selected_objects]
else:
objects = [context.active_object.name]
objects = [o.name for o in tool.Blender.get_selected_objects()]
else:
objects = [self.obj]
props = tool.Classification.get_classification_props()
@@ -516,7 +516,7 @@ def _world_segment_to_screen_pixels(
# ---------------------------------------------------------------------------
class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
class BIM_GT_box_face_quad(bpy.types.Gizmo):
"""Near-invisible face-quad click target with drag-to-resize modal.
Geometry: a unit quad in the local XY plane at z=0. The adapter
@@ -620,7 +620,7 @@ class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname
return {"RUNNING_MODAL"}
class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
class BIM_GT_box_face_outline(bpy.types.Gizmo):
"""Thin non-interactive colored edge outline for one face.
Drawn as 4 line segments in the face plane. The layout helper
@@ -160,7 +160,7 @@ def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool):
return setter
class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention
class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup):
"""Face-quad resize handles on the active clip box.
Renders six near-invisible click-target quads and six colored edge
@@ -987,7 +987,7 @@ class ExportCostSchedulesToPDF(bpy.types.Operator, ExportHelper):
@classmethod
def poll(cls, context):
try:
import typst # noqa: F401
import typst # ruff: ignore[unused-import]
return True
except ModuleNotFoundError:
@@ -313,7 +313,7 @@ class CreateAllShapes(bpy.types.Operator):
failures.append(element)
print("***** FAILURE *****")
if shape:
assert isinstance(shape, W.TriangulationElement)
assert isinstance(shape, W.triangulation_element)
geom = shape.geometry
print(
f"Success {time.time() - start:.3f}s "
@@ -28,7 +28,7 @@ operators via ``target_set_operator``; drag handles inherit modal state
from ``GizmoMovable``.
"""
__all__ = [ # noqa: RUF022 (unsorted `__all__`)
__all__ = [ # ruff: ignore[unsorted-dunder-all]
"GizmoColor",
"GizmoAxis",
"TextAlignment",
@@ -5660,7 +5660,7 @@ class BaseParametricGizmoGroup:
"""
return 0.0
def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: # noqa: ARG002
def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None:
"""Update overall_width, overall_height, and lining_offset based on view direction.
This base implementation handles the common pattern for door/window gizmos.
@@ -5837,7 +5837,7 @@ class BaseParametricGizmoGroup:
self.update_dimension_gizmos(mw, props)
self._refresh_element_specific(context, mw, props)
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: # noqa: ARG002
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
"""Override for element-specific refresh logic.
Called from both refresh() (on state change) and draw_prepare() (per frame),
@@ -6344,7 +6344,7 @@ class BaseParametricGizmoGroup:
"""
return (0.0, 0.0)
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: # noqa: ARG002
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float:
"""Get Y offset for icons based on view direction.
Uses get_icon_y_extent() to determine how far to offset icons based on
@@ -6546,9 +6546,7 @@ class BaseParametricGizmoGroup:
self._refresh_element_specific(context, mw, props)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: "Matrix", props # noqa: ARG002
) -> None:
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
"""Update dimension gizmo positions based on view direction.
Override this method in subclasses to implement view-dependent
@@ -1406,31 +1406,28 @@ class CreateDrawing(bpy.types.Operator):
# Backwards compatibility with older ifcopenshell builds that don't expose these keys.
pass
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
self.serialiser_settings.set("svg-without-storeys", True)
self.serialiser_settings.set("svg-write-poly", True)
self.serialiser_settings.set("svg-poly", True)
self.svg_settings.set("svg-without-storeys", True)
self.svg_settings.set("svg-write-poly", True)
self.svg_settings.set("svg-poly", True)
# Objects with more than these edges are rendered as wireframe instead of HLR for optimisation
self.serialiser_settings.set("profile-threshold", 10000)
self.serialiser_settings.set("svg-xmlns", True)
self.serialiser_settings.set("svg-project", True)
self.serialiser_settings.set("auto-elevation", False)
self.serialiser_settings.set("auto-section", False)
self.serialiser_settings.set("print-space-names", False)
self.serialiser_settings.set("print-space-areas", False)
self.serialiser_settings.set("door-arcs", False)
self.serialiser_settings.set("svg-no-css", True)
self.serialiser_settings.set("elevation-ref-guid", self.camera_element.GlobalId)
self.serialiser_settings.set("scale", str(self.scale))
self.serialiser_settings.set("svg-subtract-before", "always")
self.serialiser_settings.set("svg-prefilter", True) # See #3359
self.serialiser_settings.set("svg-unify-inputs", True)
self.serialiser_settings.set("svg-segment-projection", True)
self.svg_settings.set("profile-threshold", 10000)
self.svg_settings.set("svg-xmlns", True)
self.svg_settings.set("svg-project", True)
self.svg_settings.set("auto-elevation", False)
self.svg_settings.set("auto-section", False)
self.svg_settings.set("print-space-names", False)
self.svg_settings.set("print-space-areas", False)
self.svg_settings.set("door-arcs", False)
self.svg_settings.set("svg-no-css", True)
self.svg_settings.set("elevation-ref-guid", self.camera_element.GlobalId)
self.svg_settings.set("scale", str(self.scale))
self.svg_settings.set("svg-subtract-before", "always")
self.svg_settings.set("svg-prefilter", True) # See #3359
self.svg_settings.set("svg-unify-inputs", True)
self.svg_settings.set("svg-segment-projection", True)
if target_view == "REFLECTED_PLAN_VIEW":
self.serialiser_settings.set("svg-mirror-y", True)
self.serialiser = ifcopenshell.geom.serializers.svg(
self.svg_buffer, self.svg_settings, self.serialiser_settings
)
self.svg_settings.set("svg-mirror-y", True)
self.serialiser = ifcopenshell.geom.serializers.svg(self.svg_buffer, self.svg_settings)
# tree = ifcopenshell.geom.tree()
# This instructs the tree to explode BReps into faces and return
# the style of the face when running tree.select_ray()
@@ -72,11 +72,10 @@ class ExportOBJ(bpy.types.Operator):
# Conversion from IFC to OBJ
# Settings for obj
settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
settings.set("apply-default-materials", True)
serializer_settings.set("use-element-guids", True)
settings.set("use-element-guids", True)
settings.set("use-world-coords", True)
ifc_file: ifcopenshell.file
@@ -90,7 +89,7 @@ class ExportOBJ(bpy.types.Operator):
obj_file_path = os.path.join(output_dir, "model.obj")
mtl_file_path = os.path.join(output_dir, "model.mtl")
serialiser = ifcopenshell.geom.serializers.obj(obj_file_path, mtl_file_path, settings, serializer_settings)
serialiser = ifcopenshell.geom.serializers.obj(obj_file_path, mtl_file_path, settings)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
@@ -107,7 +106,7 @@ class ExportOBJ(bpy.types.Operator):
if iterator.initialize():
while True:
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
assert isinstance(shape, W.triangulation_element)
materials = shape.geometry.materials
for material in materials:
@@ -178,6 +178,7 @@ classes = (
covering.RegenSelectedCoveringObject,
space.ToggleSpaceVisibility,
space.ToggleHideSpaces,
space.ApplySpaceHeightToSelection,
mep.FitFlowSegments,
mep.RegenerateDistributionElement,
prop.SnapMousePoint,
+1 -1
View File
@@ -430,7 +430,7 @@ class SverchokData:
@classmethod
def has_sverchok(cls) -> bool:
try:
import sverchok # noqa: F401
import sverchok # ruff: ignore[unused-import]
return True
except ModuleNotFoundError:
+4 -6
View File
@@ -560,7 +560,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
)
update_door_modifier_representation(obj)
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
def _execute(self, context: bpy.types.Context) -> set[str]:
for obj in tool.Blender.get_selected_objects():
if not tool.Blender.Modifier.is_eligible_for_door_modifier(obj):
continue
@@ -638,7 +638,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
def _execute(self, context: bpy.types.Context) -> set[str]:
for obj in tool.Blender.get_selected_objects():
self.remove_door_on_object(obj)
return {"FINISHED"}
@@ -683,7 +683,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
return True
return False
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = tool.Blender.get_active_object()
if not obj:
return {"CANCELLED"}
@@ -909,9 +909,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
setattr(self, f"gizmo_swing_arc_{cfg.name}", main)
setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
) -> None:
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update door-specific swing arc gizmos."""
self.update_swing_gizmos(mw, props)
+2 -2
View File
@@ -765,7 +765,7 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_roof(element)
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None:
"""Anchor every dimension gizmo at the object origin. Each gizmo's
declared axis (height/slope along +Z, thickness along -Z) separates
them in 3D so they don't visually collide despite sharing a
@@ -776,7 +776,7 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
def get_element_height(self, props) -> float: # noqa: ARG002
def get_element_height(self, props) -> float:
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
cancel / cycle row anchors visibly above sloped or stepped roof
bodies rather than at the parametric ``props.height`` which may not
@@ -18,7 +18,9 @@
import bpy
import ifcopenshell.util.unit
import bonsai.core.geometry as core_geometry
import bonsai.core.spatial as core
import bonsai.tool as tool
@@ -115,3 +117,47 @@ class ToggleHideSpaces(bpy.types.Operator):
def execute(self, context):
core.toggle_hide_spaces(tool.Ifc, tool.Spatial)
return {"FINISHED"}
class ApplySpaceHeightToSelection(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.apply_space_height_to_selection"
bl_label = "Apply Space Height To Selection"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Apply the space height value to all selected spaces without regenerating their footprint"
@classmethod
def poll(cls, context):
selected_spaces = [
obj
for obj in context.selected_objects
if (element := tool.Ifc.get_entity(obj)) and element.is_a("IfcSpace")
]
if not selected_spaces:
cls.poll_message_set("No spaces selected.")
return False
return True
def _execute(self, context):
ifc_file = tool.Ifc.get()
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
depth_ifc = tool.Spatial.get_spatial_props().space_height / si_conversion
total = 0
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcSpace"):
continue
body = tool.Geometry.get_body_representation(element)
if not body:
continue
extrusion = tool.Model.get_extrusion(body)
if not extrusion:
continue
extrusion.Depth = depth_ifc
core_geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
total += 1
self.report({"INFO"}, f"Height applied to {total} spaces.")
+3 -5
View File
@@ -405,7 +405,7 @@ class SetStairTreads(bpy.types.Operator):
bl_label = "Set Number of Treads"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
obj = context.active_object
if not obj:
return {"CANCELLED"}
@@ -658,9 +658,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.tread_count_label_gizmo.alpha = 0.8
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
"""Update stair-specific lock and tread count gizmos. Lock positioning is
handled per-frame in the dimension-positioning hook."""
self.update_lock_gizmo(props)
@@ -707,7 +705,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
) -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
+2 -2
View File
@@ -2174,7 +2174,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return (far, near)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties" # noqa: ARG002
self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties"
) -> None:
"""Re-position length / height / height_end dimensions to the camera-facing
Y-side of the wall every frame. Mirrors the door & stair pattern: when the
@@ -2530,7 +2530,7 @@ def _perpendicular_wall_params(
return clamped_x, abs(cursor_local_y), side_sign
def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001
def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None:
"""Thin wall-scoped alias for ``tool.Parametric.commit_pending_edits_for_selection``.
Encapsulates the ``names=("wall",)`` filter so the registry name is
+1 -1
View File
@@ -538,7 +538,7 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Remove Window"
bl_options = {"REGISTER"}
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -2442,7 +2442,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
if iterator.initialize():
while True: # Main loop.
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
assert isinstance(shape, W.triangulation_element)
results.add(self.file.by_id(shape.id))
geometry = shape.geometry
@@ -2518,7 +2518,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
print("Finished", time.time() - start)
return {"FINISHED"}
def process_occurrence(self, shape: W.TriangulationElement) -> None:
def process_occurrence(self, shape: W.triangulation_element) -> None:
element = self.file.by_id(shape.id)
mat = ifcopenshell.util.shape.get_shape_matrix(shape)
@@ -24,6 +24,7 @@ from bpy.props import (
BoolProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
IntProperty,
PointerProperty,
StringProperty,
@@ -277,6 +278,17 @@ class BIMSpatialDecompositionProperties(PropertyGroup):
should_include_children: BoolProperty(
name="Should Include Children", default=True, update=update_should_include_children
)
space_height: FloatProperty(
name="Space Height",
default=3,
subtype="DISTANCE",
description="Space height in meters. Auto-detected on generation unless forced. Used as fallback.",
)
force_space_height: BoolProperty(
name="Force Height",
default=False,
description="If enabled, uses the height value directly and skips auto-detection",
)
if TYPE_CHECKING:
is_locked: bool
@@ -294,6 +306,8 @@ class BIMSpatialDecompositionProperties(PropertyGroup):
subelement_class: str
default_container: int
should_include_children: bool
space_height: float
force_space_height: bool
@property
def active_container(self) -> Union[BIMContainer, None]:
@@ -83,9 +83,14 @@ class SpatialToolUI:
@classmethod
def draw_default_interface(cls, context):
spatial_props = tool.Spatial.get_spatial_props()
row = cls.layout.row(align=True)
row.prop(data=cls.model_props, property="rl3", text="RL")
row = cls.layout.row(align=True)
row.prop(data=spatial_props, property="space_height", text="Height")
row.prop(data=spatial_props, property="force_space_height", text="", icon="PINNED")
row.operator("bim.apply_space_height_to_selection", text="", icon="COPYDOWN")
row = cls.layout.row(align=True)
op_name = lambda op: op.get_rna_type().name
if AuthoringData.data["active_class"] == "IfcWall" and context.selected_objects:
add_layout_hotkey(
@@ -558,7 +558,7 @@ class IntegerInputDialogMixin:
return None
return props
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
props = self._resolve_props(context)
if props is None:
return {"CANCELLED"}
+2 -2
View File
@@ -18,7 +18,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -31,7 +31,7 @@ def copy_attribute_to_selection(
root: type[tool.Root],
spatial: type[tool.Spatial],
name: str,
value: Union[str, None],
value: Any,
) -> int:
total_changed = 0
has_edited_spatial_name = False
+3 -3
View File
@@ -46,7 +46,7 @@ def add_instance_flooring_covering_from_cursor(
else:
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
space_polygon, _ = spatial.get_space_polygon_from_context_visible_objects(x, y)
if isinstance(space_polygon, str):
return
@@ -81,7 +81,7 @@ def add_instance_ceiling_covering_from_cursor(
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
ceiling_height = covering.get_z_from_ceiling_height()
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
space_polygon, _ = spatial.get_space_polygon_from_context_visible_objects(x, y)
if isinstance(space_polygon, str):
return
@@ -106,7 +106,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
else:
assert False, "Object has to be active and selected."
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
space_polygon, _ = spatial.get_space_polygon_from_context_visible_objects(x, y)
if isinstance(space_polygon, str):
return
+29 -2
View File
@@ -142,8 +142,35 @@ def assign_material(
else:
element_material_type = material_type
ifc.run("material.assign_material", products=[element], type=element_material_type, material=material)
assigned_material = material_tool.get_material(element)
# TODO: this whole dance is a stopgap and wants rewriting.
#
# material.assign_material creates material sets with no items in them,
# ignoring the material it was handed -- an IfcMaterialLayerSet with no
# MaterialLayers is not valid IFC, since the list is mandatory and
# [1:?]. So we repair it below, after the fact. Worse, the API rejects a
# plain IfcMaterial outright when asked for a usage, which is exactly
# what the Object Materials dropdown gives us, so we cannot even pass it
# on and have to let the API invent an empty set and then fill it in.
#
# The fix is for assign_material to build the set around the material it
# is given, rather than leaving an invalid one behind for its callers to
# patch up. That is a wider change than it looks: add_material_set has
# the same behaviour, and the create-empty-then-add-items idiom is
# spread through the API's own docstrings, examples and tests. Until
# that is untangled, keep the repair here where it is at least visible.
# Only a usage refuses a plain IfcMaterial; every other type still wants
# it, and IfcMaterial and IfcMaterialList cannot be created without it.
pass_material = material_tool.is_a_material_set(material) or not element_material_type.endswith("Usage")
ifc.run(
"material.assign_material",
products=[element],
type=element_material_type,
material=material if pass_material else None,
)
# A usage points at the set rather than being one, and it is the set
# that needs an item adding to it below.
assigned_material = material_tool.get_material(element, should_skip_usage=True)
assert assigned_material # Type checker.
if material_tool.is_a_material_set(material):
+46 -7
View File
@@ -20,9 +20,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Union
import ifcopenshell
if TYPE_CHECKING:
import bpy
import ifcopenshell
import bonsai.tool as tool
@@ -186,9 +187,6 @@ def generate_space(
"""
:return: None if successful, error message string if not.
"""
if not root.get_default_container():
raise SpaceGenerationError("Please set a default container to create the space in.")
active_obj = spatial.get_active_obj()
selected_objects = spatial.get_selected_objects()
element = None
@@ -206,7 +204,15 @@ def generate_space(
else:
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
if element and element.is_a("IfcSpace"):
z = active_obj.location.z
container = ifcopenshell.util.element.get_parent(element) or root.get_default_container()
else:
container = root.get_default_container()
if not container:
raise SpaceGenerationError("Please set a default container to create the space in.")
space_polygon, bounding_walls = spatial.get_space_polygon_from_context_visible_objects(x, y, container=container)
if isinstance(space_polygon, str):
if space_polygon == "NO POLYGONS FOUND":
@@ -220,8 +226,25 @@ def generate_space(
else:
assert space_polygon
props = spatial.get_spatial_props()
if props.force_space_height:
h = props.space_height
else:
auto_h = spatial.get_auto_space_height(space_polygon, z, bounding_walls)
if auto_h is not None and auto_h > 0:
h = auto_h
if element and element.is_a("IfcSpace"):
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
assert active_obj
spatial.set_space_representation_from_polygon(
active_obj,
element,
space_polygon,
h,
polygon_is_si=True,
bounding_walls=bounding_walls,
container=container,
)
else:
if relating_type:
name = model.generate_occurrence_name(relating_type, "IfcSpace")
@@ -234,7 +257,9 @@ def generate_space(
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
spatial.set_space_representation_from_polygon(
obj, element, space_polygon, h, polygon_is_si=True, bounding_walls=bounding_walls, container=container
)
if relating_type:
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
@@ -248,11 +273,25 @@ def generate_spaces_from_walls(
z = spatial.get_active_obj_z()
h = spatial.get_active_obj_height()
bounding_walls = [
element
for obj in spatial.get_selected_objects()
if (element := ifc.get_entity(obj)) and element.is_a("IfcWall")
]
union = spatial.get_union_shape_from_selected_objects()
props = spatial.get_spatial_props()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
if props.force_space_height:
h = props.space_height
else:
auto_h = spatial.get_auto_space_height(poly, z, bounding_walls)
if auto_h is not None and auto_h > 0:
h = auto_h
name = "Space" + str(i)
obj = spatial.create_object(name)
+1 -1
View File
@@ -651,7 +651,7 @@ class Material:
def get_default_material(cls): pass
def get_elements_by_material(cls, material): pass
def get_material_attributes(cls): pass
def get_material(cls, element, should_inherit: bool = False): pass
def get_material(cls, element, should_inherit: bool = False, should_skip_usage: bool = False): pass
def get_object_ui_active_material(cls): pass
def get_object_ui_material_type(cls): pass
def get_style(cls, material): pass
+1 -1
View File
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# Ignore unused imports.
# ruff: noqa: F401
# ruff: file-ignore[unused-import]
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.array import Array
+2
View File
@@ -25,6 +25,7 @@ import importlib
import math
import os
import platform
import re
import subprocess
import sys
import tempfile
@@ -1756,6 +1757,7 @@ class Blender(bonsai.core.tool.Blender):
repo_path = repo.working_tree_dir
assert repo_path
version_ = (Path(repo_path) / "VERSION").read_text().strip()
version_ = re.sub(r"[A-Za-z]+\d+$", "", version_)
commit_date = bonsai.get_last_commit_date()
assert commit_date
commit_date = datetime.fromisoformat(commit_date)
+2 -2
View File
@@ -1187,7 +1187,7 @@ class Geometry(bonsai.core.tool.Geometry):
if iterator and iterator.initialize():
while True:
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
assert isinstance(shape, W.triangulation_element)
element = tool.Ifc.get().by_id(shape.id)
if obj := tool.Ifc.get_object(element):
# It's possible that there will be multiple shapes for the same context,
@@ -2179,7 +2179,7 @@ class Geometry(bonsai.core.tool.Geometry):
item = tool.Ifc.get().by_id(props.ifc_definition_id)
allowed_attributes = [
a.name()
for a in item.declaration().as_entity.all_attributes()
for a in item.declaration.as_entity().all_attributes()
if a.type_of_attribute()._is("IfcLengthMeasure")
]
+2 -2
View File
@@ -872,7 +872,7 @@ class Loader(bonsai.core.tool.Loader):
cls,
element: ifcopenshell.entity_instance,
representation: ifcopenshell.entity_instance,
shape: W.TriangulationElement,
shape: W.triangulation_element,
) -> bpy.types.Camera:
"""Create camera data.
@@ -1026,7 +1026,7 @@ class Loader(bonsai.core.tool.Loader):
@classmethod
def convert_geometry_to_mesh(
cls,
geometry: W.Triangulation,
geometry: W.triangulation,
mesh: bpy.types.Mesh,
verts: Optional[npt.NDArray[np.float64]] = None,
*,
+7 -2
View File
@@ -220,9 +220,14 @@ class Material(bonsai.core.tool.Material):
@classmethod
def get_material(
cls, element: ifcopenshell.entity_instance, should_inherit: bool = False
cls,
element: ifcopenshell.entity_instance,
should_inherit: bool = False,
should_skip_usage: bool = False,
) -> Union[ifcopenshell.entity_instance, None]:
return ifcopenshell.util.element.get_material(element, should_inherit=should_inherit)
return ifcopenshell.util.element.get_material(
element, should_inherit=should_inherit, should_skip_usage=should_skip_usage
)
@classmethod
def is_a_material_set(cls, material: ifcopenshell.entity_instance) -> bool:
+2 -2
View File
@@ -618,7 +618,7 @@ class Model(bonsai.core.tool.Model):
cls.edges.extend([(i, i + 1) for i in range(offset, len(cls.vertices) - 1)])
if is_closed:
cls.edges[-1] = (len(cls.vertices) - 1, offset) # Close the loop
cls.edges.append((len(cls.vertices) - 1, offset)) # Close the loop
elif curve.is_a("IfcCompositeCurve"):
# This is a first pass incomplete implementation only for simple polylines, and misses many details.
@@ -2459,7 +2459,7 @@ class Model(bonsai.core.tool.Model):
polygons = {}
for curve in curves:
geometry = ifcopenshell.geom.create_shape(settings, curve)
assert isinstance(geometry, W.Triangulation)
assert isinstance(geometry, W.triangulation)
v = ifcopenshell.util.shape.get_vertices(geometry, is_2d=True)
v = np.round(v, 4) # Round to nearest 0.1mm, otherwise things like circles don't polygonise reliably
edges = ifcopenshell.util.shape.get_edges(geometry)
+1 -1
View File
@@ -53,7 +53,7 @@ class Profile(bonsai.core.tool.Profile):
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
shape = ifcopenshell.geom.create_shape(settings, profile)
assert isinstance(shape, W.Triangulation)
assert isinstance(shape, W.triangulation)
verts = ifcopenshell.util.shape.get_vertices(shape)
if verts.size == 0:
raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile}'.")
+368 -25
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import json
import multiprocessing
from collections import defaultdict
from collections.abc import Generator, Iterable
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
@@ -34,11 +35,14 @@ import ifcopenshell.util.classification
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder
import ifcopenshell.util.space
import ifcopenshell.util.type
import ifcopenshell.util.unit
import numpy as np
import shapely
import shapely.affinity
import shapely.ops
from mathutils import Matrix, Vector
from natsort import natsorted
@@ -58,8 +62,52 @@ if TYPE_CHECKING:
BIMSpatialDecompositionProperties,
)
_GEOM_CACHE_TOKEN = 0
@bpy.app.handlers.persistent
def _bump_geom_cache_token(*args) -> None:
global _GEOM_CACHE_TOKEN
if len(args) >= 2:
depsgraph = args[1]
if depsgraph is not None and hasattr(depsgraph, "updates"):
if not any(
(getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False))
and hasattr(u, "id")
and isinstance(u.id, bpy.types.Object)
for u in depsgraph.updates
):
return
_GEOM_CACHE_TOKEN += 1
def install_geom_cache_handlers() -> None:
for hook in (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
):
if _bump_geom_cache_token not in hook:
hook.append(_bump_geom_cache_token)
def uninstall_geom_cache_handlers() -> None:
for hook in (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
):
try:
hook.remove(_bump_geom_cache_token)
except ValueError:
pass
class Spatial(bonsai.core.tool.Spatial):
_geom_cache: dict = {}
@classmethod
def get_spatial_props(cls) -> BIMSpatialDecompositionProperties:
return bpy.context.scene.BIMSpatialDecompositionProperties
@@ -755,29 +803,233 @@ class Spatial(bonsai.core.tool.Spatial):
# HERE STARTS SPATIAL TOOL
@classmethod
def get_or_build_geom_cache(cls) -> dict:
"""Build or return a cached dict of IFC element shapes for space generation.
The cache is keyed on ``_GEOM_CACHE_TOKEN`` which is bumped by a
``depsgraph_update_post`` handler when any Object geometry or transform
changes, and on undo/redo/load. This means the cache survives space
generations (which don't change Object geometry) but is correctly
invalidated when a user moves or edits a wall, slab, etc.
:return: ``{"shapes": {id: {"verts": ndarray, "faces": ndarray, "bottom_z": float, "top_z": float}}, "token": int}``
"""
global _GEOM_CACHE_TOKEN
cached = cls._geom_cache.get("current")
if cached and cached["token"] == _GEOM_CACHE_TOKEN:
return cached
ifc_file = tool.Ifc.get()
include = []
for ifc_class in ifcopenshell.util.space.BOUNDING_CLASSES + ifcopenshell.util.space.HEIGHT_DETECTION_CLASSES:
include.extend(ifc_file.by_type(ifc_class))
settings = ifcopenshell.geom.settings()
settings.set("disable-opening-subtractions", True)
settings.set("use-world-coords", True)
shapes = {}
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=include)
if iterator.initialize():
while True:
shape = iterator.get()
verts = ifcopenshell.util.shape.get_shape_vertices(shape, shape.geometry)
faces = ifcopenshell.util.shape.get_faces(shape.geometry)
zs = verts[:, 2]
shapes[shape.id] = {
"verts": verts,
"faces": faces,
"bottom_z": float(zs.min()),
"top_z": float(zs.max()),
}
if not iterator.next():
break
cache = {"shapes": shapes, "token": _GEOM_CACHE_TOKEN}
cls._geom_cache["current"] = cache
return cache
@classmethod
def is_bounding_class(cls, visible_element: ifcopenshell.entity_instance) -> bool:
for ifc_class in ["IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate"]:
for ifc_class in ifcopenshell.util.space.BOUNDING_CLASSES:
if visible_element.is_a(ifc_class):
return True
return False
@classmethod
def get_boundary_lines_from_ifc_elements(
cls,
cut_z: float,
) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
"""Generate boundary lines by bisecting IFC element geometry with a horizontal plane.
Uses the class-level geometry cache (parallel iterator) instead of
iterating Blender visible objects. Works without any Blender objects
being loaded.
:param cut_z: Z elevation of the cutting plane in world coordinates.
:return: (boundary_lines, bounding_elements)
"""
cache = cls.get_or_build_geom_cache()
return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z)
@classmethod
def get_space_polygon_from_context_visible_objects(
cls, x: float, y: float
) -> Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]]:
boundary_lines = cls.get_boundary_lines_from_context_visible_objects()
unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines))
closed_polygons = shapely.polygonize(unioned_boundaries.geoms)
if not closed_polygons:
return "NO POLYGONS FOUND"
space_polygon = None
for polygon in closed_polygons.geoms:
if shapely.contains_xy(polygon, x, y):
space_polygon = shapely.force_3d(polygon)
if space_polygon is None:
return "NO POLYGON FOR POINT"
return space_polygon
cls, x: float, y: float, container: Optional[ifcopenshell.entity_instance] = None
) -> tuple[
Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]],
list[ifcopenshell.entity_instance],
]:
props = tool.Model.get_model_props()
calculation_rl = props.rl3
if container is None:
container = tool.Root.get_default_container()
container_obj = tool.Ifc.get_object(container)
cut_z = container_obj.matrix_world.translation.z + calculation_rl
# Commit any moved visible bounding objects before reading IFC geometry,
# so the IFC-based cache uses the current Blender positions.
# Walls/roofs/slabs that affect the space footprint or height must be
# committed before the cache is rebuilt; otherwise the IFC geometry read by
# the iterator will be stale and a moved roof/slab will not be picked up.
affected_classes = ifcopenshell.util.space.BOUNDING_CLASSES + ifcopenshell.util.space.HEIGHT_DETECTION_CLASSES
for obj in bpy.context.visible_objects:
element = tool.Ifc.get_entity(obj)
if element is None or not any(element.is_a(c) for c in affected_classes):
continue
tool.Geometry.commit_placement_if_moved(obj)
cls._geom_cache.clear()
boundary_lines, bounding_elements = cls.get_boundary_lines_from_ifc_elements(cut_z)
polygon, _ = ifcopenshell.util.space.get_space_polygon(boundary_lines, x, y)
if isinstance(polygon, str):
return polygon, []
return polygon, bounding_elements
@classmethod
def get_auto_space_height(
cls,
space_polygon: shapely.Polygon,
base_z: float,
bounding_walls: list[ifcopenshell.entity_instance],
) -> Optional[float]:
"""Auto-detect space height from elements above using IFC geometry.
Delegates to :func:`ifcopenshell.util.space.get_auto_space_height`.
:param space_polygon: The space footprint polygon in world XY.
:param base_z: The space's base Z in world coordinates.
:param bounding_walls: List of IFC wall elements bounding the space.
:return: Detected height in SI (meters), or None if nothing found.
"""
cache = cls.get_or_build_geom_cache()
return ifcopenshell.util.space.get_auto_space_height(
tool.Ifc.get(), cache["shapes"], space_polygon, base_z, bounding_walls
)
@classmethod
def get_space_volume_strategy(
cls,
space_polygon: shapely.Polygon,
base_z: float,
bounding_walls: list[ifcopenshell.entity_instance],
container: Optional[ifcopenshell.entity_instance] = None,
) -> tuple[str, Optional[list], Optional[list]]:
"""Decide how to build the space volume (clipped extrusion or B-rep).
Rays are cast from the RL cut elevation (``container_z + props.rl3``), the
same level at which the space footprint polygon was found.
"""
ifc_file = tool.Ifc.get()
cache = cls.get_or_build_geom_cache()
start_z = None
if container is None:
container = tool.Root.get_default_container()
if container is not None:
container_obj = tool.Ifc.get_object(container)
props = tool.Model.get_model_props()
start_z = container_obj.matrix_world.translation.z + props.rl3
tree = ifcopenshell.geom.tree(ifc_file)
settings = ifcopenshell.geom.settings()
settings.set("disable-opening-subtractions", True)
settings.set("use-world-coords", True)
tree.add_file(ifc_file, settings)
return ifcopenshell.util.space.detect_space_volume_strategy(
ifc_file, cache["shapes"], tree, space_polygon, base_z, bounding_walls, start_z=start_z
)
@classmethod
def _get_or_create_body_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
"""Return the Model/Body/MODEL_VIEW context, creating one if absent."""
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
if context is not None:
return context
# Some subcontexts may not expose the inherited ContextType value, so also
# search by ContextIdentifier/TargetView directly.
for ctx in ifc_file.by_type("IfcGeometricRepresentationSubContext"):
if ctx.ContextIdentifier == "Body" and getattr(ctx, "TargetView", None) == "MODEL_VIEW":
return ctx
# Create a minimal context if none exists.
model_context = ifcopenshell.util.representation.get_context(ifc_file, "Model")
if model_context is None:
model_context = ifc_file.createIfcGeometricRepresentationContext(
ContextType="Model",
CoordinateSpaceDimension=3,
Precision=1e-5,
WorldCoordinateSystem=ifc_file.createIfcAxis2Placement3D(
ifc_file.createIfcCartesianPoint([0.0, 0.0, 0.0])
),
TrueNorth=ifc_file.createIfcDirection([0.0, 1.0, 0.0]),
)
return ifc_file.createIfcGeometricRepresentationSubContext(
ParentContext=model_context,
ContextIdentifier="Body",
TargetView="MODEL_VIEW",
ContextType="Model",
)
@classmethod
def _remove_existing_body_representations(
cls, element: ifcopenshell.entity_instance
) -> Optional[ifcopenshell.entity_instance]:
"""Remove every existing Body representation from an element.
Returns the context of the first removed representation, or None.
"""
ifc_file = tool.Ifc.get()
if element.Representation is None:
return None
body_reps = [r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"]
context = None
for rep in body_reps:
context = rep.ContextOfItems
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=rep)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=rep)
return context
@classmethod
def set_brep_representation_from_mesh(
cls,
obj: bpy.types.Object,
element: ifcopenshell.entity_instance,
item: ifcopenshell.entity_instance,
) -> None:
"""Assign a representation item (clipped solid or B-rep) to the element."""
ifc_file = tool.Ifc.get()
context = cls._remove_existing_body_representations(element)
if context is None:
context = cls._get_or_create_body_context(ifc_file)
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_body,
)
@classmethod
def debug_shape(cls, foo: shapely.Polygon) -> None:
@@ -810,7 +1062,9 @@ class Spatial(bonsai.core.tool.Spatial):
bpy.context.view_layer.update()
@classmethod
def get_boundary_lines_from_context_visible_objects(cls) -> list[shapely.LineString]:
def get_boundary_lines_from_context_visible_objects(
cls,
) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
props = tool.Model.get_model_props()
calculation_rl = props.rl3
container = tool.Root.get_default_container()
@@ -818,6 +1072,7 @@ class Spatial(bonsai.core.tool.Spatial):
cut_point = container_obj.matrix_world.translation.copy() + Vector((0, 0, calculation_rl))
cut_normal = Vector((0, 0, 1))
boundary_lines = []
bounding_elements = []
for obj in bpy.context.visible_objects:
visible_element = tool.Ifc.get_entity(obj)
@@ -831,6 +1086,7 @@ class Spatial(bonsai.core.tool.Spatial):
):
continue
bounding_elements.append(visible_element)
old_mesh = obj.data
assert isinstance(old_mesh, bpy.types.Mesh)
if visible_element.HasOpenings:
@@ -870,7 +1126,7 @@ class Spatial(bonsai.core.tool.Spatial):
start, end = tool.Drawing.extend_line(start, end, 0.05)
boundary_lines.append(shapely.LineString([start, end]))
return boundary_lines
return boundary_lines, bounding_elements
@classmethod
def get_gross_mesh_from_element(cls, visible_element: ifcopenshell.entity_instance) -> bpy.types.Mesh:
@@ -1086,13 +1342,9 @@ class Spatial(bonsai.core.tool.Spatial):
curve = builder.polyline(coords_2d, closed=True)
item = builder.extrude(curve, magnitude=depth_ifc)
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if old_body:
context = old_body.ContextOfItems
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
else:
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
context = cls._remove_existing_body_representations(element)
if context is None:
context = cls._get_or_create_body_context(ifc_file)
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
@@ -1111,13 +1363,104 @@ class Spatial(bonsai.core.tool.Spatial):
poly: Polygon,
h: float,
polygon_is_si: bool = True,
bounding_walls: Optional[list[ifcopenshell.entity_instance]] = None,
container: Optional[ifcopenshell.entity_instance] = None,
) -> None:
"""Create or replace the IFC body representation of a space from a polygon.
:param h: The height in SI (meters).
"""
# Remove collinear points introduced by the mesh bisection so the
# footprint polygon has a minimal vertex count.
poly = poly.simplify(0, preserve_topology=True)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
ifc_file = tool.Ifc.get()
x, y, z = obj.matrix_world.translation
origin = obj.matrix_world.translation # Blender SI
# The space builders expect base_z and polygon in SI (world) units.
base_z = z
poly_si = poly if polygon_is_si else shapely.affinity.scale(poly, unit_scale, unit_scale, origin=(0, 0))
# Ensure the IFC entity has an ObjectPlacement matching the Blender object,
# so the generated representation is in the correct local coordinate system.
bpy.context.view_layer.update()
matrix = np.array(obj.matrix_world)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=element,
matrix=matrix,
is_si=True,
)
for b in list(element.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc_file, b)
cls._remove_existing_body_representations(element)
if cls.get_spatial_props().force_space_height:
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
return
if bounding_walls is None:
bounding_walls = []
if container is None:
container = ifcopenshell.util.element.get_container(element)
if container is not None:
for wall in ifc_file.by_type("IfcWall"):
if wall in ifcopenshell.util.element.get_decomposition(container):
bounding_walls.append(wall)
# Detect planes in world SI (same coordinate system as the geom cache).
strategy, top_planes, bottom_planes = cls.get_space_volume_strategy(poly_si, base_z, bounding_walls, container)
# Build the geometry in the space's local coordinate system so the IFC
# representation is relative to the object's ObjectPlacement.
# Use the full inverse of the object's placement matrix so rotated spaces
# keep the correct footprint orientation.
matrix_inv = np.array(obj.matrix_world.inverted())
# shapely.affine_transform expects [a, b, d, e, xoff, yoff]
# where x' = a*x + b*y + xoff, y' = d*x + e*y + yoff.
affine_params = [
matrix_inv[0, 0],
matrix_inv[0, 1],
matrix_inv[1, 0],
matrix_inv[1, 1],
matrix_inv[0, 3],
matrix_inv[1, 3],
]
local_poly_si = shapely.affinity.affine_transform(poly_si, affine_params)
local_base_z = base_z - origin.z
def localize_plane(plane):
point, normal = plane
local_point = matrix_inv @ np.array([*point, 1.0])
rotation_inv = matrix_inv[:3, :3]
local_normal = rotation_inv @ np.array(normal)
local_normal = local_normal / np.linalg.norm(local_normal)
return (local_point[:3], local_normal)
local_top_planes = [localize_plane(p) for p in (top_planes or [])]
local_bottom_planes = [localize_plane(p) for p in (bottom_planes or [])]
if strategy == "EXTRUDE_CLIP" and top_planes:
item = ifcopenshell.util.space.build_extruded_clipped_space(
ifc_file, local_poly_si, local_base_z, local_top_planes, local_bottom_planes
)
cls.set_brep_representation_from_mesh(obj, element, item)
else:
shapes = cls.get_or_build_geom_cache()["shapes"]
local_shapes = {}
for shape_id, shape_data in shapes.items():
local_shape_data = dict(shape_data)
local_shape_data["top_z"] = shape_data["top_z"] - origin.z
local_shape_data["bottom_z"] = shape_data["bottom_z"] - origin.z
local_shapes[shape_id] = local_shape_data
item = ifcopenshell.util.space.build_brep_space(
ifc_file, element, local_shapes, local_poly_si, local_base_z
)
if item is None:
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
else:
cls.set_brep_representation_from_mesh(obj, element, item)
@classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
+3 -10
View File
@@ -245,16 +245,9 @@ class Wall(bonsai.core.tool.Wall):
@classmethod
def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
"""Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
connecting a slab to this wall the rel kind ``extend_walls_to_underside``
creates. Walks ``wall.ConnectedFrom`` because the slab is the relating
side of the TOP rel."""
for rel in getattr(wall, "ConnectedFrom", []) or ():
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
continue
slab = rel.RelatingElement
if slab is None:
continue
yield slab, rel
connecting a slab to this wall. Delegates to
:func:`ifcopenshell.util.element.iter_top_connections`."""
yield from ifcopenshell.util.element.iter_top_connections(wall)
@classmethod
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
@@ -76,6 +76,10 @@ Release
Notes:
- Typically all packages are released at once using the same version schema
- ``VERSION`` uses Python/PEP 440-compatible spelling. For example, an alpha
release may be ``0.9.0alpha0`` (canonicalized to ``0.9.0a0``); build scripts
derive numeric-only and SemVer forms such as ``0.9.0`` and
``0.9.0-alpha0`` where required.
- The ``README.md`` badges can serve as a visual reference for what versions have been released
- Corrective Release (if needed after a standard release):
+1 -1
View File
@@ -33,7 +33,7 @@ exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
lint.extend-select = [
"F401", # unused imports
"unused-import", # unused imports
]
[tool.ruff.lint.isort]
+1
View File
@@ -42,6 +42,7 @@ markers =
type
unit
void
wall
web
# Provide plugins explicitly, so it will be possible run tests with PYTEST_DISABLE_PLUGIN_AUTOLOAD.
+4 -4
View File
@@ -45,10 +45,10 @@ for dep in dependencies:
subprocess.check_call(command + [dep])
try:
import pygments # noqa: F401
import pytest # noqa: F401
import pytest_bdd # noqa: F401
import pytest_blender # noqa: F401
import pygments # ruff: ignore[unused-import]
import pytest # ruff: ignore[unused-import]
import pytest_bdd # ruff: ignore[unused-import]
import pytest_blender # ruff: ignore[unused-import]
print("Test dependency installation was successful!")
except Exception as e:
+21 -24
View File
@@ -163,32 +163,29 @@ class Drawer:
# self.svg_settings.set_deflection_tolerance(0.0001)
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
self.serialiser_settings.set("svg-without-storeys", True)
self.serialiser_settings.set("svg-write-poly", True)
self.serialiser_settings.set("svg-poly", True)
self.svg_settings.set("svg-without-storeys", True)
self.svg_settings.set("svg-write-poly", True)
self.svg_settings.set("svg-poly", True)
# Objects with more than these edges are rendered as wireframe instead of HLR for optimisation
self.serialiser_settings.set("profile-threshold", 10000)
self.serialiser_settings.set("svg-xmlns", True)
self.serialiser_settings.set("svg-project", True)
self.serialiser_settings.set("auto-elevation", False)
self.serialiser_settings.set("auto-section", False)
self.serialiser_settings.set("print-space-names", False)
self.serialiser_settings.set("print-space-areas", False)
self.serialiser_settings.set("door-arcs", False)
self.serialiser_settings.set("svg-no-css", True)
self.serialiser_settings.set("elevation-ref-guid", self.camera_element.GlobalId)
self.serialiser_settings.set("scale", "1/50")
self.serialiser_settings.set("svg-subtract-before", "always")
self.serialiser_settings.set("svg-prefilter", True) # See #3359
# self.serialiser_settings.set("svg-prefilter", False) # See #3359
self.serialiser_settings.set("svg-unify-inputs", True)
self.serialiser_settings.set("svg-segment-projection", True)
self.svg_settings.set("profile-threshold", 10000)
self.svg_settings.set("svg-xmlns", True)
self.svg_settings.set("svg-project", True)
self.svg_settings.set("auto-elevation", False)
self.svg_settings.set("auto-section", False)
self.svg_settings.set("print-space-names", False)
self.svg_settings.set("print-space-areas", False)
self.svg_settings.set("door-arcs", False)
self.svg_settings.set("svg-no-css", True)
self.svg_settings.set("elevation-ref-guid", self.camera_element.GlobalId)
self.svg_settings.set("scale", "1/50")
self.svg_settings.set("svg-subtract-before", "always")
self.svg_settings.set("svg-prefilter", True) # See #3359
# self.svg_settings.set("svg-prefilter", False) # See #3359
self.svg_settings.set("svg-unify-inputs", True)
self.svg_settings.set("svg-segment-projection", True)
if target_view == "REFLECTED_PLAN_VIEW":
self.serialiser_settings.set("svg-mirror-y", True)
self.serialiser = ifcopenshell.geom.serializers.svg(
self.svg_buffer, self.svg_settings, self.serialiser_settings
)
self.svg_settings.set("svg-mirror-y", True)
self.serialiser = ifcopenshell.geom.serializers.svg(self.svg_buffer, self.svg_settings)
self.serialiser.setFile(ifc)
@@ -72,12 +72,12 @@ Scenario: Add classification reference - object
And I press "bim.add_classification"
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.change_classification_level(parent_id={classification})"
And the variable "reference" is "{classification_ifc}.by_type('IfcClassificationReference')[0].id()"
When I press "bim.add_classification_reference(reference={reference}, obj='IfcWallType/Cube', obj_type='Object')"
When I press "bim.add_classification_reference(reference={reference}, obj='IfcWall/Cube', obj_type='Object')"
Then nothing happens
Scenario: Change classification level
@@ -88,8 +88,8 @@ Scenario: Change classification level
And I press "bim.add_classification"
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.change_classification_level(parent_id={classification})"
And the variable "reference" is "{classification_ifc}.by_type('IfcClassificationReference')[0].id()"
@@ -104,8 +104,8 @@ Scenario: Disable editing classification references
And I press "bim.add_classification"
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.change_classification_level(parent_id={classification})"
When I press "bim.disable_editing_classification_references"
@@ -119,12 +119,12 @@ Scenario: Enable editing classification reference
And I press "bim.add_classification"
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.change_classification_level(parent_id={classification})"
And the variable "reference" is "{classification_ifc}.by_type('IfcClassificationReference')[0].id()"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWallType/Cube', obj_type='Object')"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWall/Cube', obj_type='Object')"
And the variable "reference" is "{ifc}.by_type('IfcClassificationReference')[0].id()"
When I press "bim.enable_editing_classification_reference(reference={reference})"
Then nothing happens
@@ -137,12 +137,12 @@ Scenario: Disable editing classification reference
And I press "bim.add_classification"
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.change_classification_level(parent_id={classification})"
And the variable "reference" is "{classification_ifc}.by_type('IfcClassificationReference')[0].id()"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWallType/Cube', obj_type='Object')"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWall/Cube', obj_type='Object')"
And the variable "reference" is "{ifc}.by_type('IfcClassificationReference')[0].id()"
And I press "bim.enable_editing_classification_reference(reference={reference})"
When I press "bim.disable_editing_classification_reference"
@@ -156,15 +156,15 @@ Scenario: Remove classification reference - object
And I press "bim.add_classification"
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.change_classification_level(parent_id={classification})"
And the variable "reference" is "{classification_ifc}.by_type('IfcClassificationReference')[0].id()"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWallType/Cube', obj_type='Object')"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWall/Cube', obj_type='Object')"
And the variable "reference" is "{ifc}.by_type('IfcClassificationReference')[0].id()"
And I press "bim.enable_editing_classification_reference(reference={reference})"
When I press "bim.remove_classification_reference(reference={reference}, obj='IfcWallType/Cube', obj_type='Object')"
When I press "bim.remove_classification_reference(reference={reference}, obj='IfcWall/Cube', obj_type='Object')"
Then nothing happens
Scenario: Edit classification reference
@@ -175,12 +175,12 @@ Scenario: Edit classification reference
And I press "bim.add_classification"
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.change_classification_level(parent_id={classification})"
And the variable "reference" is "{classification_ifc}.by_type('IfcClassificationReference')[0].id()"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWallType/Cube', obj_type='Object')"
And I press "bim.add_classification_reference(reference={reference}, obj='IfcWall/Cube', obj_type='Object')"
And the variable "reference" is "{ifc}.by_type('IfcClassificationReference')[0].id()"
And I press "bim.enable_editing_classification_reference(reference={reference})"
When I press "bim.edit_classification_reference"
@@ -185,6 +185,7 @@ Scenario: Update representation - updating a layered extrusion
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -213,6 +214,7 @@ Scenario: Update representation - updating a profiled extrusion
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -416,6 +418,7 @@ Scenario: Override duplicate move - copying a layered extrusion
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -447,6 +450,7 @@ Scenario: Override duplicate move - copying a profiled extrusion
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -121,6 +121,7 @@ Scenario: Assign material - material layer set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
When I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
Then the object "IfcWallType/Empty" does not have the material "Default"
@@ -134,6 +135,7 @@ Scenario: Unassign material - material layer set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
When I press "bim.unassign_material"
@@ -155,6 +157,7 @@ Scenario: Unassign material - removing inherited material
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
@@ -181,6 +184,7 @@ Scenario: Enable editing assigned material - material layer set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
When I press "bim.enable_editing_assigned_material"
@@ -200,6 +204,7 @@ Scenario: Disable editing assigned material - material layer set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -220,6 +225,7 @@ Scenario: Edit assigned material - material layer set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -235,6 +241,7 @@ Scenario: Assign material - material profile set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
When I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
Then the object "IfcWallType/Empty" does not have the material "Default"
@@ -248,6 +255,7 @@ Scenario: Unassign material - material profile set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
When I press "bim.unassign_material"
@@ -267,6 +275,7 @@ Scenario: Enable editing assigned material - material profile set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
When I press "bim.enable_editing_assigned_material"
@@ -286,6 +295,7 @@ Scenario: Disable editing assigned material - material profile set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -306,6 +316,7 @@ Scenario: Edit assigned material - material profile set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -454,6 +465,7 @@ Scenario: Add material set layer
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -477,6 +489,7 @@ Scenario: Remove material set layer
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
+139 -97
View File
@@ -314,6 +314,12 @@ Scenario: Load project elements - auto offset of cartesian points
Then the object "IfcBuildingElementProxy/NAME" is at "0,0,0"
Scenario: Load project elements - all georeferencing coordinate situations - disabled false origin mode
# D, G and J have their geometry far from their placement, so each is
# shifted onto one of its own verts to keep its precision. Which vert that
# is comes from the geometry kernel and has changed before, so these assert
# that the origin is on a vert rather than which one, and name verts rather
# than origins. In automatic mode the model origin is picked the same way
# and everything moves with it, so there they are relative to it.
Given an empty Blender session
And I press "bim.load_project(filepath='{cwd}/test/files/geolocation.ifc', is_advanced=True)"
When I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
@@ -326,13 +332,19 @@ Scenario: Load project elements - all georeferencing coordinate situations - dis
And the object "IfcActuator/A" is at "7,3,0"
And the object "IfcActuator/B" is at "6,1,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "13,4,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "13,4,-1" at map coordinates "13000,4000,-1000"
And the object "IfcActuator/D" has a vert at "15,2,1" at map coordinates "15000,2000,1000"
And the object "IfcActuator/E" is at "6,3,0"
And the object "IfcActuator/F" is at "3,3,0"
And the object "IfcActuator/G" is at "15,6,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "15,6,-1" at map coordinates "15000,6000,-1000"
And the object "IfcActuator/G" has a vert at "17,4,1" at map coordinates "17000,4000,1000"
And the object "IfcActuator/H" is at "9,2,0"
And the object "IfcActuator/I" is at "3,3,0"
And the object "IfcActuator/J" is at "11,3,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "11,3,-1" at map coordinates "11000,3000,-1000"
And the object "IfcActuator/J" has a vert at "13,1,1" at map coordinates "13000,1000,1000"
And the object "IfcActuator/K" is at "10,0,0"
Scenario: Load project elements - all georeferencing coordinate situations - automatic false origin mode
@@ -342,24 +354,27 @@ Scenario: Load project elements - all georeferencing coordinate situations - aut
When I set "scene.BIMProjectProperties.distance_limit" to "5"
And I press "bim.load_project_elements"
Then "scene.BIMGeoreferenceProperties.has_blender_offset" is "True"
And "scene.BIMGeoreferenceProperties.model_origin" is "13000.0,4000.0,-1000.0"
And "scene.BIMGeoreferenceProperties.blender_offset_x" is "13000.0"
And "scene.BIMGeoreferenceProperties.blender_offset_y" is "4000.0"
And "scene.BIMGeoreferenceProperties.blender_offset_z" is "-1000.0"
And the model origin is on an object vertex
And the object "IfcSite/My Site" is at "0,0,0"
And the object "IfcBuilding/My Building" is at "0,0,0"
And the object "IfcBuildingStorey/My Storey" is at "0,0,0"
And the object "IfcActuator/A" is at "-6,-1,1"
And the object "IfcActuator/B" is at "-7,-3,1"
And the object "IfcActuator/A" is at "7,3,0" relative to the model origin at map coordinates "7000,3000,0"
And the object "IfcActuator/B" is at "6,1,0" relative to the model origin at map coordinates "6000,1000,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "0,0,0"
And the object "IfcActuator/E" is at "-7,-1,1"
And the object "IfcActuator/F" is at "-10,-1,1"
And the object "IfcActuator/G" is at "2,2,0"
And the object "IfcActuator/H" is at "-4,-2,1"
And the object "IfcActuator/I" is at "-10,-1,1"
And the object "IfcActuator/J" is at "-2,-1,0"
And the object "IfcActuator/K" is at "-3,-4,1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "13,4,-1" relative to the model origin at map coordinates "13000,4000,-1000"
And the object "IfcActuator/D" has a vert at "15,2,1" relative to the model origin at map coordinates "15000,2000,1000"
And the object "IfcActuator/E" is at "6,3,0" relative to the model origin at map coordinates "6000,3000,0"
And the object "IfcActuator/F" is at "3,3,0" relative to the model origin at map coordinates "3000,3000,0"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "15,6,-1" relative to the model origin at map coordinates "15000,6000,-1000"
And the object "IfcActuator/G" has a vert at "17,4,1" relative to the model origin at map coordinates "17000,4000,1000"
And the object "IfcActuator/H" is at "9,2,0" relative to the model origin at map coordinates "9000,2000,0"
And the object "IfcActuator/I" is at "3,3,0" relative to the model origin at map coordinates "3000,3000,0"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "11,3,-1" relative to the model origin at map coordinates "11000,3000,-1000"
And the object "IfcActuator/J" has a vert at "13,1,1" relative to the model origin at map coordinates "13000,1000,1000"
And the object "IfcActuator/K" is at "10,0,0" relative to the model origin at map coordinates "10000,0,0"
Scenario: Load project elements - all georeferencing coordinate situations - manual false origin mode
Given an empty Blender session
@@ -379,23 +394,20 @@ Scenario: Load project elements - all georeferencing coordinate situations - man
And the object "IfcActuator/A" is at "-3,3,0"
And the object "IfcActuator/B" is at "-4,1,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "3,4,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "3,4,-1" at map coordinates "13000,4000,-1000"
And the object "IfcActuator/D" has a vert at "5,2,1" at map coordinates "15000,2000,1000"
And the object "IfcActuator/E" is at "-4,3,0"
And the object "IfcActuator/F" is at "-7,3,0"
And the object "IfcActuator/G" is at "5,6,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "5,6,-1" at map coordinates "15000,6000,-1000"
And the object "IfcActuator/G" has a vert at "7,4,1" at map coordinates "17000,4000,1000"
And the object "IfcActuator/H" is at "-1,2,0"
And the object "IfcActuator/I" is at "-7,3,0"
And the object "IfcActuator/J" is at "1,3,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "1,3,-1" at map coordinates "11000,3000,-1000"
And the object "IfcActuator/J" has a vert at "3,1,1" at map coordinates "13000,1000,1000"
And the object "IfcActuator/K" is at "0,0,0"
And the object "IfcActuator/D" has a cartesian point offset of "31,4,-1"
And the object "IfcActuator/G" has a cartesian point offset of "-25,6,-1"
And the object "IfcActuator/J" has a cartesian point offset of "11,3,-1"
And the object "IfcActuator/D" has a vertex at "3,2,-1"
And the object "IfcActuator/D" has a vertex at "5,2,-1"
And the object "IfcActuator/G" has a vertex at "5,4,-1"
And the object "IfcActuator/G" has a vertex at "7,4,-1"
And the object "IfcActuator/J" has a vertex at "1,1,-1"
And the object "IfcActuator/J" has a vertex at "3,1,-1"
Scenario: Load project elements - all georeferencing coordinate situations with an offset site - disabled false origin mode
Given an empty Blender session
@@ -410,13 +422,19 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "5.985,14.71,0"
And the object "IfcActuator/B" is at "5.5367,12.519,0"
And the object "IfcActuator/C" is at "0,10,0"
And the object "IfcActuator/D" is at "11.522,17.228,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "11.5218,17.2284,-1" at map coordinates "11521.758,17228.35,-1000"
And the object "IfcActuator/D" has a vert at "13.9712,15.8141,1" at map coordinates "13971.246,15814.136,1000"
And the object "IfcActuator/E" is at "5.0191,14.451,0"
And the object "IfcActuator/F" is at "2.1213,13.674,0"
And the object "IfcActuator/G" is at "12.936,19.678,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "12.936,19.6778,-1" at map coordinates "12935.975,19677.841,-1000"
And the object "IfcActuator/G" has a vert at "15.3855,18.2636,1" at map coordinates "15385.465,18263.627,1000"
And the object "IfcActuator/H" is at "8.1757,14.261,0"
And the object "IfcActuator/I" is at "2.1213,13.674,0"
And the object "IfcActuator/J" is at "9.8487,15.745,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "9.8487,15.7448,-1" at map coordinates "9848.726,15744.786,-1000"
And the object "IfcActuator/J" has a vert at "12.2982,14.3306,1" at map coordinates "12298.216,14330.573,1000"
And the object "IfcActuator/K" is at "9.6593,12.588,0"
Scenario: Load project elements - all georeferencing coordinate situations with an offset site - automatic false origin mode
@@ -436,13 +454,19 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "7,3,0"
And the object "IfcActuator/B" is at "6,1,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "13,4,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "13,4,-1" at map coordinates "11521.758,17228.35,-1000"
And the object "IfcActuator/D" has a vert at "15,2,1" at map coordinates "13971.246,15814.136,1000"
And the object "IfcActuator/E" is at "6,3,0"
And the object "IfcActuator/F" is at "3,3,0"
And the object "IfcActuator/G" is at "15,6,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "15,6,-1" at map coordinates "12935.975,19677.841,-1000"
And the object "IfcActuator/G" has a vert at "17,4,1" at map coordinates "15385.465,18263.627,1000"
And the object "IfcActuator/H" is at "9,2,0"
And the object "IfcActuator/I" is at "3,3,0"
And the object "IfcActuator/J" is at "11,3,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "11,3,-1" at map coordinates "9848.726,15744.786,-1000"
And the object "IfcActuator/J" has a vert at "13,1,1" at map coordinates "12298.216,14330.573,1000"
And the object "IfcActuator/K" is at "10,0,0"
Scenario: Load project elements - all georeferencing coordinate situations with an offset site - manual false origin mode
@@ -463,23 +487,20 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "5.985,4.71,0"
And the object "IfcActuator/B" is at "5.5367,2.519,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "11.522,7.228,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "11.5218,7.2284,-1" at map coordinates "11521.758,17228.35,-1000"
And the object "IfcActuator/D" has a vert at "13.9712,5.8141,1" at map coordinates "13971.246,15814.136,1000"
And the object "IfcActuator/E" is at "5.0191,4.451,0"
And the object "IfcActuator/F" is at "2.1213,3.674,0"
And the object "IfcActuator/G" is at "12.936,9.678,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "12.936,9.6778,-1" at map coordinates "12935.975,19677.841,-1000"
And the object "IfcActuator/G" has a vert at "15.3855,8.2636,1" at map coordinates "15385.465,18263.627,1000"
And the object "IfcActuator/H" is at "8.1757,4.261,0"
And the object "IfcActuator/I" is at "2.1213,3.674,0"
And the object "IfcActuator/J" is at "9.8487,5.745,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "9.8487,5.7448,-1" at map coordinates "9848.726,15744.786,-1000"
And the object "IfcActuator/J" has a vert at "12.2982,4.3306,1" at map coordinates "12298.216,14330.573,1000"
And the object "IfcActuator/K" is at "9.6593,2.588,0"
And the object "IfcActuator/D" has a cartesian point offset of "31,4,-1"
And the object "IfcActuator/G" has a cartesian point offset of "-25,6,-1"
And the object "IfcActuator/J" has a cartesian point offset of "11,3,-1"
And the object "IfcActuator/D" has a vertex at "12.039,5.296,-1"
And the object "IfcActuator/D" has a vertex at "13.971,5.814,-1"
And the object "IfcActuator/G" has a vertex at "13.454,7.746,-1"
And the object "IfcActuator/G" has a vertex at "15.385,8.264,-1"
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
Scenario: Load project elements - all georeferencing coordinate situations with an offset site - manual false origin mode - with custom project north
Given an empty Blender session
@@ -500,13 +521,19 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "7,3,0"
And the object "IfcActuator/B" is at "6,1,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "13,4,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "13,4,-1" at map coordinates "11521.758,17228.35,-1000"
And the object "IfcActuator/D" has a vert at "15,2,1" at map coordinates "13971.246,15814.136,1000"
And the object "IfcActuator/E" is at "6,3,0"
And the object "IfcActuator/F" is at "3,3,0"
And the object "IfcActuator/G" is at "15,6,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "15,6,-1" at map coordinates "12935.975,19677.841,-1000"
And the object "IfcActuator/G" has a vert at "17,4,1" at map coordinates "15385.465,18263.627,1000"
And the object "IfcActuator/H" is at "9,2,0"
And the object "IfcActuator/I" is at "3,3,0"
And the object "IfcActuator/J" is at "11,3,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "11,3,-1" at map coordinates "9848.726,15744.786,-1000"
And the object "IfcActuator/J" has a vert at "13,1,1" at map coordinates "12298.216,14330.573,1000"
And the object "IfcActuator/K" is at "10,0,0"
Scenario: Load project elements - all georeferencing coordinate situations with a map conversion - disabled false origin mode (this should be identical to the situation with no map conversion)
@@ -522,13 +549,19 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "7,3,0"
And the object "IfcActuator/B" is at "6,1,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "13,4,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "13,4,-1" at map coordinates "28000,4000,-1000"
And the object "IfcActuator/D" has a vert at "15,2,1" at map coordinates "30000,2000,1000"
And the object "IfcActuator/E" is at "6,3,0"
And the object "IfcActuator/F" is at "3,3,0"
And the object "IfcActuator/G" is at "15,6,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "15,6,-1" at map coordinates "30000,6000,-1000"
And the object "IfcActuator/G" has a vert at "17,4,1" at map coordinates "32000,4000,1000"
And the object "IfcActuator/H" is at "9,2,0"
And the object "IfcActuator/I" is at "3,3,0"
And the object "IfcActuator/J" is at "11,3,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "11,3,-1" at map coordinates "26000,3000,-1000"
And the object "IfcActuator/J" has a vert at "13,1,1" at map coordinates "28000,1000,1000"
And the object "IfcActuator/K" is at "10,0,0"
Scenario: Load project elements - all georeferencing coordinate situations with a map conversion - automatic false origin mode (this should affect the Blender eastings and northings, which is now different to the Blender offset XYZ, but is otherwise identical to the non-map conversion variant)
@@ -538,24 +571,27 @@ Scenario: Load project elements - all georeferencing coordinate situations with
When I set "scene.BIMProjectProperties.distance_limit" to "5"
And I press "bim.load_project_elements"
Then "scene.BIMGeoreferenceProperties.has_blender_offset" is "True"
And "scene.BIMGeoreferenceProperties.model_origin" is "28000.0,4000.0,-1000.0"
And "scene.BIMGeoreferenceProperties.blender_offset_x" is "13000.0"
And "scene.BIMGeoreferenceProperties.blender_offset_y" is "4000.0"
And "scene.BIMGeoreferenceProperties.blender_offset_z" is "-1000.0"
And the model origin is on an object vertex
And the object "IfcSite/My Site" is at "0,0,0"
And the object "IfcBuilding/My Building" is at "0,0,0"
And the object "IfcBuildingStorey/My Storey" is at "0,0,0"
And the object "IfcActuator/A" is at "-6,-1,1"
And the object "IfcActuator/B" is at "-7,-3,1"
And the object "IfcActuator/A" is at "22,3,0" relative to the model origin at map coordinates "22000,3000,0"
And the object "IfcActuator/B" is at "21,1,0" relative to the model origin at map coordinates "21000,1000,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "0,0,0"
And the object "IfcActuator/E" is at "-7,-1,1"
And the object "IfcActuator/F" is at "-10,-1,1"
And the object "IfcActuator/G" is at "2,2,0"
And the object "IfcActuator/H" is at "-4,-2,1"
And the object "IfcActuator/I" is at "-10,-1,1"
And the object "IfcActuator/J" is at "-2,-1,0"
And the object "IfcActuator/K" is at "-3,-4,1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "28,4,-1" relative to the model origin at map coordinates "28000,4000,-1000"
And the object "IfcActuator/D" has a vert at "30,2,1" relative to the model origin at map coordinates "30000,2000,1000"
And the object "IfcActuator/E" is at "21,3,0" relative to the model origin at map coordinates "21000,3000,0"
And the object "IfcActuator/F" is at "18,3,0" relative to the model origin at map coordinates "18000,3000,0"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "30,6,-1" relative to the model origin at map coordinates "30000,6000,-1000"
And the object "IfcActuator/G" has a vert at "32,4,1" relative to the model origin at map coordinates "32000,4000,1000"
And the object "IfcActuator/H" is at "24,2,0" relative to the model origin at map coordinates "24000,2000,0"
And the object "IfcActuator/I" is at "18,3,0" relative to the model origin at map coordinates "18000,3000,0"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "26,3,-1" relative to the model origin at map coordinates "26000,3000,-1000"
And the object "IfcActuator/J" has a vert at "28,1,1" relative to the model origin at map coordinates "28000,1000,1000"
And the object "IfcActuator/K" is at "25,0,0" relative to the model origin at map coordinates "25000,0,0"
Scenario: Load project elements - all georeferencing coordinate situations with a map conversion - manual false origin mode (this should affect the Blender eastings and northings, which is now different to the Blender offset XYZ, but is otherwise identical to the non-map conversion variant)
Given an empty Blender session
@@ -575,23 +611,20 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "-3,3,0"
And the object "IfcActuator/B" is at "-4,1,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "3,4,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "3,4,-1" at map coordinates "28000,4000,-1000"
And the object "IfcActuator/D" has a vert at "5,2,1" at map coordinates "30000,2000,1000"
And the object "IfcActuator/E" is at "-4,3,0"
And the object "IfcActuator/F" is at "-7,3,0"
And the object "IfcActuator/G" is at "5,6,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "5,6,-1" at map coordinates "30000,6000,-1000"
And the object "IfcActuator/G" has a vert at "7,4,1" at map coordinates "32000,4000,1000"
And the object "IfcActuator/H" is at "-1,2,0"
And the object "IfcActuator/I" is at "-7,3,0"
And the object "IfcActuator/J" is at "1,3,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "1,3,-1" at map coordinates "26000,3000,-1000"
And the object "IfcActuator/J" has a vert at "3,1,1" at map coordinates "28000,1000,1000"
And the object "IfcActuator/K" is at "0,0,0"
And the object "IfcActuator/D" has a cartesian point offset of "31,4,-1"
And the object "IfcActuator/G" has a cartesian point offset of "-25,6,-1"
And the object "IfcActuator/J" has a cartesian point offset of "11,3,-1"
And the object "IfcActuator/D" has a vertex at "3,2,-1"
And the object "IfcActuator/D" has a vertex at "5,2,-1"
And the object "IfcActuator/G" has a vertex at "5,4,-1"
And the object "IfcActuator/G" has a vertex at "7,4,-1"
And the object "IfcActuator/J" has a vertex at "1,1,-1"
And the object "IfcActuator/J" has a vertex at "3,1,-1"
Scenario: Load project elements - all georeferencing coordinate situations with map conversion and an offset site - disabled false origin mode
Given an empty Blender session
@@ -606,13 +639,19 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "5.985,14.71,0"
And the object "IfcActuator/B" is at "5.5367,12.519,0"
And the object "IfcActuator/C" is at "0,10,0"
And the object "IfcActuator/D" is at "11.522,17.228,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "11.5218,17.2284,-1" at map coordinates "26521.758,17228.35,-1000"
And the object "IfcActuator/D" has a vert at "13.9712,15.8141,1" at map coordinates "28971.246,15814.136,1000"
And the object "IfcActuator/E" is at "5.0191,14.451,0"
And the object "IfcActuator/F" is at "2.1213,13.674,0"
And the object "IfcActuator/G" is at "12.936,19.678,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "12.936,19.6778,-1" at map coordinates "27935.975,19677.841,-1000"
And the object "IfcActuator/G" has a vert at "15.3855,18.2636,1" at map coordinates "30385.465,18263.627,1000"
And the object "IfcActuator/H" is at "8.1757,14.261,0"
And the object "IfcActuator/I" is at "2.1213,13.674,0"
And the object "IfcActuator/J" is at "9.8487,15.745,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "9.8487,15.7448,-1" at map coordinates "24848.726,15744.786,-1000"
And the object "IfcActuator/J" has a vert at "12.2982,14.3306,1" at map coordinates "27298.216,14330.573,1000"
And the object "IfcActuator/K" is at "9.6593,12.588,0"
Scenario: Load project elements - all georeferencing coordinate situations with map conversion and an offset site - automatic false origin mode
@@ -632,13 +671,19 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "7,3,0"
And the object "IfcActuator/B" is at "6,1,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "13,4,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "13,4,-1" at map coordinates "26521.758,17228.35,-1000"
And the object "IfcActuator/D" has a vert at "15,2,1" at map coordinates "28971.246,15814.136,1000"
And the object "IfcActuator/E" is at "6,3,0"
And the object "IfcActuator/F" is at "3,3,0"
And the object "IfcActuator/G" is at "15,6,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "15,6,-1" at map coordinates "27935.975,19677.841,-1000"
And the object "IfcActuator/G" has a vert at "17,4,1" at map coordinates "30385.465,18263.627,1000"
And the object "IfcActuator/H" is at "9,2,0"
And the object "IfcActuator/I" is at "3,3,0"
And the object "IfcActuator/J" is at "11,3,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "11,3,-1" at map coordinates "24848.726,15744.786,-1000"
And the object "IfcActuator/J" has a vert at "13,1,1" at map coordinates "27298.216,14330.573,1000"
And the object "IfcActuator/K" is at "10,0,0"
Scenario: Load project elements - all georeferencing coordinate situations with map conversion and an offset site - manual false origin mode
@@ -659,23 +704,20 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/A" is at "5.985,4.71,0"
And the object "IfcActuator/B" is at "5.5367,2.519,0"
And the object "IfcActuator/C" is at "0,0,0"
And the object "IfcActuator/D" is at "11.522,7.228,-1"
And the object "IfcActuator/D" has its origin on a vertex
And the object "IfcActuator/D" has a vert at "11.5218,7.2284,-1" at map coordinates "26521.758,17228.35,-1000"
And the object "IfcActuator/D" has a vert at "13.9712,5.8141,1" at map coordinates "28971.246,15814.136,1000"
And the object "IfcActuator/E" is at "5.0191,4.451,0"
And the object "IfcActuator/F" is at "2.1213,3.674,0"
And the object "IfcActuator/G" is at "12.936,9.678,-1"
And the object "IfcActuator/G" has its origin on a vertex
And the object "IfcActuator/G" has a vert at "12.936,9.6778,-1" at map coordinates "27935.975,19677.841,-1000"
And the object "IfcActuator/G" has a vert at "15.3855,8.2636,1" at map coordinates "30385.465,18263.627,1000"
And the object "IfcActuator/H" is at "8.1757,4.261,0"
And the object "IfcActuator/I" is at "2.1213,3.674,0"
And the object "IfcActuator/J" is at "9.8487,5.745,-1"
And the object "IfcActuator/J" has its origin on a vertex
And the object "IfcActuator/J" has a vert at "9.8487,5.7448,-1" at map coordinates "24848.726,15744.786,-1000"
And the object "IfcActuator/J" has a vert at "12.2982,4.3306,1" at map coordinates "27298.216,14330.573,1000"
And the object "IfcActuator/K" is at "9.6593,2.588,0"
And the object "IfcActuator/D" has a cartesian point offset of "31,4,-1"
And the object "IfcActuator/G" has a cartesian point offset of "-25,6,-1"
And the object "IfcActuator/J" has a cartesian point offset of "11,3,-1"
And the object "IfcActuator/D" has a vertex at "12.039,5.296,-1"
And the object "IfcActuator/D" has a vertex at "13.971,5.814,-1"
And the object "IfcActuator/G" has a vertex at "13.454,7.746,-1"
And the object "IfcActuator/G" has a vertex at "15.385,8.264,-1"
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
Scenario: Link IFC - from an empty IFC project
Given an empty IFC project
+4
View File
@@ -81,6 +81,7 @@ Scenario: Assign type - assign to a type with a material layer set, which automa
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
When the variable "type" is "{ifc}.by_type('IfcWallType')[0].id()"
@@ -102,6 +103,7 @@ Scenario: Assign type - assign to a type with a material layer set, which automa
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
When the variable "type" is "{ifc}.by_type('IfcWallType')[0].id()"
@@ -125,6 +127,7 @@ Scenario: Assign type - assign to a different type with a LAYER2 material layer
And I press "bim.assign_class"
And the variable "type" is "{ifc}.by_type('IfcWallType')[-1].id()"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I add an empty
@@ -180,6 +183,7 @@ Scenario: Assign type - assign to a type with a material profile set
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I press "bim.add_material()"
And the object "IfcWallType/Empty" is selected
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
@@ -45,14 +45,14 @@ def test_text_formatter_defaults_to_none():
def test_text_formatter_field_stores_callable():
formatter = lambda props, value: f"{value:.2f}m" # noqa: E731
formatter = lambda props, value: f"{value:.2f}m"
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
assert config.text_formatter is not None
assert callable(config.text_formatter)
def test_text_formatter_receives_props_and_value():
formatter = lambda props, value: f"{props.label}={value}" # noqa: E731
formatter = lambda props, value: f"{props.label}={value}"
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
props = SimpleNamespace(label="L")
assert config.text_formatter(props, 3.14) == "L=3.14"
@@ -104,7 +104,7 @@ class TestParametricGizmoPollsHideDuringTransformModal:
continue
try:
result = poll(bpy.context)
except Exception as exc: # noqa: BLE001
except Exception as exc:
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
@@ -98,7 +98,7 @@ class TestWallGizmoGroupsHideDuringPreview:
continue
try:
result = poll(bpy.context)
except Exception as exc: # noqa: BLE001
except Exception as exc:
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
@@ -119,7 +119,7 @@ class TestWallGizmoGroupsHideOnArrayChildSelection:
for name, cls in groups:
try:
result = cls.poll(bpy.context)
except Exception as exc: # noqa: BLE001
except Exception as exc:
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
@@ -159,7 +159,7 @@ class TestWallOperatorsRejectArrayChildSelection:
for name, cls in ops:
try:
result = cls.poll(bpy.context)
except Exception as exc: # noqa: BLE001
except Exception as exc:
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
+107
View File
@@ -36,6 +36,7 @@ import bpy
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.unit
import numpy as np
import pytest
from mathutils import Vector
@@ -1000,6 +1001,7 @@ def i_click_button_and_expect_error_error_msg(button, error_msg):
@given(parsers.parse('I evaluate expression "{expression}"'))
@when(parsers.parse('I evaluate expression "{expression}"'))
@then(parsers.parse('I evaluate expression "{expression}"'))
def i_evaluate_expression(expression):
expression = replace_variables(expression)
exec(expression)
@@ -1680,6 +1682,111 @@ def the_object_name_has_a_vertex_at_location(name, location):
assert is_pass, f"No verts found at {location}: {verts}"
def get_model_origin() -> Vector:
"""Where the model was shifted to, in Blender units.
Geometry far from the origin is moved next to it so it keeps its precision,
and the shift is recorded as the model origin. Which vert of which object it
lands on is not something to depend on, so anything measured from it stays
put even when that choice changes.
"""
props = bpy.context.scene.BIMGeoreferenceProperties
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(an_ifc_file_exists())
return Vector([float(co) for co in props.model_origin.split(",")]) * unit_scale
def get_world_verts(obj: bpy.types.Object) -> list[Vector]:
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh) and len(mesh.vertices), f"Object {obj.name} has no mesh"
return [obj.matrix_world @ v.co for v in mesh.vertices]
def assert_vert_at_map_coordinates(obj: bpy.types.Object, vert: Vector, coordinates: str) -> None:
# Same conversion as the georeferencing calculator, which works in project
# units rather than Blender ones.
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(an_ifc_file_exists())
enh = Vector(tool.Georeference.xyz2enh(tuple(co / unit_scale for co in vert)))
expected = Vector([float(co) for co in coordinates.split(",")])
assert (enh - expected).length < 0.05, f"Vert {vert} is at map coordinates {enh[:]} instead of {coordinates}"
@then(
parsers.parse(
'the object "{name}" is at "{location}" relative to the model origin at map coordinates "{coordinates}"'
)
)
def the_object_name_is_at_location_relative_to_the_model_origin_at_map_coordinates(name, location, coordinates):
"""For objects with no geometry to name a vert on.
The Blender location is only meaningful next to the origin everything was
shifted by, since the two move together, but the map coordinates hold still
either way.
"""
obj = the_object_name_exists(name)
obj_location = obj.location + get_model_origin()
assert (
obj_location - Vector([float(co) for co in location.split(",")])
).length < 0.05, f"Object is at {obj_location} relative to the model origin instead of {location}"
assert_vert_at_map_coordinates(obj, obj.matrix_world.translation, coordinates)
@then(parsers.parse('the object "{name}" has a vert at "{location}" at map coordinates "{coordinates}"'))
def the_object_name_has_a_vert_at_location_at_map_coordinates(name, location, coordinates):
"""Check where a vert sits in Blender and where it is in the world.
Both matter: the Blender location is what the user sees, and checking only
the map coordinates would pass just as happily if the georeferencing maths
or the offsets it reads were wrong, since the same maths produces both.
"""
obj = the_object_name_exists(name)
target = Vector([float(co) for co in location.split(",")])
verts = get_world_verts(obj)
vert = next((v for v in verts if (v - target).length < 0.001), None)
assert vert is not None, f"No vert found at {location}: {verts}"
assert_vert_at_map_coordinates(obj, vert, coordinates)
@then(
parsers.parse(
'the object "{name}" has a vert at "{location}" relative to the model origin at map coordinates "{coordinates}"'
)
)
def the_object_name_has_a_vert_at_location_relative_to_the_model_origin_at_map_coordinates(name, location, coordinates):
"""As above, for when the whole model has been shifted onto the origin.
Blender locations are then only meaningful relative to that origin, since
everything moves together with it.
"""
obj = the_object_name_exists(name)
target = Vector([float(co) for co in location.split(",")]) - get_model_origin()
verts = get_world_verts(obj)
vert = next((v for v in verts if (v - target).length < 0.001), None)
assert vert is not None, f"No vert found at {location} relative to the model origin: {verts}"
assert_vert_at_map_coordinates(obj, vert, coordinates)
@then(parsers.parse('the object "{name}" has its origin on a vertex'))
def the_object_name_has_its_origin_on_a_vertex(name):
"""Far away geometry is shifted onto one of its own verts, which keeps the
origin on the geometry and the local coordinates small enough to keep their
precision. Which vert that is does not matter."""
obj = the_object_name_exists(name)
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh) and len(mesh.vertices), f"Object {obj.name} has no mesh"
nearest = min(v.co.length for v in mesh.vertices)
assert nearest < 0.001, f"Object origin is {nearest} away from its nearest vert"
@then("the model origin is on an object vertex")
def the_model_origin_is_on_an_object_vertex():
for obj in bpy.data.objects:
if not isinstance(obj.data, bpy.types.Mesh):
continue
if any(v.length < 0.001 for v in get_world_verts(obj)):
return
assert False, "No object has a vert at the model origin"
@then(parsers.parse('the object "{name}" has no scale'))
def the_object_name_has_no_scale(name):
assert the_object_name_exists(name).scale == Vector(
+112
View File
@@ -0,0 +1,112 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import pytest
import bonsai
import bonsai.core.covering as subject
import bonsai.core.tool
from test.core.bootstrap import Prophecy, ifc, root, spatial
# NOTE: The Prophecy mocking framework serialises call arguments as JSON,
# which means shapely geometry objects cannot be passed through mocked
# calls. We use the plain integer 42 as a serialisable stand-in for the
# polygon return value; the test verifies the unpack behaviour (that the
# polygon-like scalar 42 reaches set_covering_representation_from_polygon
# instead of the tuple (42, []) which old code would have passed).
@pytest.fixture
def covering():
prophet = Prophecy(bonsai.core.tool.Covering)
yield prophet
prophet.verify()
class TestAddInstanceFlooringCoveringFromCursor:
def test_run(self, ifc, root, spatial):
root.get_default_container().should_be_called().will_return("container")
spatial.get_active_obj().should_be_called().will_return(None)
spatial.get_selected_objects().should_be_called().will_return([])
spatial.get_relating_type_id().should_be_called().will_return(0)
spatial.get_x_y_z_h_mat_from_cursor().should_be_called().will_return((0, 0, 0, 3, None))
spatial.get_space_polygon_from_context_visible_objects(0, 0).should_be_called().will_return((42, []))
spatial.create_object("Covering").should_be_called().will_return("mock_obj")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation("mock_obj").should_be_called()
spatial.translate_obj_to_z_location("mock_obj", 0).should_be_called()
spatial.assign_type_to_obj("mock_obj").should_be_called()
spatial.set_covering_representation_from_polygon("mock_obj", 42, polygon_is_si=True).should_be_called()
subject.add_instance_flooring_covering_from_cursor(ifc, root, spatial)
def test_raises_when_no_default_container(self, ifc, root, spatial):
root.get_default_container().should_be_called().will_return(None)
with pytest.raises(subject.NoDefaultContainer):
subject.add_instance_flooring_covering_from_cursor(ifc, root, spatial)
class TestAddInstanceCeilingCoveringFromCursor:
def test_run(self, ifc, root, covering, spatial):
root.get_default_container().should_be_called().will_return("container")
spatial.get_active_obj().should_be_called().will_return(None)
spatial.get_selected_objects().should_be_called().will_return([])
spatial.get_relating_type_id().should_be_called().will_return(0)
covering.get_z_from_ceiling_height().should_be_called().will_return(3.0)
spatial.get_x_y_z_h_mat_from_cursor().should_be_called().will_return((0, 0, 0, 3, None))
spatial.get_space_polygon_from_context_visible_objects(0, 0).should_be_called().will_return((42, []))
spatial.create_object("Covering").should_be_called().will_return("mock_obj")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation("mock_obj").should_be_called()
spatial.translate_obj_to_z_location("mock_obj", 3.0).should_be_called()
spatial.assign_type_to_obj("mock_obj").should_be_called()
spatial.set_covering_representation_from_polygon("mock_obj", 42, polygon_is_si=True).should_be_called()
subject.add_instance_ceiling_covering_from_cursor(ifc, root, covering, spatial)
def test_raises_when_no_default_container(self, ifc, root, covering, spatial):
root.get_default_container().should_be_called().will_return(None)
with pytest.raises(subject.NoDefaultContainer):
subject.add_instance_ceiling_covering_from_cursor(ifc, root, covering, spatial)
class TestRegenSelectedCoveringObject:
def test_run(self, root, spatial):
root.get_default_container().should_be_called().will_return("container")
spatial.get_active_obj().should_be_called().will_return("active")
spatial.get_selected_objects().should_be_called().will_return(["active"])
spatial.get_x_y_z_h_mat_from_obj("active").should_be_called().will_return((2, 3, 1, 3, None))
spatial.get_space_polygon_from_context_visible_objects(2, 3).should_be_called().will_return((42, []))
spatial.set_covering_representation_from_polygon("active", 42, polygon_is_si=True).should_be_called()
subject.regen_selected_covering_object(root, spatial)
def test_raises_when_no_default_container(self, root, spatial):
root.get_default_container().should_be_called().will_return(None)
with pytest.raises(subject.NoDefaultContainer):
subject.regen_selected_covering_object(root, spatial)
def test_raises_when_no_active_selected(self, root, spatial):
root.get_default_container().should_be_called().will_return("container")
spatial.get_active_obj().should_be_called().will_return(None)
spatial.get_selected_objects().should_be_called().will_return([])
with pytest.raises(AssertionError):
subject.regen_selected_covering_object(root, spatial)
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.ruff]
extend = "../pyproject.toml"
lint.ignore = [
"F401", # unused imports
"unused-import", # unused imports
]
+39
View File
@@ -32,6 +32,7 @@ import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
import numpy as np
from ifcopenshell.util.shape_builder import ShapeBuilder, V
from mathutils import Matrix
import bonsai.core.tool
import bonsai.tool as tool
@@ -1044,3 +1045,41 @@ class TestGetSiblingOccurrenceCount(NewFile):
ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
assert subject.get_sibling_occurrence_count(wall_type) == 2
class TestConvertCurveToMesh(NewFile):
def test_closed_polyline_converts_to_closed_loop(self):
"""A closed IfcPolyline must produce the full edge loop.
Before the fix (cls.edges[-1] = overwrite) the closing edge
replaced the real last segment, leaving every loop open by one
edge e.g. a quad got only 3 edges.
"""
ifc = ifcopenshell.file()
# Closed quad: 4 unique points + closing repeat = 5 points
p0 = ifc.createIfcCartesianPoint((0.0, 0.0))
p1 = ifc.createIfcCartesianPoint((1.0, 0.0))
p2 = ifc.createIfcCartesianPoint((1.0, 1.0))
p3 = ifc.createIfcCartesianPoint((0.0, 1.0))
polyline = ifc.createIfcPolyline((p0, p1, p2, p3, p0))
subject.vertices = []
subject.edges = []
subject.arcs = []
subject.circles = []
subject.unit_scale = 1.0
subject.convert_curve_to_mesh(None, Matrix(), polyline)
assert len(subject.vertices) == 4, f"Expected 4 vertices, got {len(subject.vertices)}"
assert len(subject.edges) == 4, f"Expected 4 edges, got {len(subject.edges)}"
# Every vertex must appear in exactly 2 edges (closed loop)
from collections import defaultdict
counts = defaultdict(int)
for e in subject.edges:
counts[e[0]] += 1
counts[e[1]] += 1
for v_idx, cnt in counts.items():
assert cnt == 2, f"Vertex {v_idx} has {cnt} incident edges (expected 2)"
+546 -16
View File
@@ -16,6 +16,8 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import bpy
import ifcopenshell
import ifcopenshell.api
@@ -24,12 +26,16 @@ import ifcopenshell.api.feature
import ifcopenshell.api.nest
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.util.representation
import numpy as np
from mathutils import Matrix
import pytest
import shapely
from mathutils import Matrix, Vector
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.tool.spatial import Spatial as subject
from bonsai.tool.spatial import _bump_geom_cache_token
from test.bim.bootstrap import NewFile
@@ -258,17 +264,59 @@ class TestSelectProducts(NewFile):
assert obj in bpy.context.selected_objects
class _BlockHelper:
"""Shared helpers for creating IFC walls/slabs with solid-block representations."""
@staticmethod
def create_wall(ifc, height=10.0):
"""Create an IFC wall with a 10x10x{height} block representation from z=0."""
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 10.0, 10.0)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([0.0, 0.0, 0.0]))
extrusion = ifc.createIfcExtrudedAreaSolid(
profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), height
)
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
wall.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
return wall, extrusion
@staticmethod
def create_thin_wall(ifc, cx, cy, width, depth, height=10.0):
"""Create an IFC wall with a thin block representation centered at (cx, cy)."""
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, width, depth)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([cx, cy, 0.0]))
extrusion = ifc.createIfcExtrudedAreaSolid(
profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), height
)
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
wall.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
return wall
@staticmethod
def create_slab(ifc, z=4.0):
"""Create an IfcSlab with a 12x12x1.0 block representation at bottom_z={z}."""
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 12.0, 12.0)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([0.0, 0.0, z]))
extrusion = ifc.createIfcExtrudedAreaSolid(profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), 1.0)
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
slab.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
class TestGenerateSpace(NewFile):
def test_generate_space_at_cursor(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
scene = bpy.context.scene
product = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
obj = bpy.data.objects["Cube"]
scene.collection.objects.link(obj)
tool.Ifc.link(product, obj)
scene.cursor.location = (0, 0, 0)
# The wall block spans z=0..10, bisects to a 10x10 polygon at cut_z.
_BlockHelper.create_wall(ifc, height=10.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
@@ -292,13 +340,8 @@ class TestGenerateSpace(NewFile):
def test_regenerate_space_preserves_z_location(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
scene = bpy.context.scene
product = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
obj = bpy.data.objects["Cube"]
scene.collection.objects.link(obj)
tool.Ifc.link(product, obj)
scene.cursor.location = (0, 0, 0)
_BlockHelper.create_wall(ifc, height=10.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
@@ -307,8 +350,495 @@ class TestGenerateSpace(NewFile):
bpy.context.view_layer.objects.active = space
space.select_set(True)
obj.select_set(False)
bpy.ops.bim.generate_space()
assert np.isclose(space.location.z, 5), f"Expected z=5, got {space.location.z}"
def test_auto_space_height_from_slab_above(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
_BlockHelper.create_slab(ifc, z=4.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
assert np.isclose(space.dimensions.z, 4, atol=0.1), f"Expected height ~4, got {space.dimensions.z}"
def test_forced_space_height(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
bpy.context.scene.cursor.location = (0, 0, 0)
spatial_props = tool.Spatial.get_spatial_props()
spatial_props.force_space_height = True
spatial_props.space_height = 5
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
assert np.isclose(space.dimensions.z, 5, atol=0.1), f"Expected height 5, got {space.dimensions.z}"
def test_auto_space_height_fallback_no_slab(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
bpy.context.scene.cursor.location = (0, 0, 0)
spatial_props = tool.Spatial.get_spatial_props()
spatial_props.force_space_height = False
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
assert space.dimensions.z > 0, f"Expected positive height, got {space.dimensions.z}"
def test_apply_space_height_to_selection(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
spatial_props = tool.Spatial.get_spatial_props()
spatial_props.space_height = 6
bpy.context.view_layer.objects.active = space
space.hide_viewport = False
space.select_set(True)
bpy.ops.bim.apply_space_height_to_selection()
bpy.context.view_layer.update()
assert np.isclose(space.dimensions.z, 6, atol=0.1), f"Expected height 6, got {space.dimensions.z}"
def test_cache_survives_second_generation(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space1 = bpy.data.objects["IfcSpace/Space"]
height1 = space1.dimensions.z
bpy.ops.bim.generate_space()
space2 = bpy.data.objects["IfcSpace/Space"]
height2 = space2.dimensions.z
assert np.isclose(height1, height2, atol=0.1), f"Cache changed height: {height1} vs {height2}"
def test_regenerate_after_wall_height_change(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
wall, extrusion = _BlockHelper.create_wall(ifc, height=10.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
original_height = space.dimensions.z
# Modify the IFC representation to change the wall height.
extrusion.Depth = 15.0
_bump_geom_cache_token()
bpy.context.view_layer.objects.active = space
space.select_set(True)
bpy.ops.bim.generate_space()
new_height = space.dimensions.z
assert new_height != original_height or new_height > 0
def test_regenerate_space_from_centered_cube_representation(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
scene = bpy.context.scene
scene.cursor.location = (0, 0, 0)
# Create a space with a unit cube PolygonalFaceSet centered at local origin.
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
points = ifc.createIfcCartesianPointList3D(
[
(-0.5, -0.5, -0.5),
(-0.5, -0.5, 0.5),
(-0.5, 0.5, -0.5),
(-0.5, 0.5, 0.5),
(0.5, -0.5, -0.5),
(0.5, -0.5, 0.5),
(0.5, 0.5, -0.5),
(0.5, 0.5, 0.5),
]
)
faces = [
ifc.createIfcIndexedPolygonalFace([1, 2, 4, 3]),
ifc.createIfcIndexedPolygonalFace([3, 4, 8, 7]),
ifc.createIfcIndexedPolygonalFace([7, 8, 6, 5]),
ifc.createIfcIndexedPolygonalFace([5, 6, 2, 1]),
ifc.createIfcIndexedPolygonalFace([3, 7, 5, 1]),
ifc.createIfcIndexedPolygonalFace([8, 4, 2, 6]),
]
face_set = ifc.createIfcPolygonalFaceSet(points, True, faces)
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "Tessellation", [face_set])
space_element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSpace")
space_element.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 5))
obj = bpy.data.objects["Cube"]
scene.collection.objects.link(obj)
tool.Ifc.link(space_element, obj)
bpy.context.view_layer.update()
obj.name = "MySpace"
# Check the cube's world bottom Z before regeneration.
bottom_z = (obj.matrix_world @ Vector(obj.bound_box[0])).z
assert np.isclose(bottom_z, 4.5), f"Expected bottom_z=4.5, got {bottom_z}"
# Regenerate the space.
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.bim.generate_space()
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh)
world_verts = [obj.matrix_world @ v.co for v in mesh.vertices]
world_zs = [v.z for v in world_verts]
assert min(world_zs) >= -0.1, f"Expected space world bottom near z>=0, got {min(world_zs)}"
assert max(world_zs) > 0, f"Expected space to have positive height, got {max(world_zs)}"
assert np.isclose(obj.location.z, 5.0, atol=0.01), f"Expected location.z=5.0, got {obj.location.z}"
class TestGenerateSpaceSlopedRoof(NewFile):
def _create_shed_roof(self, ifc, z=4.0, rise=3.0):
"""Create an IfcRoof whose underside is a sloped plane across the footprint.
Triangular prism: vertical profile (in the y-z plane) extruded along +x.
Profile points (u, v) with placement loc=(-5, 0, z), axis=(1,0,0),
ref=(0,0,1). The local frame maps u to world +z (u=0 -> z, u=rise ->
z+rise) and v to world -y (v=-5 -> y=+5, v=+5 -> y=-5):
(0,-5) -> world (-5, +5, z) eave (low) at north
(rise,-5) -> world (-5, +5, z+rise) vertical edge
(rise,5) -> world (-5, -5, z+rise) ridge at south
The underside is the sloped face from (y=+5, z) to (y=-5, z+rise).
ExtrudedDirection (0,0,1) is local, mapping to world +x; depth 10 spans
x in [-5, 5].
"""
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
roof = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcRoof")
pts = [
ifc.createIfcCartesianPoint((0.0, -5.0)),
ifc.createIfcCartesianPoint((float(rise), -5.0)),
ifc.createIfcCartesianPoint((float(rise), 5.0)),
]
polyline = ifc.createIfcPolyline(pts)
profile = ifc.createIfcArbitraryClosedProfileDef(ProfileType="CURVE", OuterCurve=polyline)
placement = ifc.createIfcAxis2Placement3D(
ifc.createIfcCartesianPoint((-5.0, 0.0, z)),
ifc.createIfcDirection((1.0, 0.0, 0.0)),
ifc.createIfcDirection((0.0, 0.0, 1.0)),
)
extrude_dir = ifc.createIfcDirection((0.0, 0.0, 1.0))
solid = ifc.createIfcExtrudedAreaSolid(profile, placement, extrude_dir, 10.0)
rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [solid])
ifcopenshell.api.geometry.assign_representation(ifc, product=roof, representation=rep)
return roof
def test_generate_space_under_shed_roof(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_thin_wall(ifc, 0.0, 4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 0.0, -4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 4.8, 0.0, 0.4, 10.0)
_BlockHelper.create_thin_wall(ifc, -4.8, 0.0, 0.4, 10.0)
self._create_shed_roof(ifc, z=4.0, rise=3.0)
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
mesh = space.data
assert isinstance(mesh, bpy.types.Mesh)
verts = np.array([v.co for v in mesh.vertices])
min_z = verts[:, 2].min()
max_z = verts[:, 2].max()
assert min_z >= -0.1
assert max_z > 0
top_z_north = max([v[2] for v in verts if v[1] > 1])
top_z_south = max([v[2] for v in verts if v[1] < -1])
assert abs(top_z_north - top_z_south) > 0.05, f"Top should slope along y: {top_z_north} vs {top_z_south}"
class TestSpaceVolumeStrategy(NewFile):
def test_vertical_box_returns_extrude_clip(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
_BlockHelper.create_slab(ifc, z=4.0)
space_polygon = shapely.box(-5, -5, 5, 5)
strategy, top, bottom = subject.get_space_volume_strategy(space_polygon, 0.0, [ifc.by_type("IfcWall")[0]])
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
@staticmethod
def _create_sloped_slab(ifc, z=4.0, rise=3.0):
"""Create an IfcSlab whose underside is a sloped plane across the footprint."""
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
pts = [
ifc.createIfcCartesianPoint((0.0, -5.0)),
ifc.createIfcCartesianPoint((float(rise), -5.0)),
ifc.createIfcCartesianPoint((float(rise), 5.0)),
]
polyline = ifc.createIfcPolyline(pts)
profile = ifc.createIfcArbitraryClosedProfileDef(ProfileType="CURVE", OuterCurve=polyline)
placement = ifc.createIfcAxis2Placement3D(
ifc.createIfcCartesianPoint((-5.0, 0.0, z)),
ifc.createIfcDirection((1.0, 0.0, 0.0)),
ifc.createIfcDirection((0.0, 0.0, 1.0)),
)
extrude_dir = ifc.createIfcDirection((0.0, 0.0, 1.0))
solid = ifc.createIfcExtrudedAreaSolid(profile, placement, extrude_dir, 10.0)
rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [solid])
ifcopenshell.api.geometry.assign_representation(ifc, product=slab, representation=rep)
return slab
def test_sloped_slab_returns_extrude_clip(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
self._create_sloped_slab(ifc, z=4.0, rise=3.0)
space_polygon = shapely.box(-5, -5, 5, 5)
strategy, top, bottom = subject.get_space_volume_strategy(space_polygon, 0.0, [])
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
class TestRegenerateSpaceFromRealIfc2x3(NewFile):
def load_house_with_garage(self):
filepath = (
Path(__file__).parents[3]
/ "ifcopenshell-python"
/ "test"
/ "IfcRelSpaceBoundary_TestFiles"
/ "IfcRelSpaceBoundary2ndLevel"
/ "HouseWithGarage_AC22_IFC2X3.ifc"
).resolve()
bpy.ops.bim.load_project(filepath=filepath.as_posix())
ifc = tool.Ifc.get()
return ifc
def _regenerate_space(self, ifc, space_id):
space = ifc.by_id(space_id)
obj = tool.Ifc.get_object(space)
assert obj
import numpy as np
original_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
original_bounds = (
original_verts[:, 0].min(),
original_verts[:, 0].max(),
original_verts[:, 1].min(),
original_verts[:, 1].max(),
original_verts[:, 2].min(),
original_verts[:, 2].max(),
)
original_origin = obj.matrix_world.translation.copy()
# Delete existing related IfcRelSpaceBoundary as in the manual repro.
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.update()
# Patch Spatial helpers so generate_space uses the active IfcSpace.
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
bpy.ops.bim.generate_space()
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
regen_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
regen_bounds = (
regen_verts[:, 0].min(),
regen_verts[:, 0].max(),
regen_verts[:, 1].min(),
regen_verts[:, 1].max(),
regen_verts[:, 2].min(),
regen_verts[:, 2].max(),
)
regen_origin = obj.matrix_world.translation.copy()
return (original_bounds, original_origin), (regen_bounds, regen_origin)
def test_regenerate_space_5710_keeps_world_location(self):
ifc = self.load_house_with_garage()
(original_bounds, original_origin), (regen_bounds, regen_origin) = self._regenerate_space(ifc, 5710)
assert (regen_origin - original_origin).length < 0.02
for o, r in zip(original_bounds, regen_bounds):
assert r == pytest.approx(o, abs=0.02)
def test_regenerate_space_2363_keeps_world_location(self):
ifc = self.load_house_with_garage()
(original_bounds, original_origin), (regen_bounds, regen_origin) = self._regenerate_space(ifc, 2363)
assert (regen_origin - original_origin).length < 0.02
# X and Y stable; Z may differ because the regenerated space detects the
# sloped roof and clips the extrusion.
for j in (0, 1, 2, 3, 4):
assert regen_bounds[j] == pytest.approx(original_bounds[j], abs=0.02)
# Verify the regenerated body contains boolean clipping (roof clipping).
space = ifc.by_id(2363)
body = ifcopenshell.util.representation.get_representation(space, "Model", "Body", "MODEL_VIEW")
assert body is not None
boolean_items = [i for i in (body.Items or []) if i.is_a("IfcBooleanClippingResult")]
assert len(boolean_items) >= 1, "Expected roof clipping but got no boolean result"
def test_regenerate_space_twice_does_not_duplicate_half_spaces(self):
ifc = self.load_house_with_garage()
space = ifc.by_id(2363)
obj = tool.Ifc.get_object(space)
assert obj
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.update()
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
bpy.ops.bim.generate_space()
bpy.ops.bim.generate_space()
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
body_reps = [r for r in (space.Representation.Representations or []) if r.RepresentationIdentifier == "Body"]
assert len(body_reps) == 1
rep = body_reps[0]
boolean_chains = [item for item in rep.Items if item.is_a("IfcBooleanClippingResult")]
assert len(boolean_chains) <= 1
if boolean_chains:
half_space_ids = set()
for item in ifc.traverse(boolean_chains[0]):
if item.is_a("IfcHalfSpaceSolid"):
assert item.id() not in half_space_ids, "Duplicate half-space solid in boolean chain"
half_space_ids.add(item.id())
def test_regenerate_space_after_moving_roof_updates_shape(self):
ifc = self.load_house_with_garage()
space = ifc.by_id(2363)
space_obj = tool.Ifc.get_object(space)
assert space_obj
roof = ifc.by_id(5773)
roof_obj = tool.Ifc.get_object(roof)
assert roof_obj
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = space_obj
bpy.ops.object.select_all(action="DESELECT")
space_obj.select_set(True)
bpy.context.view_layer.update()
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [space_obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: space_obj)
bpy.ops.bim.generate_space()
roof_obj.hide_set(False)
roof_obj.location.z += 1.0
bpy.context.view_layer.update()
tool.Geometry.commit_placement_if_moved(roof_obj)
bpy.ops.bim.generate_space()
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
body_reps = [r for r in (space.Representation.Representations or []) if r.RepresentationIdentifier == "Body"]
assert len(body_reps) == 1
def test_regenerate_space_is_stable_across_multiple_iterations(self):
"""Regenerating the same space 5+ times must produce identical Z and bounds."""
ifc = self.load_house_with_garage()
space = ifc.by_id(2363)
obj = tool.Ifc.get_object(space)
assert obj
for b in list(space.BoundedBy or []):
ifcopenshell.api.boundary.remove_boundary(ifc, b)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.update()
original_get_selected_objects = tool.Spatial.get_selected_objects
original_get_active_obj = tool.Spatial.get_active_obj
def snapshot():
verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices], dtype=float)
return (
obj.matrix_world.translation.copy(),
(
float(verts[:, 0].min()),
float(verts[:, 0].max()),
float(verts[:, 1].min()),
float(verts[:, 1].max()),
float(verts[:, 2].min()),
float(verts[:, 2].max()),
),
)
snapshots = []
try:
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
for _ in range(5):
bpy.ops.bim.generate_space()
snapshots.append(snapshot())
finally:
tool.Spatial.get_selected_objects = original_get_selected_objects
tool.Spatial.get_active_obj = original_get_active_obj
ref_origin, ref_bounds = snapshots[0]
for i, (origin, bounds) in enumerate(snapshots[1:], start=1):
assert (
origin - ref_origin
).length < 0.02, f"Iteration {i}: Z drifted from {list(ref_origin)} to {list(origin)}"
for j, (o, r) in enumerate(zip(ref_bounds, bounds)):
assert r == pytest.approx(
o, abs=0.02
), f"Iteration {i} axis {j}: {o} != {r} full ref={ref_bounds} cur={bounds}"
class TestGenerateSpaceLocation(NewFile):
def test_generate_space_at_non_zero_cursor_location(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
# 4 thin walls forming a hollow box around (10, 20).
_BlockHelper.create_thin_wall(ifc, 10.0, 20.0 + 4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 10.0, 20.0 - 4.8, 10.0, 0.4)
_BlockHelper.create_thin_wall(ifc, 10.0 + 4.8, 20.0, 0.4, 10.0)
_BlockHelper.create_thin_wall(ifc, 10.0 - 4.8, 20.0, 0.4, 10.0)
bpy.context.scene.cursor.location = (10, 20, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
mesh = space.data
assert isinstance(mesh, bpy.types.Mesh)
world_verts = np.array([space.matrix_world @ v.co for v in mesh.vertices])
center = (world_verts.min(axis=0) + world_verts.max(axis=0)) / 2
assert center[0] == pytest.approx(10.0, abs=0.1)
assert center[1] == pytest.approx(20.0, abs=0.1)
+3 -3
View File
@@ -168,7 +168,7 @@ ifcopenshell_deploy_qt_runtime(BonsaiViewer)
# them explicitly. (In a static build these are absent from lib/
# and the glob just no-ops, so this rule is safe in both modes.)
#
# 2. Plug-ins (ifcopenshell.*.dylib, no `lib` prefix) dlopen-only
# 2. Plug-ins (ifcopenshell_*.dylib, no `lib` prefix) dlopen-only
# deps the plug-in loader resolves at runtime. macdeployqt has
# no way to know about these.
#
@@ -177,7 +177,7 @@ ifcopenshell_deploy_qt_runtime(BonsaiViewer)
# inside the bundle), so plug-ins and core libs both find each other
# on the first probe.
#
# The geometry-writer filter drops ifcopenshell.geometry.writer.*.dylib
# The geometry-writer filter drops ifcopenshell_geometry_writer_*.dylib
# (the per-schema OBJ / glTF / DAE / STP / IGS / SVG / TTL export
# converters heavy, viewer-irrelevant). Mirrors the Rocky workflow's
# filter in `stage_runtime_payload` (see 27249770e).
@@ -195,7 +195,7 @@ if(APPLE)
install(CODE [[
set(_fw "${CMAKE_INSTALL_PREFIX}/BonsaiViewer.app/Contents/Frameworks")
file(GLOB _ifc_dylibs "${CMAKE_INSTALL_PREFIX}/lib/*.dylib")
list(FILTER _ifc_dylibs EXCLUDE REGEX "ifcopenshell\\.geometry\\.writer\\.")
list(FILTER _ifc_dylibs EXCLUDE REGEX "ifcopenshell_geometry_writer_")
if(_ifc_dylibs)
message(STATUS "Staging IfcOpenShell dylibs (linked core + plug-ins) into BonsaiViewer.app/Contents/Frameworks")
file(COPY ${_ifc_dylibs} DESTINATION "${_fw}")

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