Compare commits

...

18 Commits

Author SHA1 Message Date
Ryan Schultz 60063ac1c7 Add tests for IfcGridAxis fixes
- Add test_create_axis_curve.py with three unit tests: basic
  polyline creation, safe removal of an unshared existing curve,
  and preservation of a shared curve when only one referencing
  axis is updated (regression for the shallow-copy duplication bug)
- Add feature scenario "Export IFC - with duplicate-of-duplicate
  grid axis locations preserved" to project.feature, reproducing
  the case where duplicates of duplicates lost their positions on
  save/reload
2026-07-10 19:22:03 -05:00
Ryan Schultz f2d3e226b4 Fix IfcGridAxis unlock not working in IFC4X3
In IFC4X3, IfcGrid is a subtype of IfcPositioningElement, but
IfcGridAxis is not. The unlock handler only fetched
IfcPositioningElement instances, so grid axes were never
unlocked and remained immovable.

Fix: include IfcGridAxis in the element list for the IFC4X3
(non-IFC2X3/IFC4) branch of update_grid_is_locked.
2026-07-10 19:22:03 -05:00
Ryan Schultz c0889c7f10 Fix IfcGridAxis duplication losing geometry on save
When duplicating an IfcGridAxis one or more times before saving,
duplicates shared the same IfcPolyline as the source via shallow copy.
This caused two issues: (1) updating any one axis's AxisCurve during
export would destroy the shared curve, corrupting others; (2) duplicates
whose matrix_world checksum happened to match their current position were
skipped entirely by the is_moved guard, so their moved position was never
written to IFC.

Three fixes:

- geometry.py: call create_axis_curve immediately after copy_class for
  IfcGridAxis duplicates, so each new axis owns its AxisCurve from the
  moment of duplication rather than sharing the source's.

- create_axis_curve.py: only remove the old AxisCurve when its inverse
  count drops to zero, preventing destruction of curves still referenced
  by other axes.

- export_ifc.py: move the IfcGridAxis branch before the is_moved guard.
  Grid axes store position in AxisCurve geometry rather than
  ObjectPlacement, so is_moved is not a reliable gate. The internal
  matrices_differ check is the correct decision point, and
  record_object_position at the end keeps checksums in sync.

Generated with the assistance of an AI coding tool.
2026-07-10 19:22:03 -05:00
Ryan Schultz e609f10559 Fix grid axis annotation misalignment when axis is moved
After moving an IfcGridAxis in Blender, the drawing annotation
was not tracking the axis to its new visual position. Two issues
were found and fixed:

1. generate_grid_axis_reference_points used the IFC AxisCurve
   geometry (via create_shape) with the grid object's matrix_world.
   After a save, the IFC AxisCurve is updated but the Blender mesh
   is not rebuilt, causing the two sources to diverge. The fix reads
   the axis object's Blender mesh vertices directly with
   axis_obj.matrix_world, which always matches what Blender renders.

2. When no Blender axis object exists, falls back to reading IFC
   geometry with the grid object's matrix_world (unchanged behavior).

Minor refactors: extracted matrices_differ variable in
sync_grid_axis_object_placement (export_ifc.py and drawing.py) and
extracted grid_placement variable in create_axis_curve.py for clarity.

Generated with the assistance of an AI coding tool.
2026-07-10 19:04:44 -05:00
Ryan Schultz e70ce17431 Fix grid decorations missing due to geolocation offset
generate_grid_axis_reference_points was building the grid-to-world
transform using get_local_placement(grid.ObjectPlacement), which
returns raw IFC world coordinates. When the project uses a
geolocation offset (survey point shift), these coordinates are
hundreds of meters from the Blender world origin, placing grid
vertices far outside the camera's ortho bounds and causing
clip_segment to return None for every axis.

Fix by using tool.Ifc.get_object(grid).matrix_world instead, which
already has the importer-applied geolocation offset baked in,
keeping the coordinate space consistent with the camera.

Generated with the assistance of an AI coding tool.
2026-07-10 19:04:44 -05:00
sboddy 256d5a63f1 Merge pull request #8495 from sboddy/lint-pass
Fix ci-lint failures: black formatting, ruff unused imports, ty type errors
2026-07-10 22:47:46 +01:00
Stephen Boddy c4605f2a8f Fix lint drift introduced by merging v0.8.0 into lint-pass
- add_stationing_referent.py: black reformat (new drift from v0.8.0).
- update_fallback_position.py: v0.8.0's changes to this file made the
  ifcopenshell.util.unit import (added in an earlier commit here) unused;
  removed per ruff.
2026-07-10 22:21:23 +01:00
Stephen Boddy 4a62ffe9ca Merge remote-tracking branch 'origin/lint-pass' into lint-pass 2026-07-10 22:20:29 +01:00
Stephen Boddy d5e890bccd Fix ty-ios type-check errors (ifcopenshell-python side)
poe ty's sequence only reaches ty-ios once ty-bonsai passes, so these
never surfaced until now:

- util/alignment.py: drop the stale `include_referent=False` kwarg from
  add_zero_length_segment() - that parameter was removed from the function's
  signature in 45ea5eb07 but this caller in a different file was missed,
  leaving a latent TypeError if this code path is ever exercised.
- ifcopenshell_wrapper.pyi: add the optional trailing `logger` parameter to
  parse_ifcxml/open/construct_iterator*, matching the real SWIG signatures
  in src/ifcwrap/*.i (all declare `Logger& logger = Logger::Root()`) that
  the hand-maintained stub never picked up.
- ifcopenshell/__init__.py: remove a stale `ty: ignore[unknown-argument]`
  comment that ty confirms is no longer suppressing anything.
- assign_cost_item_quantity.py: OPERATORS mixes 2-arg binary operators with
  the 1-arg `operator.neg` (for ast.USub), but FormulaEvaluator has no
  visit_UnaryOp so USub can never reach this lookup via visit_BinOp.
  Suppressed at the call site rather than touching the dict, since this
  looks like scaffolding for unary-minus support rather than dead code.
- Explicit submodule imports (ifcopenshell.geom / api.alignment / util.unit
  / api.aggregate / api.context / api.spatial) added where accessed but
  only reachable by accident of import order.
2026-07-10 22:19:56 +01:00
sboddy bba11aa619 Merge branch 'v0.8.0' into lint-pass 2026-07-10 21:53:44 +01:00
Stephen Boddy 9f848a73e1 Fix remaining ty type-check errors in tool.py, product.py, railing.py
- tool.py: drop the `-> int` annotation on the Parametric interface's
  get_geom_generation stub; its `pass` body implicitly returns None, which
  ty can't reconcile with the runtime @interface/@abstractmethod rewriting
  it never sees statically. Matches the file's other stubs (-> None).
- railing.py: qualify the "BIMRailingProperties" string annotations as
  "prop.BIMRailingProperties" on the two functions using it, since the bare
  name was never imported into this module's namespace.
- product.py: suppress ty's missing-argument errors on
  copy_z_rotation_to_selected's Surveyor.get_z_rotation/set_z_rotation
  calls with targeted ty: ignore comments. The function is unused and its
  two dependencies were never implemented on the concrete Surveyor tool;
  left as-is rather than deleted or implemented.
2026-07-10 21:45:31 +01:00
Stephen Boddy 4fb8af2278 Fix ty type-check errors: missing imports and unresolved names
- gizmos.py: TYPE_CHECKING-guard `import bmesh` for the string-literal
  annotation in build_schematic_mesh; suppress the still-unresolved
  gizmo_textures import in TexturedQuadGizmoMixin (WIP dependency, not dead
  code).
- model/__init__.py: register the `decorator` submodule, which unregister()
  already calls (would have raised NameError on addon disable).
- mep.py / tool/model.py: add explicit imports for bonsai.core.geometry and
  bonsai.core.model, previously only reachable by accident of import order.
- Test files: add explicit ifcopenshell.api.pset / ifcopenshell.util.element
  submodule imports used but not imported.
2026-07-10 21:27:10 +01:00
Stephen Boddy 78653a1708 Remove unused imports flagged by ruff
Fixes 23 unused-import violations, mostly in the alignment API module.
2026-07-10 20:42:49 +01:00
Stephen Boddy 216092150a Apply black formatting to fix CI lint-formatting drift
20 files had fallen out of sync with the project's black version;
running `black .` brings them back in line with no logic changes.
2026-07-10 20:42:18 +01:00
Richard Brice ade03b171a Fixes bug with fallback position introduced in 206cd6bb 2026-07-10 09:54:03 -07:00
Richard Brice b5c1b81ede Stationing referent can optionally be located relative to the basis_curve (default) or the alignment curve 2026-07-10 09:46:11 -07:00
Richard Brice 47a20f0c7c Locates positioning referent on the alignment curve, not the basis curve 2026-07-10 09:45:38 -07:00
Richard Brice 52d894298e Fixes double unit conversion when convert-back-units are used 2026-07-10 17:09:51 +02:00
55 changed files with 425 additions and 182 deletions
+4 -3
View File
@@ -120,10 +120,10 @@ class IfcExporter:
# updata_representation will run edit_object_placement if object is scaled
# and had no openings.
return element
if not tool.Ifc.is_moved(obj):
return
if element.is_a("IfcGridAxis"):
return self.sync_grid_axis_object_placement(obj, element)
if not tool.Ifc.is_moved(obj):
return
if not hasattr(element, "ObjectPlacement"):
return
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
@@ -134,7 +134,8 @@ class IfcExporter:
grid_obj = tool.Ifc.get_object(grid)
if grid_obj:
self.sync_object_placement(grid_obj)
if grid_obj.matrix_world != obj.matrix_world:
matrices_differ = grid_obj.matrix_world != obj.matrix_world
if matrices_differ:
bpy.ops.bim.update_representation(obj=obj.name)
tool.Geometry.record_object_position(obj)
+18 -3
View File
@@ -82,7 +82,15 @@ import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Literal,
Optional,
Protocol,
runtime_checkable,
)
import blf
import bpy
@@ -105,6 +113,9 @@ from mathutils.kdtree import KDTree
import bonsai.tool as tool
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
if TYPE_CHECKING:
import bmesh
SNAP_POINT_SIZE = 10.0
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
SNAP_MAX_RADIUS = 50.0
@@ -2035,7 +2046,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
def setup(self) -> None:
super().setup()
from bonsai.bim.module.drawing import gizmo_textures
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
self._quad_batch = batch_for_shader(
gizmo_textures.get_shader(),
@@ -2044,7 +2057,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
)
def draw(self, context: bpy.types.Context) -> None:
from bonsai.bim.module.drawing import gizmo_textures
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
texture = gizmo_textures.get_icon_texture(self.icon_name)
if texture is None:
@@ -981,7 +981,9 @@ class CreateDrawing(bpy.types.Operator):
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
@@ -27,6 +27,7 @@ import bonsai.tool as tool
from . import (
array,
covering,
decorator,
door,
external,
grid,
@@ -38,6 +38,7 @@ import numpy as np
from ifcopenshell.util.shape_builder import ShapeBuilder
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
@@ -138,7 +138,7 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
def generate_wall_mounted_handrail_preview(
obj: bpy.types.Object,
props: "BIMRailingProperties",
props: "prop.BIMRailingProperties",
path_data: dict[str, Any],
si_conversion: float,
) -> None:
@@ -860,7 +860,9 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup)
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
def update_editing_gizmos(
self, context: bpy.types.Context, mw: "Matrix", props: "prop.BIMRailingProperties"
) -> None:
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
The base class shows the pen gizmo whenever ``is_editing`` is False,
+1 -1
View File
@@ -128,7 +128,7 @@ def update_grid_is_locked(self: "BIMGridProperties", context: bpy.types.Context)
if tool.Ifc.get().schema in ("IFC2X3", "IFC4"):
elements = tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis")
else:
elements = tool.Ifc.get().by_type("IfcPositioningElement")
elements = tool.Ifc.get().by_type("IfcPositioningElement") + tool.Ifc.get().by_type("IfcGridAxis")
for element in elements:
if obj := tool.Ifc.get_object(element):
if self.is_locked:
+19 -14
View File
@@ -302,23 +302,25 @@ def add_drawing(
context=drawing.get_body_context(),
ifc_representation_class=None,
)
drawings_parent_group = None
for group in ifc.get().by_type("IfcGroup"):
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
drawings_parent_group = group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
ifc.run(
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
)
group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=group, products=[element])
ifc.run("group.assign_group", group=drawings_parent_group, products=[group])
collector.assign(camera)
pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing")
if drawing.get_unit_system() == "METRIC":
@@ -355,7 +357,7 @@ def add_drawing(
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
@@ -363,7 +365,7 @@ def add_drawing(
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
information = ifc.run("document.add_information", parent=drawings_parent_document)
uri = drawing.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
@@ -392,17 +394,19 @@ def duplicate_drawing(
drawing_tool.set_name(new_drawing, drawing_name)
group = drawing_tool.get_drawing_group(new_drawing)
ifc.run("group.unassign_group", group=group, products=[new_drawing])
drawings_parent_group = None
for parent_group in ifc.get().by_type("IfcGroup"):
if parent_group.Name == "DRAWINGS" and parent_group.ObjectType == "DRAWINGS":
drawings_parent_group = parent_group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
ifc.run(
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
)
new_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=new_group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=new_group, products=[new_drawing])
@@ -427,7 +431,7 @@ def duplicate_drawing(
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
@@ -622,7 +626,8 @@ def sync_references(
for reference_element in potential_reference_elements:
if not drawing_tool.get_drawing_reference_annotation(drawing, reference_element):
if annotation := drawing_tool.generate_reference_annotation(drawing, reference_element, context):
annotation = drawing_tool.generate_reference_annotation(drawing, reference_element, context)
if annotation:
ifc.run("drawing.assign_product", relating_product=reference_element, related_object=annotation)
ifc.run("group.assign_group", group=group, products=[annotation])
collector.assign(ifc.get_object(annotation))
+4 -3
View File
@@ -50,14 +50,15 @@ def copy_z_rotation_to_selected(
flip: bool = False,
) -> int:
"""Apply ``active``'s Z-Euler rotation to each target."""
source_z = surveyor.get_z_rotation(active)
source_z = surveyor.get_z_rotation(active) # ty: ignore[missing-argument]
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
target_z = surveyor.get_z_rotation(obj) # ty: ignore[missing-argument]
if abs(_z_rotation_diff(target_z, source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z)
surveyor.set_z_rotation(obj, source_z) # ty: ignore[missing-argument]
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
+1 -1
View File
@@ -804,7 +804,7 @@ class Profile:
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def get_geom_generation(cls): pass
def refresh_post_commit(cls, operator) -> None: pass
+2 -1
View File
@@ -120,7 +120,8 @@ class Collector(bonsai.core.tool.Collector):
project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection)
elif element.is_a("IfcAnnotation") and (drawing_obj := cls.get_annotation_drawing_obj(element)):
cls.link_collection_object_safe(tool.Blender.get_object_bim_props(drawing_obj).collection, obj)
target_collection = tool.Blender.get_object_bim_props(drawing_obj).collection
cls.link_collection_object_safe(target_collection, obj)
elif container := ifcopenshell.util.element.get_container(element):
while container.is_a("IfcSpace"):
container = ifcopenshell.util.element.get_aggregate(container)
+20 -8
View File
@@ -1953,19 +1953,29 @@ class Drawing(bonsai.core.tool.Drawing):
if camera.data.type != "ORTHO":
return
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geometry = ifcopenshell.geom.create_shape(settings, axis.AxisCurve)
verts = ifcopenshell.util.shape.get_vertices(geometry)
grid = (axis.PartOfU or axis.PartOfV or axis.PartOfW)[0]
m = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
axis_obj = tool.Ifc.get_object(axis)
if axis_obj and axis_obj.data and len(axis_obj.data.vertices) >= 2:
m = np.array(axis_obj.matrix_world)
verts = [np.array(v.co) for v in axis_obj.data.vertices[:2]]
else:
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geometry = ifcopenshell.geom.create_shape(settings, axis.AxisCurve)
verts = list(ifcopenshell.util.shape.get_vertices(geometry)[:2])
grid_obj = tool.Ifc.get_object(grid)
if grid_obj:
m = np.array(grid_obj.matrix_world)
else:
m = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
im = camera.matrix_world.inverted()
v1, v2 = [im @ Vector((m @ np.append(v, 1.0))[:3]) for v in verts[:2]]
v1, v2 = [im @ Vector((m @ np.append(v[:3], 1.0))[:3]) for v in verts]
target_view = tool.Drawing.get_drawing_target_view(drawing)
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW"):
bounds = helper.ortho_view_frame(camera.data)
if not (points := helper.clip_segment(bounds, [v1, v2])):
points = helper.clip_segment(bounds, [v1, v2])
if not points:
return
elif target_view in ("ELEVATION_VIEW", "SECTION_VIEW"):
bounds = helper.ortho_view_frame(camera.data)
@@ -2183,6 +2193,7 @@ class Drawing(bonsai.core.tool.Drawing):
def sync_object_placement(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
blender_matrix = np.array(obj.matrix_world)
element = tool.Ifc.get_entity(obj)
is_moved = tool.Ifc.is_moved(obj)
if tool.Geometry.is_scaled(obj):
bpy.ops.bim.update_representation(obj=obj.name)
return element
@@ -2199,7 +2210,8 @@ class Drawing(bonsai.core.tool.Drawing):
grid_obj = tool.Ifc.get_object(grid)
if grid_obj:
cls.sync_object_placement(grid_obj)
if grid_obj.matrix_world != obj.matrix_world:
matrices_differ = grid_obj.matrix_world != obj.matrix_world
if matrices_differ:
bpy.ops.bim.update_representation(obj=obj.name)
tool.Geometry.record_object_position(obj)
+5
View File
@@ -2670,6 +2670,11 @@ class Geometry(bonsai.core.tool.Geometry):
# copy the actual class
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
# Give each duplicated IfcGridAxis its own AxisCurve so it doesn't
# share geometry with the source axis.
if new and new.is_a("IfcGridAxis"):
tool.Model.create_axis_curve(new_obj, new)
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
if new and temp_data and not new.is_a("IfcGridAxis"):
+1
View File
@@ -59,6 +59,7 @@ from ifcopenshell.util.shape_builder import ShapeBuilder, np_to_3d
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.model
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
@@ -920,6 +920,21 @@ Scenario: Export IFC - with moved grid axis location synchronised
And I load previously saved IFC project
Then the object "IfcGridAxis/01" bottom left corner is at "1,-2,0"
Scenario: Export IFC - with duplicate-of-duplicate grid axis locations preserved
Given an empty IFC project
And I press "bim.add_grid"
And I set "scene.BIMGridProperties.is_locked" to "False"
And the object "IfcGridAxis/01" is selected
And I duplicate the selected objects
And the object "IfcGridAxis/01.001" is moved to "1,0,0"
And the object "IfcGridAxis/01.001" is selected
And I duplicate the selected objects
And the object "IfcGridAxis/01.002" is moved to "2,0,0"
When I save IFC project
And I load previously saved IFC project
Then the object "IfcGridAxis/01.001" bottom left corner is at "1,-2,0"
And the object "IfcGridAxis/01.002" bottom left corner is at "2,-2,0"
Scenario: Export IFC - with changed object scale ignored
Given an empty IFC project
And I add a cube
@@ -35,6 +35,7 @@ from unittest.mock import Mock, patch
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import pytest
import bonsai.tool as tool
@@ -37,6 +37,8 @@ from unittest.mock import patch
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.element
import pytest
import bonsai.tool as tool
@@ -146,7 +146,9 @@ def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
) as bend, patch.object(
mep.MEPAddTransition, "_execute", return_value=None
) as transition:
mep.FitFlowSegments._execute(op, context=context)
assert obstruction.call_count == 1
@@ -178,7 +180,9 @@ def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
mep.tool.Model, "get_flow_segment_profile", return_value=profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
) as bend, patch.object(
mep.MEPAddTransition, "_execute", return_value=None
) as transition:
mep.FitFlowSegments._execute(op, context=context)
obstruction.assert_not_called()
@@ -173,8 +173,9 @@ def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicat
predicate = getattr(tool.Parametric, is_element_predicate)
fake_element = Mock()
fake_element.is_a.return_value = True
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
tool.System, "has_parametric_body", return_value=True
with (
patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p,
patch.object(tool.System, "has_parametric_body", return_value=True),
):
cls.is_element_type(fake_element)
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
@@ -139,6 +139,5 @@ def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
assert not orphaned, (
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
+ "\n ".join(orphaned)
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n " + "\n ".join(orphaned)
)
+1
View File
@@ -24,6 +24,7 @@ import time
import bpy
import ifcopenshell
import ifcopenshell.util.element
import pytest
from bonsai import tool as tool
+1
View File
@@ -23,6 +23,7 @@ import bpy
import ifcopenshell
import ifcopenshell.api.geometry
import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.style
import ifcopenshell.api.type
+5 -3
View File
@@ -57,7 +57,8 @@ class CsvHeader(TypedDict):
# Formula
Formula: NotRequired[str]
#QuantityClass: NotRequired[str]
# QuantityClass: NotRequired[str]
# Currently we assume that if column is not part of the main header,
# then it is a cost value category. So here we list any additional column
@@ -97,7 +98,8 @@ class CostItem(TypedDict):
Query: Union[str, None]
Formula: Union[str, None]
#QuantityClass: Union[str, None]
# QuantityClass: Union[str, None]
class Csv2Ifc:
# Inputs.
@@ -420,7 +422,7 @@ class Csv2Ifc:
products=results,
formula=cost_item["Formula"],
ifc_class=ifc_quantity_class,
)
)
self.create_cost_items(cost_item["children"], cost_item["ifc"])
@@ -231,7 +231,7 @@ def open(
kwargs = {"mmap": mmap}
if logger is not None:
kwargs["logger"] = logger
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument]
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs)
else:
f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ()))
return file(f)
@@ -22,8 +22,6 @@ import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
@@ -22,28 +22,11 @@ import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment import _map_alignment_cant_segment
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.util.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
def _add_segment_to_layout(
@@ -18,11 +18,7 @@
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.alignment
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -> None:
@@ -18,6 +18,7 @@
import ifcopenshell.api.alignment
import ifcopenshell.geom
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment
from typing import Union
@@ -51,11 +51,11 @@ def add_positioning_referent(
ifcopenshell.api.alignment.add_positioning_referent(model,name="Pier 1 Sta 1+00",alignment=alignment,distance_along=0.0,station=100.0,positioned_product=pier)
"""
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
curve = ifcopenshell.api.alignment.get_curve(alignment)
object_placement = None
representation = None
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
@@ -63,7 +63,7 @@ def add_positioning_referent(
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=basis_curve,
BasisCurve=curve,
)
),
)
@@ -34,6 +34,7 @@ def add_stationing_referent(
distance_along: float,
station: float,
incoming_station: Optional[float] = None,
on_basis_curve: Optional[bool] = None,
) -> entity_instance:
"""
Adds an IfcReferent to the alignment that defines the stationing system.
@@ -43,6 +44,7 @@ def add_stationing_referent(
:param distance_along: distance along the alignment basis curve
:param station: station value
:param incoming_station: station value of the incoming segment, only set to specify a station equation
:param on_basis_curve: whether the referent is positioned on the basis curve or the alignment curve, if None the function will default to the basis curve
:return: referent
Example:
@@ -53,11 +55,18 @@ def add_stationing_referent(
ifcopenshell.api.alignment.add_stationing_referent(model,name="1+00.0",alignment=alignment,distance_along=0.0,station=100.0)
"""
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
if on_basis_curve is None:
on_basis_curve = True
curve = (
ifcopenshell.api.alignment.get_basis_curve(alignment)
if on_basis_curve
else ifcopenshell.api.alignment.get_curve(alignment)
)
object_placement = None
representation = None
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
@@ -65,7 +74,7 @@ def add_stationing_referent(
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=basis_curve,
BasisCurve=curve,
)
),
)
@@ -23,18 +23,8 @@ import ifcopenshell.api.alignment
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
import ifcopenshell.ifcopenshell_wrapper as wrapper
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
_update_curve_segment_transition_code,
)
@@ -87,9 +87,7 @@ def create(
_create_geometric_representation(file, alignment)
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, referent_name, alignment, 0.0, start_station
)
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
for layout in alignment_layouts:
_add_zero_length_segment(file, layout)
@@ -21,9 +21,7 @@ from typing import Union
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.geom
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._add_segment_to_layout import _add_segment_to_layout
@@ -73,7 +73,10 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
return station - start_station
stations = [
(_distance_along_of_referent(referent), ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"))
(
_distance_along_of_referent(referent),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
)
for referent in referent_nest.RelatedObjects
]
stations.sort(key=lambda entry: entry[0])
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
from ifcopenshell import entity_instance
@@ -19,6 +19,7 @@
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.placement
from ifcopenshell import entity_instance
@@ -36,12 +36,9 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
p = ifcopenshell.util.placement.get_local_placement(lp)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(p[0, 3])*unit_scale
y = float(p[1, 3])*unit_scale
z = float(p[2, 3])*unit_scale
x = float(p[0, 3])
y = float(p[1, 3])
z = float(p[2, 3])
rx = float(p[0, 0])
ry = float(p[1, 0])
@@ -117,7 +117,7 @@ def assign_cost_item_quantity(
"products": products or [],
"prop_name": prop_name,
"formula": formula,
"ifc_class" : ifc_class
"ifc_class": ifc_class,
}
return usecase.execute()
@@ -134,7 +134,7 @@ class Usecase:
continue
self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"])
if self.settings["formula"]:
tree = ast.parse(self.settings["formula"], mode = "eval")
tree = ast.parse(self.settings["formula"], mode="eval")
collector = VariableExtractor()
collector.visit(tree)
variables = collector.variables
@@ -144,10 +144,10 @@ class Usecase:
value = getter(product, variable)
if value is None:
print(
f"WARNING: Variable '{variable}' in product '{product.Name}' "
f"is missing (None). Check Pset/Qset or property name."
)
print(
f"WARNING: Variable '{variable}' in product '{product.Name}' "
f"is missing (None). Check Pset/Qset or property name."
)
elif value == 0:
print(
f"WARNING: Variable '{variable}' in product '{product.Name}' "
@@ -159,7 +159,9 @@ class Usecase:
new_quantity = None
for quantity in self.quantities:
if quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1: #Todo improve it
if (
quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1
): # Todo improve it
new_quantity = quantity
self.settings["ifc_class"] = quantity.is_a()
continue
@@ -184,23 +186,23 @@ class Usecase:
self.update_cost_item_count()
def get_value_from_pset(
self,
product:ifcopenshell.entity_instance,
v: str,
self,
product: ifcopenshell.entity_instance,
v: str,
) -> float:
pset_name = v.split(".")[0]
pset = ifcopenshell.util.element.get_pset(product, pset_name)
pset_property_name = v.split(".")[1]
return (pset or {}).get(pset_property_name,None)
return (pset or {}).get(pset_property_name, None)
def get_value_from_qset(
self,
product:ifcopenshell.entity_instance,
v: str,
self,
product: ifcopenshell.entity_instance,
v: str,
) -> float:
qtos = ifcopenshell.util.element.get_psets(product, qtos_only = True)
qtos = ifcopenshell.util.element.get_psets(product, qtos_only=True)
quantities = next(iter(qtos.values()), {})
return (quantities or {}).get(v,None)
return (quantities or {}).get(v, None)
def assign_cost_control(
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
@@ -243,6 +245,7 @@ class Usecase:
count += 1
quantity[3] = count
OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
@@ -252,18 +255,20 @@ OPERATORS = {
ast.USub: operator.neg,
}
def build_full_name(node):
#used for variables with dots
# used for variables with dots
parts = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
class VariableExtractor(ast.NodeVisitor):
def __init__(self):
self.variables = set()
@@ -274,6 +279,7 @@ class VariableExtractor(ast.NodeVisitor):
def visit_Attribute(self, node):
self.variables.add(build_full_name(node))
class FormulaEvaluator(ast.NodeVisitor):
def __init__(self, values):
self.values = values
@@ -281,7 +287,7 @@ class FormulaEvaluator(ast.NodeVisitor):
def visit_BinOp(self, node):
left = self.visit(node.left)
right = self.visit(node.right)
return OPERATORS[type(node.op)](left, right)
return OPERATORS[type(node.op)](left, right) # ty: ignore[too-many-positional-arguments]
def visit_Name(self, node):
return self.values[node.id]
@@ -78,7 +78,8 @@ def create_axis_curve(
points /= unit_scale
grid = next(i for i in file.get_inverse(grid_axis) if i.is_a("IfcGrid"))
grid_matrix_i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement))
grid_placement = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
grid_matrix_i = np.linalg.inv(grid_placement)
p1, p2 = ifc_safe_vector_type(np_apply_matrix(points, grid_matrix_i))
grid_axis.AxisCurve = file.create_entity(
"IfcPolyline",
@@ -88,5 +89,5 @@ def create_axis_curve(
),
)
if existing_curve:
if existing_curve and file.get_total_inverses(existing_curve) == 0:
ifcopenshell.util.element.remove_deep2(file, existing_curve)
@@ -221,8 +221,7 @@ for id in to_emit:
statements.append("%s << %s" % (id, stmt))
if __name__ == "__main__":
print(
r"""
print(r"""
# This file is generated by IfcOpenShell ifcexpressparser bootstrap.py
from __future__ import annotations
@@ -261,6 +260,4 @@ if __name__ == "__main__":
mdl = importlib.import_module(output)
mdl.Generator(m).emit()
sys.stdout.write(m.schema.name)
"""
% ("\n ".join(statements))
)
""" % ("\n ".join(statements)))
@@ -695,6 +695,7 @@ codegen_rule("MOD", lambda context: "%")
codegen_rule("TRUE", lambda context: "True")
codegen_rule("FALSE", lambda context: "False")
def _dotted_name(node: ast.AST):
"""Return dotted name for Name/Attribute chains, else None."""
if isinstance(node, ast.Name):
@@ -704,6 +705,7 @@ def _dotted_name(node: ast.AST):
return f"{base}.{node.attr}" if base else node.attr
return None
class AttributeGetattrTransformer(ast.NodeTransformer):
def visit_Attribute(self, node):
parents = []
@@ -720,7 +722,7 @@ class AttributeGetattrTransformer(ast.NodeTransformer):
if isinstance(node.ctx, ast.Store):
return node
if _dotted_name(node) in ('ifcopenshell.create_entity', 'str.lower'):
if _dotted_name(node) in ("ifcopenshell.create_entity", "str.lower"):
return node
if node.attr.startswith("__"):
@@ -363,24 +363,18 @@ class EarlyBoundCodeWriter:
)
)
self.statements[self.statements.index("{factory_placeholder}")] = (
"""
self.statements[self.statements.index("{factory_placeholder}")] = """
class %(schema_name)s_instance_factory : public IfcParse::instance_factory {
virtual IfcUtil::IfcBaseClass* operator()(const IfcParse::declaration* decl, IfcEntityInstanceData&& data) const {
%(instance_mapping)s
}
};
"""
% locals()
)
""" % locals()
""
self.statements[self.statements.index("{string_pool_placeholder}")] = (
"""
self.statements[self.statements.index("{string_pool_placeholder}")] = """
const std::string strings[] = {%s};
"""
% ",".join(map(lambda s: '"%s"s' % s, self.strings))
)
""" % ",".join(map(lambda s: '"%s"s' % s, self.strings))
def __str__(self):
return "\n".join(self.statements)
@@ -145,8 +145,7 @@ class configuration:
config.set(
"snippets",
"print all wall ids",
self.config_encode(
"""
self.config_encode("""
###########################################################################
# A simple script that iterates over all walls in the current model #
# and prints their Globally unique IDs (GUIDS) to the console window #
@@ -154,15 +153,13 @@ class configuration:
for wall in model.by_type("IfcWall"):
print ("wall with global id: "+str(wall.GlobalId))
""".lstrip()
),
""".lstrip()),
)
config.set(
"snippets",
"print properties of current selection",
self.config_encode(
"""
self.config_encode("""
###########################################################################
# A simple script that iterates over all IfcPropertySets of the currently #
# selected object and prints them to the console #
@@ -180,8 +177,7 @@ if selection:
for prop in relDefinesByProperties.RelatingPropertyDefinition.HasProperties:
print ("{:<20} :{}".format(prop.Name,prop.NominalValue.wrappedValue))
print ("\\n")
""".lstrip()
),
""".lstrip()),
)
with open(conf_file, "w") as configfile:
config.write(configfile)
@@ -1697,10 +1697,16 @@ class uninitialized_tag: ...
def arrange_polygons(settings, polygons): ...
def clear_schemas(): ...
def construct_iterator(geometry_library, settings, file, num_threads): ...
def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ...
def construct_iterator_with_include_exclude_globalid(geometry_library, settings, file, elems, include, num_threads): ...
def construct_iterator_with_include_exclude_id(geometry_library, settings, file, elems, include, num_threads): ...
def construct_iterator(geometry_library, settings, file, num_threads, logger=...): ...
def construct_iterator_with_include_exclude(
geometry_library, settings, file, elems, include, num_threads, logger=...
): ...
def construct_iterator_with_include_exclude_globalid(
geometry_library, settings, file, elems, include, num_threads, logger=...
): ...
def construct_iterator_with_include_exclude_id(
geometry_library, settings, file, elems, include, num_threads, logger=...
): ...
def convert_loop_to_function_item(loop): ...
def create_box(*args): ...
def create_epeck(*args): ...
@@ -1717,8 +1723,8 @@ def line_segments_to_polygons(s, eps, segments): ...
def map_shape(settings, instance): ...
def nary_union(sequence): ...
def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ...
def open(fn: str, readonly: bool = False) -> file: ...
def parse_ifcxml(filename): ...
def open(fn: str, readonly: bool = False, logger=...) -> file: ...
def parse_ifcxml(filename, logger=...): ...
def polygons_to_svg(*args): ...
def read(data): ...
def register_schema(arg1): ...
@@ -56,7 +56,7 @@ def append_zero_length_segments(file: ifcopenshell.file) -> ifcopenshell.file:
for alignment in alignments:
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
for layout in layouts:
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout, include_referent=False)
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve:
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, curve)
@@ -355,8 +355,7 @@ def get_cost_rate(
class CostValueUnserialiser:
def parse(self, formula: str):
l = lark.Lark(
"""start: formula
l = lark.Lark("""start: formula
formula: operand (operator operand)*
operand: value | category "(" formula ")"
value: NUMBER?
@@ -393,8 +392,7 @@ class CostValueUnserialiser:
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
"""
)
""")
start = l.parse(formula)
return self.get_formula(start.children[0])
@@ -39,8 +39,7 @@ import ifcopenshell.util.shape
import ifcopenshell.util.system
import ifcopenshell.util.unit
filter_elements_grammar = lark.Lark(
"""start: filter_group
filter_elements_grammar = lark.Lark("""start: filter_group
filter_group: facet_list ("+" facet_list)*
facet_list: facet ("," facet)*
@@ -111,11 +110,9 @@ filter_elements_grammar = lark.Lark(
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
"""
)
""")
get_element_grammar = lark.Lark(
"""start: keys
get_element_grammar = lark.Lark("""start: keys
keys: key ("." key)*
key: quoted_string | regex_string | unquoted_string
@@ -130,11 +127,9 @@ get_element_grammar = lark.Lark(
WS: /[ \\t\\f\\r\\n]/+
%ignore WS // Disregard spaces in text
"""
)
""")
format_grammar = lark.Lark(
"""start: expression
format_grammar = lark.Lark("""start: expression
?expression: add_sub
?add_sub: mul_div
@@ -193,8 +188,7 @@ format_grammar = lark.Lark(
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
"""
)
""")
class FormatTransformer(lark.Transformer):
@@ -0,0 +1,115 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.element
def _create_test_file():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _create_test_alignment_with_vertical(file):
# include_vertical=True so that get_curve() (IfcGradientCurve, on the "Axis" representation)
# and get_basis_curve() (IfcCompositeCurve, on the "FootPrint" representation) are different
# entities, letting the on_basis_curve option be observed.
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True, start_station=0.0)
assert ifcopenshell.api.alignment.get_basis_curve(alignment).is_a("IfcCompositeCurve")
assert ifcopenshell.api.alignment.get_curve(alignment).is_a("IfcGradientCurve")
assert ifcopenshell.api.alignment.get_basis_curve(alignment) != ifcopenshell.api.alignment.get_curve(alignment)
return alignment
def _assert_common_referent_asserts(referent, name, station):
assert referent.is_a("IfcReferent")
assert referent.PredefinedType == "STATION"
assert referent.Name == name
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == station
assert referent.ObjectPlacement != None
def test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve():
# on_basis_curve=None should behave the same as on_basis_curve=True
file = _create_test_file()
alignment = _create_test_alignment_with_vertical(file)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=None
)
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
alignment
)
def test_add_stationing_referent_on_basis_curve_true():
file = _create_test_file()
alignment = _create_test_alignment_with_vertical(file)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=True
)
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
alignment
)
def test_add_stationing_referent_on_basis_curve_false():
# with a vertical layout present, on_basis_curve=False positions the referent on the
# alignment curve (IfcGradientCurve) rather than on the basis curve (IfcCompositeCurve).
file = _create_test_file()
alignment = _create_test_alignment_with_vertical(file)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=False
)
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
basis_curve = referent.ObjectPlacement.RelativePlacement.Location.BasisCurve
assert basis_curve == ifcopenshell.api.alignment.get_curve(alignment)
assert basis_curve != ifcopenshell.api.alignment.get_basis_curve(alignment)
test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve()
test_add_stationing_referent_on_basis_curve_true()
test_add_stationing_referent_on_basis_curve_false()
@@ -21,9 +21,12 @@ import math
import pytest
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import numpy as np
import ifcopenshell.util.unit
def test_create_representation():
@@ -0,0 +1,88 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell.api.grid
import test.bootstrap
class TestCreateAxisCurve(test.bootstrap.IFC4):
def make_grid_with_axis(self, axis_tag="A"):
grid = self.file.createIfcGrid()
grid.ObjectPlacement = self.file.createIfcLocalPlacement(
RelativePlacement=self.file.createIfcAxis2Placement3D(
Location=self.file.createIfcCartesianPoint([0.0, 0.0, 0.0])
)
)
axis = ifcopenshell.api.grid.create_grid_axis(
self.file, axis_tag=axis_tag, same_sense=True, uvw_axes="UAxes", grid=grid
)
return grid, axis
def test_creates_a_polyline_axis_curve(self):
_, axis = self.make_grid_with_axis()
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
)
assert axis.AxisCurve is not None
assert axis.AxisCurve.is_a("IfcPolyline")
assert len(axis.AxisCurve.Points) == 2
def test_replaces_existing_curve_when_unshared(self):
"""Calling create_axis_curve again on the same axis replaces the old curve
and removes the old curve from the file when nothing else references it."""
_, axis = self.make_grid_with_axis()
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
)
old_curve_id = axis.AxisCurve.id()
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([1.0, 0.0, 0.0]), p2=np.array([11.0, 0.0, 0.0]), grid_axis=axis
)
assert axis.AxisCurve.id() != old_curve_id
assert self.file.by_id(old_curve_id) is None
def test_does_not_remove_shared_curve(self):
"""When two axes share the same AxisCurve (e.g. after a shallow copy during
duplication), updating one axis must not destroy the curve still referenced
by the other axis."""
grid, axis = self.make_grid_with_axis()
axis2 = ifcopenshell.api.grid.create_grid_axis(
self.file, axis_tag="B", same_sense=True, uvw_axes="UAxes", grid=grid
)
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
)
shared_curve = axis.AxisCurve
shared_curve_id = shared_curve.id()
# Simulate what copy_class produces: a duplicate axis that shares the
# source's AxisCurve rather than having its own copy.
axis2.AxisCurve = shared_curve
assert self.file.get_total_inverses(shared_curve) == 2
# Updating axis1's curve must not remove the curve that axis2 still needs.
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([1.0, 0.0, 0.0]), p2=np.array([11.0, 0.0, 0.0]), grid_axis=axis
)
assert axis2.AxisCurve.id() == shared_curve_id
assert self.file.by_id(shared_curve_id) is not None
class TestCreateAxisCurveIFC2X3(test.bootstrap.IFC2X3, TestCreateAxisCurve):
pass
@@ -1,5 +1,6 @@
import ifcopenshell
def test_skip_over_non_entity_instance():
data = """
ISO-10303-21;
+1 -1
View File
@@ -46,4 +46,4 @@ def test_file(filename):
if __name__ == "__main__":
pytest.main(["-sx", __file__, '--import-mode=importlib'])
pytest.main(["-sx", __file__, "--import-mode=importlib"])
@@ -111,7 +111,7 @@ class Patcher(ifcpatch.BasePatcher):
if element.is_a("IfcProject"):
proj = self.new.add(element)
for ctx in element.RepresentationContexts or ():
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
for coop in getattr(ctx, "HasCoordinateOperation", ()):
self.new.add(coop)
return proj
return ifcopenshell.api.project.append_asset(
@@ -33,9 +33,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
Points=point_list,
Segments=segments,
)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
return curve
def test_run_without_segments(self):
@@ -80,9 +78,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
Points=point_list,
Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))],
)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
@@ -110,9 +106,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
self.file.createIfcLineIndex((3, 4)),
],
)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
+2
View File
@@ -977,12 +977,14 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
if (item == nullptr) {
throw IfcParse::IfcException("Failed to convert placement");
}
/*
if (st.get<ifcopenshell::geometry::settings::ConvertBackUnits>().get()) {
// we pass the settings to the Transformation object, but access the data just offloads to the
// generic cartesian_base<Matrix4> so there's no time to apply the settings to the translation part.
item = ifcopenshell::geometry::taxonomy::matrix4::ptr(item->clone_());
item->components().col(3).head<3>() /= kernel.settings().get<ifcopenshell::geometry::settings::LengthUnit>().get();
}
*/
return new IfcGeom::Transformation(kernel.settings(), item);
} else {
if (!representation) {