Compare commits

...

5 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
9 changed files with 140 additions and 16 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)
+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:
+2 -1
View File
@@ -626,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))
+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"):
@@ -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
@@ -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)
@@ -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