diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 4eb1664d1a..2b41569090 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -205,6 +205,7 @@ def generate_space( x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor() 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() @@ -235,7 +236,6 @@ def generate_space( if element and element.is_a("IfcSpace"): assert active_obj - active_obj.location.z = z spatial.set_space_representation_from_polygon( active_obj, element, diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index a33c058ecb..34db9eaa50 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -890,9 +890,13 @@ class Spatial(bonsai.core.tool.Spatial): # 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 ifcopenshell.util.space.BOUNDING_CLASSES): + 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() @@ -955,6 +959,55 @@ class Spatial(bonsai.core.tool.Spatial): 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, @@ -963,17 +1016,14 @@ class Spatial(bonsai.core.tool.Spatial): item: ifcopenshell.entity_instance, ) -> None: """Assign a representation item (clipped solid or B-rep) to the element.""" - old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - if old_body: - context = old_body.ContextOfItems - ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=element, representation=old_body) - ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=old_body) - else: - context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") + 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(tool.Ifc.get()) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) new_body = builder.get_representation(context, item) - ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), product=element, representation=new_body) + ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body) bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -1292,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) @@ -1346,6 +1392,11 @@ class Spatial(bonsai.core.tool.Spatial): 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 diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 986f5da041..534eb76592 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -504,7 +504,7 @@ class TestGenerateSpace(NewFile): 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, 4.5, atol=0.01), f"Expected location.z=4.5, got {obj.location.z}" + assert np.isclose(obj.location.z, 5.0, atol=0.01), f"Expected location.z=5.0, got {obj.location.z}" class TestGenerateSpaceSlopedRoof(NewFile): @@ -641,6 +641,7 @@ class TestRegenerateSpaceFromRealIfc2x3(NewFile): 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 []): @@ -662,7 +663,7 @@ class TestRegenerateSpaceFromRealIfc2x3(NewFile): tool.Spatial.get_active_obj = original_get_active_obj regen_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices]) - return original_bounds, ( + regen_bounds = ( regen_verts[:, 0].min(), regen_verts[:, 0].max(), regen_verts[:, 1].min(), @@ -670,18 +671,150 @@ class TestRegenerateSpaceFromRealIfc2x3(NewFile): 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, regen = self._regenerate_space(ifc, 5710) - for o, r in zip(original, regen): + (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, regen = self._regenerate_space(ifc, 2363) - for o, r in zip(original, regen): - assert r == pytest.approx(o, abs=0.02) + (original_bounds, original_origin), (regen_bounds, regen_origin) = self._regenerate_space(ifc, 2363) + assert (regen_origin - original_origin).length < 0.02 + # X, Y, Z-min stable; Z-max may differ because the regenerated space + # correctly detects the roof and clips to a different top elevation. + for j in (0, 1, 4): + assert regen_bounds[j] == pytest.approx(original_bounds[j], abs=0.02) + + 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):