diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py index 4f9692b49c..d507481141 100644 --- a/src/bonsai/bonsai/bim/export_ifc.py +++ b/src/bonsai/bonsai/bim/export_ifc.py @@ -113,7 +113,6 @@ class IfcExporter: # Ensure reflected ceiling cameras have the correct scale if obj.scale != (-1, -1, -1): obj.scale = (-1, -1, -1) - obj.rotation_euler = (0.0, 0.0, radians(180)) # Skip all other scale handling for cameras elif tool.Geometry.is_scaled(obj): bpy.ops.bim.update_representation(obj=obj.name) diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index 7cbfb0ed93..344c247af2 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -282,7 +282,11 @@ class BIM_PT_classification_references(Panel, ReferenceUI): @classmethod def poll(cls, context): - return bool((obj := context.active_object) and tool.Ifc.get_entity(obj)) + return ( + (obj := tool.Blender.get_active_object()) + and (element := tool.Ifc.get_entity(obj)) + and element.is_a("IfcObjectDefinition") + ) def get_object_name(self, context: bpy.types.Context) -> str: assert (obj := context.active_object) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index f271d3f47f..d657166346 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -210,12 +210,6 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator): should_duplicate_annotations=self.should_duplicate_annotations, ) - # TODO: Why need to resync active drawing, if it wasn't changed. - drawing = props.get_active_drawing() - if drawing is None: - return - core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=drawing) - class CreateDrawing(bpy.types.Operator): """Creates/refreshes a .svg drawing @@ -2358,14 +2352,14 @@ class ActivateDrawingBase(tool.Ifc.Operator): is_reflected = ifcopenshell.util.element.get_pset(camera_element, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW" if is_reflected and camera.scale != (-1, -1, -1): camera.scale = (-1, -1, -1) - camera.rotation_euler = (0.0, 0.0, radians(180)) + if camera_props.update_representation(camera.matrix_world): bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="") # Restore the scale after update if needed if is_reflected: camera.scale = (-1, -1, -1) - camera.rotation_euler = (0.0, 0.0, radians(180)) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index 385df0c749..77a2c8b63b 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -112,7 +112,6 @@ def block_scale(scene: bpy.types.Scene) -> None: # Only update if scale isn't already (-1, -1, -1) if obj.scale != (-1, -1, -1): obj.scale = (-1, -1, -1) - obj.rotation_euler = (0.0, 0.0, math.radians(180)) else: if obj.scale != (1, 1, 1): obj.scale = (1, 1, 1) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 148937e577..2f5b9c906e 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2522,6 +2522,7 @@ class FlipClippingPlane(bpy.types.Operator): obj = context.active_object if obj in tool.Project.get_project_props().clipping_planes_objs: obj.rotation_euler[0] += radians(180) + obj.rotation_euler[0] %= radians(360) context.view_layer.update() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 5682c00c81..2cbdeb6e7b 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -450,9 +450,9 @@ class BIMProjectProperties(PropertyGroup): self.mvd = header_data.mvd self.author_name = header_data.author_name self.author_email = header_data.author_email - self.organisation_name = header_data.organization_name - self.organisation_email = header_data.organization_email - self.authorisation = header_data.authorization + self.organisation_name = header_data.organisation_name + self.organisation_email = header_data.organisation_email + self.authorisation = header_data.authorisation if TYPE_CHECKING: is_editing: bool diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index ab11c5f1ce..abd3a8b90f 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -242,14 +242,11 @@ class BIM_PT_object_psets(Panel): @classmethod def poll(cls, context): - if not (obj := context.active_object): - return False - ifc_id = tool.Blender.get_ifc_definition_id(obj) - if not ifc_id: - return False - if not tool.Ifc.get_object_by_identifier(ifc_id): - return False - return True + return ( + (obj := tool.Blender.get_active_object()) + and (element := tool.Ifc.get_entity(obj)) + and element.is_a("IfcObjectDefinition") + ) def draw(self, context): if not ObjectPsetsData.is_loaded: @@ -388,14 +385,11 @@ class BIM_PT_object_qtos(Panel): @classmethod def poll(cls, context): - if not (obj := context.active_object): - return False - ifc_id = tool.Blender.get_ifc_definition_id(obj) - if not ifc_id: - return False - if not tool.Ifc.get_object_by_identifier(ifc_id): - return False - return True + return ( + (obj := tool.Blender.get_active_object()) + and (element := tool.Ifc.get_entity(obj)) + and element.is_a("IfcObjectDefinition") + ) def draw(self, context): if not ObjectQtosData.is_loaded: diff --git a/src/bonsai/bonsai/bim/module/search/data.py b/src/bonsai/bonsai/bim/module/search/data.py index 34f032c2b5..4bde0aa359 100644 --- a/src/bonsai/bonsai/bim/module/search/data.py +++ b/src/bonsai/bonsai/bim/module/search/data.py @@ -101,7 +101,7 @@ class ColourByPropertyData: elif pset.endswith("BaseQuantities"): keys.extend([f'/.*BaseQuantities/."{name}"' for name in properties.keys() if name != "id"]) else: - keys.extend([f"{pset}.{name}" for name in properties.keys() if name != "id"]) + keys.extend([f'"{pset}"."{name}"' for name in properties.keys() if name != "id"]) results = [(k, k, "") for k in keys] return default + results diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index dd08278bc9..f749ceb07e 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -34,14 +34,11 @@ class BIM_PT_type(Panel): @classmethod def poll(cls, context): - if not context.active_object: - return False - element = tool.Ifc.get_entity(context.active_object) - if not element: - return False - if not element.is_a("IfcProduct") and not element.is_a("IfcTypeProduct"): - return True - return True + return ( + (obj := tool.Blender.get_active_object()) + and (element := tool.Ifc.get_entity(obj)) + and (element.is_a("IfcObject") or element.is_a("IfcTypeObject")) + ) def draw(self, context): if not TypeData.is_loaded: diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index c10c88b461..2e2d07c96c 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -316,6 +316,7 @@ def duplicate_drawing( ) -> ifcopenshell.entity_instance: drawing_name = drawing_tool.ensure_unique_drawing_name(drawing_tool.get_name(drawing)) new_drawing = ifc.run("root.copy_class", product=drawing) + drawing_tool.clear_annotation_relationships(new_drawing) drawing_tool.copy_representation(drawing, new_drawing) drawing_tool.set_name(new_drawing, drawing_name) group = drawing_tool.get_drawing_group(new_drawing) @@ -471,56 +472,40 @@ def sync_references( if not drawing_tool.has_annotation(drawing): return - context = drawing_tool.get_annotation_context(drawing_tool.get_drawing_target_view(drawing)) - if not context: + if not (context := drawing_tool.get_annotation_context(drawing_tool.get_drawing_target_view(drawing))): return group = drawing_tool.get_drawing_group(drawing) potential_reference_elements = drawing_tool.get_potential_reference_elements(drawing) + + for element in potential_reference_elements: + if (obj := ifc.get_object(element)) and ifc.is_moved(obj): + drawing_tool.sync_object_placement(obj) + for element in drawing_tool.get_group_elements(group): - if ( - drawing_tool.is_auto_annotation(element) - and drawing_tool.get_assigned_product(element) not in potential_reference_elements - ): + if not drawing_tool.is_auto_annotation(element): + continue + if (obj := ifc.get_object(element)) and ifc.is_moved(obj): + drawing_tool.sync_object_placement(obj) + reference_element = drawing_tool.get_assigned_product(element) + reference_obj = ifc.get_object(reference_element) + if reference_element not in potential_reference_elements: + # It was auto created, so it makes sense to auto delete + if obj := ifc.get_object(element): + drawing_tool.delete_object(obj) + ifc.run("root.remove_product", product=element) + elif not drawing_tool.regenerate_reference_annotation(drawing, element, reference_element, context): if obj := ifc.get_object(element): drawing_tool.delete_object(obj) ifc.run("root.remove_product", product=element) for reference_element in potential_reference_elements: - reference_obj = ifc.get_object(reference_element) - annotation = drawing_tool.get_drawing_reference_annotation(drawing, reference_element) - - should_delete_existing_annotation = False - should_create_annotation = False - - # remove annotation only if the reference object was changed - # otherwise we rely on the existing annotation - if annotation: - if reference_obj and (ifc.is_moved(reference_obj) or ifc.is_edited(reference_obj)): - should_delete_existing_annotation = True - - if should_delete_existing_annotation or not annotation: - should_create_annotation = True - - if should_delete_existing_annotation: - annotation_obj = ifc.get_object(annotation) - if annotation_obj: - drawing_tool.delete_object(annotation_obj) - ifc.run("root.remove_product", product=annotation) - - if should_create_annotation: - annotation = drawing_tool.generate_reference_annotation(drawing, reference_element, context) - if annotation: + if not drawing_tool.get_drawing_reference_annotation(drawing, reference_element): + if annotation := drawing_tool.generate_reference_annotation(drawing, reference_element, context): 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)) - if reference_obj and ifc.is_moved(reference_obj): - drawing_tool.sync_object_placement(reference_obj) - - if reference_obj and ifc.is_edited(reference_obj): - drawing_tool.sync_object_representation(reference_obj) - def select_assigned_product(drawing: type[tool.Drawing], context: bpy.types.Context) -> None: drawing.select_assigned_product(context) diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 9acdc617a2..a53272b408 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -217,7 +217,9 @@ class Drawing(bonsai.core.tool.Drawing): return e @classmethod - def exclude_annotation_from_drawing(cls, element: ifcopenshell.entity_instance, drawing: ifcopenshell.entity_instance) -> None: + def exclude_annotation_from_drawing( + cls, element: ifcopenshell.entity_instance, drawing: ifcopenshell.entity_instance + ) -> None: pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing") if not pset: pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing") @@ -883,7 +885,13 @@ class Drawing(bonsai.core.tool.Drawing): def get_assigned_product(cls, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: for rel in element.HasAssignments: if rel.is_a("IfcRelAssignsToProduct"): - return rel.RelatingProduct + product = rel.RelatingProduct + if product.is_a("IfcGrid") and rel.Name: + for attribute in ("UAxes", "VAxes", "WAxes"): + for axis in getattr(product, attribute) or []: + if axis.AxisTag == rel.Name: + return axis + return product @classmethod def import_annotations_in_group(cls, group: ifcopenshell.entity_instance) -> None: @@ -1499,7 +1507,7 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def is_auto_annotation(cls, element: ifcopenshell.entity_instance): - return element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL") + return element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL") @classmethod def get_drawing_reference_annotation( @@ -1512,7 +1520,7 @@ class Drawing(bonsai.core.tool.Drawing): # IfcRelAssignsToProduct.RelatingProduct = IfcGrid # IfcRelAssignsToProduct.Name = IfcGridAxis.AxisTag grid = None - for attribute in ("PartOfW", "PartOfV", "PartOfU"): + for attribute in ("PartOfU", "PartOfV", "PartOfW"): if getattr(reference_element, attribute, None): grid = getattr(reference_element, attribute)[0] break @@ -1534,6 +1542,26 @@ class Drawing(bonsai.core.tool.Drawing): if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct == reference_element: return element + @classmethod + def regenerate_reference_annotation( + cls, + drawing: ifcopenshell.entity_instance, + annotation: ifcopenshell.entity_instance, + reference_element: ifcopenshell.entity_instance, + context: ifcopenshell.entity_instance, + ) -> ifcopenshell.entity_instance: + if reference_element.is_a("IfcGridAxis"): + return cls.regenerate_grid_axis_reference_annotation(drawing, annotation, reference_element, context) + elif reference_element.is_a("IfcAnnotation") and reference_element.ObjectType == "DRAWING": + target_view = ifcopenshell.util.element.get_pset(reference_element, "EPset_Drawing", "TargetView") + if target_view == "ELEVATION_VIEW": + return cls.regenerate_elevation_reference_annotation(drawing, annotation, reference_element, context) + elif target_view == "SECTION_VIEW": + return cls.regenerate_section_reference_annotation(drawing, annotation, reference_element, context) + elif reference_element.is_a("IfcBuildingStorey"): + return cls.regenerate_storey_annotation(drawing, annotation, reference_element, context) + return annotation + @classmethod def generate_reference_annotation( cls, @@ -1543,333 +1571,410 @@ class Drawing(bonsai.core.tool.Drawing): ) -> ifcopenshell.entity_instance: if reference_element.is_a("IfcGridAxis"): return cls.generate_grid_axis_reference_annotation(drawing, reference_element, context) - elif reference_element.is_a("IfcAnnotation") and reference_element.ObjectType == "DRAWING": - - def ensure_referenced_drawing_obj_exists(drawing: ifcopenshell.entity_instance): - obj = tool.Ifc.get_object(drawing) - if obj is None: - # Annotations in that drawing are lazy loaded as needed - obj = cls.import_drawing(drawing) - tool.Blender.get_layer_collection(obj.users_collection[0]).hide_viewport = True - target_view = ifcopenshell.util.element.get_pset(reference_element, "EPset_Drawing", "TargetView") if target_view == "ELEVATION_VIEW": - ensure_referenced_drawing_obj_exists(reference_element) return cls.generate_elevation_reference_annotation(drawing, reference_element, context) elif target_view == "SECTION_VIEW": - ensure_referenced_drawing_obj_exists(reference_element) return cls.generate_section_reference_annotation(drawing, reference_element, context) - elif reference_element.is_a("IfcBuildingStorey"): return cls.generate_storey_annotation(drawing, reference_element, context) + @classmethod + def generate_storey_points( + cls, drawing: ifcopenshell.entity_instance, storey: ifcopenshell.entity_instance + ) -> list | None: + import bonsai.bim.module.drawing.helper as helper + + camera = tool.Ifc.get_object(drawing) + if camera.data.type != "ORTHO": + return + if not cls.is_matrix_perpendicular(camera.matrix_world, Matrix()): + return + + xmin, xmax, ymin, ymax = helper.ortho_view_frame(camera.data)[:4] + rl = ifcopenshell.util.placement.get_local_placement(storey.ObjectPlacement)[2][3] + y = (camera.matrix_world.inverted() @ Vector((0.0, 0.0, rl))).y + if y < ymin or y > ymax: + return + + return (Vector((xmax, y, 0.0)), Vector((xmin, y, 0.0))) + @classmethod def generate_storey_annotation( cls, drawing: ifcopenshell.entity_instance, - reference_element: ifcopenshell.entity_instance, + storey: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance, ) -> ifcopenshell.entity_instance: + if not (points := cls.generate_storey_points(drawing, storey)): + return + + camera = tool.Ifc.get_object(drawing) + mesh = bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new(storey.Name or "Unnamed", mesh) + obj.matrix_world = cls.get_default_annotation_matrix(camera) + element = cls.run_root_assign_class( + obj=obj, ifc_class="IfcAnnotation", predefined_type="SECTION_LEVEL", should_add_representation=False + ) + tool.Geometry.run_edit_object_placement(obj) + element.Name = storey.Name or "Unnamed" + builder = ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + points = [p / unit_scale for p in points] + representation = builder.get_representation(context, [builder.polyline(points)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation) + return element + + @classmethod + def regenerate_storey_annotation( + cls, + drawing: ifcopenshell.entity_instance, + annotation: ifcopenshell.entity_instance, + storey: ifcopenshell.entity_instance, + context: ifcopenshell.entity_instance, + ) -> ifcopenshell.entity_instance: + if not (points := cls.generate_storey_points(drawing, storey)): + return + + camera = tool.Ifc.get_object(drawing) + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, annotation) + m = ifcopenshell.util.shape.get_shape_matrix(shape) + mw = cls.get_default_annotation_matrix(camera) + existing_verts = [Vector(v) for v in ifcopenshell.util.shape.get_vertices(shape.geometry)] + + new_points = None + if not np.allclose(m, np.array(mw), atol=1e-4): + new_points = points + elif len(existing_verts) != 2: + new_points = points + else: + existing_verts = sorted(existing_verts, key=lambda v: v.x) + xmin, xmax = [v.x for v in existing_verts] + y = points[0].y + if not tool.Cad.is_x(y, existing_verts[0].y) or not tool.Cad.is_x(y, existing_verts[1].y): + new_points = (Vector((xmax, y, 0.0)), Vector((xmin, y, 0.0))) + + if new_points: + if representation := ifcopenshell.util.representation.get_representation(annotation, context): + ifcopenshell.api.geometry.unassign_representation( + tool.Ifc.get(), product=annotation, representation=representation + ) + builder = ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + new_points = [p / unit_scale for p in new_points] + representation = builder.get_representation(context, [builder.polyline(new_points)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), annotation, representation) + + if obj := tool.Ifc.get_object(annotation): + obj.matrix_world = mw + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=representation + ) + else: + ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), product=annotation, matrix=np.array(mw)) + + annotation.Name = storey.Name or "Unnamed" + return annotation + + @classmethod + def generate_section_reference_points( + cls, drawing: ifcopenshell.entity_instance, section: ifcopenshell.entity_instance + ) -> list | None: import bonsai.bim.module.drawing.helper as helper camera = tool.Ifc.get_object(drawing) - assert isinstance(camera, bpy.types.Object) - assert isinstance((camera_data := camera.data), bpy.types.Camera) - props = tool.Drawing.get_camera_props(camera_data) + if camera.data.type != "ORTHO": + return + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, section) + m = ifcopenshell.util.shape.get_shape_matrix(shape) + if not cls.is_matrix_perpendicular(camera.matrix_world, Matrix(m)): + return + if not cls.does_shape_intersect_camera(shape, camera): + return - bounds = helper.ortho_view_frame(camera_data) if camera_data.type == "ORTHO" else None - reference_obj = tool.Ifc.get_object(reference_element) - assert isinstance(reference_obj, bpy.types.Object) + # Get cutting plane as a line + verts = ifcopenshell.util.shape.get_vertices(shape.geometry) + cutting_plane_verts = sorted(verts, key=lambda v: v[2])[-4:] + v1, *_, v2 = sorted(cutting_plane_verts, key=lambda v: v[0]) # Cut is in +X direction + im = camera.matrix_world.inverted() + v1, v2 = [im @ Vector((m @ np.append(v, 1.0))[:3]) for v in [v1, v2]] - def to_camera_coords(camera: bpy.types.Object, reference_obj: bpy.types.Object) -> Matrix: - mat = reference_obj.matrix_world.copy() - xyz = camera.matrix_world.inverted() @ reference_obj.matrix_world.translation - xyz[2] = 0 - xyz = camera.matrix_world @ xyz - mat.translation = xyz - annotation_offset = mathutils.Vector((0, 0, -camera_data.clip_start - 0.05)) - annotation_offset = camera.matrix_world.to_quaternion() @ annotation_offset - mat.translation += annotation_offset - return mat - - def project_point_onto_camera(point: Vector, camera: bpy.types.Object) -> Vector: - projection = camera.matrix_world.to_quaternion() @ mathutils.Vector((0, 0, -1)) - return camera.matrix_world.inverted() @ mathutils.geometry.intersect_line_plane( - point.xyz, point.xyz - projection, camera.location, projection - ) - - obj_matrix = to_camera_coords(camera, reference_obj) - - if props.raster_x > props.raster_y: - width = camera_data.ortho_scale - height = width / props.raster_x * props.raster_y - else: - height = camera_data.ortho_scale - width = height / props.raster_y * props.raster_x - - projection = project_point_onto_camera(reference_obj.location, camera) - co1 = camera.matrix_world @ mathutils.Vector((width / 2, projection[1], -1)) - co2 = camera.matrix_world @ mathutils.Vector((-(width / 2), projection[1], -1)) - co1 = obj_matrix.inverted() @ co1 - co2 = obj_matrix.inverted() @ co2 - - data = bpy.data.curves.new("Annotation", type="CURVE") - data.dimensions = "3D" - data.resolution_u = 2 - - polyline = data.splines.new("POLY") - polyline.points.add(1) - polyline.points[-2].co = list(co1) + [1] - polyline.points[-1].co = list(co2) + [1] - - obj = bpy.data.objects.new(reference_obj.name, data) - obj.matrix_world = obj_matrix - - element = cls.run_root_assign_class( - obj=obj, - ifc_class="IfcAnnotation", - predefined_type="SECTION_LEVEL", - should_add_representation=True, - context=context, - ifc_representation_class=None, - ) - if representation := ifcopenshell.util.representation.get_representation(element, context): - cls.reload_representation(obj=obj, representation=representation) - bpy.data.curves.remove(data) - - return element + bounds = helper.ortho_view_frame(camera.data) + if not (points := helper.clip_segment(bounds, [v1, v2])): + return + for v in points: + v.z = 0 + return points @classmethod def generate_section_reference_annotation( cls, drawing: ifcopenshell.entity_instance, - reference_element: ifcopenshell.entity_instance, + section: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance, ) -> ifcopenshell.entity_instance: - import bonsai.bim.module.drawing.helper as helper + if not (points := cls.generate_section_reference_points(drawing, section)): + return - reference_obj = tool.Ifc.get_object(reference_element) - reference_obj.matrix_world camera = tool.Ifc.get_object(drawing) - bounds = helper.ortho_view_frame(camera.data) if camera.data.type == "ORTHO" else None + mesh = bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new(section.Name or "Unnamed", mesh) + obj.matrix_world = cls.get_default_annotation_matrix(camera) + element = cls.run_root_assign_class( + obj=obj, ifc_class="IfcAnnotation", predefined_type="SECTION", should_add_representation=False + ) + element.Name = section.Name or "Unnamed" + builder = ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + points = [p / unit_scale for p in points] + representation = builder.get_representation(context, [builder.polyline(points)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation) + return element - def to_camera_coords(camera: bpy.types.Object, reference_obj: bpy.types.Object) -> Matrix: - mat = reference_obj.matrix_world.copy() - xyz = camera.matrix_world.inverted() @ reference_obj.matrix_world.translation - xyz[2] = 0 - xyz = camera.matrix_world @ xyz - mat.translation = xyz - annotation_offset = mathutils.Vector((0, 0, -camera.data.clip_start - 0.05)) - annotation_offset = camera.matrix_world.to_quaternion() @ annotation_offset - mat.translation += annotation_offset - return mat + @classmethod + def regenerate_section_reference_annotation( + cls, + drawing: ifcopenshell.entity_instance, + annotation: ifcopenshell.entity_instance, + section: ifcopenshell.entity_instance, + context: ifcopenshell.entity_instance, + ) -> ifcopenshell.entity_instance: + if not (points := cls.generate_section_reference_points(drawing, section)): + return - def clip_to_camera_boundary( - mesh: bpy.types.Mesh, bounds: tuple[float, float, float, float, float, float] - ) -> Union[bpy.types.Mesh, None]: - mesh.verts.ensure_lookup_table() - points = [v.co for v in mesh.verts[0:2]] - points = helper.clip_segment(bounds, points) - if points is None: - return None - mesh.verts[0].co = points[0] - mesh.verts[1].co = points[1] - return mesh + camera = tool.Ifc.get_object(drawing) + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, annotation) + m = ifcopenshell.util.shape.get_shape_matrix(shape) + mw = cls.get_default_annotation_matrix(camera) + existing_verts = [Vector(v) for v in ifcopenshell.util.shape.get_vertices(shape.geometry)] - if cls.is_perpendicular(camera, reference_obj) and cls.is_intersecting(camera, reference_obj): - reference_mesh = cls.get_camera_block(reference_obj) - obj_matrix = to_camera_coords(camera, reference_obj) + new_points = None + if not np.allclose(m, np.array(mw), atol=1e-4): + new_points = points + elif len(existing_verts) != 2: + new_points = points + else: + if not tool.Cad.are_edges_collinear(existing_verts, points): + # Attempt to update the section line by projecting existing verts onto the new line + v1 = tool.Cad.point_on_edge(existing_verts[0], points) + v2 = tool.Cad.point_on_edge(existing_verts[1], points) + existing_length = (existing_verts[0] - existing_verts[1]).length + new_length = (v2 - v1).length + if abs((existing_length - new_length) / existing_length) <= 0.10: + # If the projected line is within 10% of the previous length ... + new_points = (v1, v2) + else: + new_points = points - # The reference mesh vertices represent a view cube. To convert - # this into a section line we: - # 1. Select the 4 +Z vertices local to the reference element. This - # is the cutting plane. - verts_local_to_reference = [reference_obj.matrix_world.inverted() @ v for v in reference_mesh["verts"]] - cutting_plane_verts = sorted(verts_local_to_reference, key=lambda x: x.z)[-4:] - global_cutting_plane_verts = [reference_obj.matrix_world @ v for v in cutting_plane_verts] - # 2. Project the cutting plane onto our viewing camera. - verts_local_to_camera = [camera.matrix_world.inverted() @ v for v in global_cutting_plane_verts] - # 3. Collapse verts with the same XY coords, and set Z to be just - # below the clip_start so it's visible - collapsed_verts = [] - for vert in verts_local_to_camera: - if not [True for v in collapsed_verts if (vert.xy - v.xy).length < 1e-2]: - collapsed_verts.append(mathutils.Vector((vert.x, vert.y, -camera.data.clip_start - 0.05))) - # 4. The first two vertices is the section line - section_line = collapsed_verts[0:2] - # 5. Sort the vertices in the +X direction so that the vertices are - # ordered to "point" in the direction of the section cut. - section_line = sorted( - section_line, key=lambda co: (reference_obj.matrix_world.inverted() @ camera.matrix_world @ co).x - ) - global_section_line = [camera.matrix_world @ v for v in section_line] - local_section_line = [obj_matrix.inverted() @ v for v in global_section_line] - - mesh = bpy.data.meshes.new(name="Annotation") - mesh.from_pydata(local_section_line, [(0, 1)], []) - bm = bmesh.new() - bm.from_mesh(mesh) - bm = clip_to_camera_boundary(bm, bounds) - bm.to_mesh(mesh) - bm.free() - - obj = bpy.data.objects.new(reference_obj.name, mesh) - obj.matrix_world = obj_matrix - - element = cls.run_root_assign_class( - obj=obj, - ifc_class="IfcAnnotation", - predefined_type="SECTION", - should_add_representation=True, - context=context, - ifc_representation_class=None, - ) - if representation := ifcopenshell.util.representation.get_representation(element, context): - cls.reload_representation(obj=obj, representation=representation) - return element + if new_points: + if representation := ifcopenshell.util.representation.get_representation(annotation, context): + ifcopenshell.api.geometry.unassign_representation( + tool.Ifc.get(), product=annotation, representation=representation + ) + builder = ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + new_points = [p / unit_scale for p in new_points] + representation = builder.get_representation(context, [builder.polyline(new_points)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), annotation, representation) + if obj := tool.Ifc.get_object(annotation): + obj.matrix_world = mw + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=representation + ) + else: + ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), product=annotation, matrix=np.array(mw)) + annotation.Name = section.Name or "Unnamed" + return annotation @classmethod def generate_elevation_reference_annotation( cls, drawing: ifcopenshell.entity_instance, - reference_element: ifcopenshell.entity_instance, + elevation: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance, ) -> ifcopenshell.entity_instance: - reference_obj = tool.Ifc.get_object(reference_element) - reference_obj.matrix_world camera = tool.Ifc.get_object(drawing) - - def to_camera_coords(camera: bpy.types.Object, reference_obj: bpy.types.Object) -> Matrix: - mat = reference_obj.matrix_world.copy() - xyz = camera.matrix_world.inverted() @ reference_obj.matrix_world.translation - xyz[2] = 0 - xyz = camera.matrix_world @ xyz - mat.translation = xyz - annotation_offset = mathutils.Vector((0, 0, -camera.data.clip_start - 0.05)) - annotation_offset = camera.matrix_world.to_quaternion() @ annotation_offset - mat.translation += annotation_offset - return mat - - if cls.is_perpendicular(camera, reference_obj) and cls.is_intersecting(camera, reference_obj): - obj = bpy.data.objects.new(reference_obj.name, None) + if camera.data.type != "ORTHO": + return + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, elevation) + m = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape)) + if cls.is_matrix_perpendicular(camera.matrix_world, m) and cls.does_shape_intersect_camera(shape, camera): + obj = bpy.data.objects.new(elevation.Name or "Unnamed", None) obj.empty_display_size = 0.1 - obj.matrix_world = to_camera_coords(camera, reference_obj) - + obj.matrix_world = cls.get_default_annotation_matrix(camera, matrix_world=m) element = cls.run_root_assign_class( - obj=obj, - ifc_class="IfcAnnotation", - predefined_type="ELEVATION", - should_add_representation=False, - context=context, - ifc_representation_class=None, + obj=obj, ifc_class="IfcAnnotation", predefined_type="ELEVATION", should_add_representation=False ) - if representation := ifcopenshell.util.representation.get_representation(element, context): - cls.reload_representation(obj=obj, representation=representation) + element.Name = elevation.Name or "Unnamed" return element + @classmethod + def regenerate_elevation_reference_annotation( + cls, + drawing: ifcopenshell.entity_instance, + annotation: ifcopenshell.entity_instance, + elevation: ifcopenshell.entity_instance, + context: ifcopenshell.entity_instance, + ) -> ifcopenshell.entity_instance: + camera = tool.Ifc.get_object(drawing) + if camera.data.type != "ORTHO": + return + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, elevation) + m = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape)) + if cls.is_matrix_perpendicular(camera.matrix_world, m) and cls.does_shape_intersect_camera(shape, camera): + existing_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(annotation.ObjectPlacement)) + # The user is allowed to shift the elevation, but not rotate it + if not np.allclose(np.array(m.to_3x3()), np.array(existing_matrix.to_3x3()), atol=1e-4): + mw = cls.get_default_annotation_matrix(camera, matrix_world=m) + if obj := tool.Ifc.get_object(annotation): + obj.matrix_world = mw + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + else: + ifcopenshell.api.geometry.edit_object_placement( + tool.Ifc.get(), product=annotation, matrix=np.array(mw) + ) + annotation.Name = elevation.Name or "Unnamed" + return annotation + + @classmethod + def generate_grid_axis_reference_points( + cls, drawing: ifcopenshell.entity_instance, axis: ifcopenshell.entity_instance + ) -> list | None: + import bonsai.bim.module.drawing.helper as helper + + camera = tool.Ifc.get_object(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) + im = camera.matrix_world.inverted() + v1, v2 = [im @ Vector((m @ np.append(v, 1.0))[:3]) for v in verts[:2]] + + 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])): + return + elif target_view in ("ELEVATION_VIEW", "SECTION_VIEW"): + bounds = helper.ortho_view_frame(camera.data) + if not (points := helper.elevate_segment(bounds, [v1, v2])): + return + else: + return + for v in points: + v.z = 0 + return points + @classmethod def generate_grid_axis_reference_annotation( cls, drawing: ifcopenshell.entity_instance, - reference_element: ifcopenshell.entity_instance, + axis: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance, ) -> Union[ifcopenshell.entity_instance, None]: - import bonsai.bim.module.drawing.helper as helper - - target_view = tool.Drawing.get_drawing_target_view(drawing) + if not (points := cls.generate_grid_axis_reference_points(drawing, axis)): + return camera = tool.Ifc.get_object(drawing) - assert isinstance(camera, bpy.types.Object) - assert isinstance(camera_data := camera.data, bpy.types.Camera) - - is_ortho = camera_data.type == "ORTHO" - bounds = helper.ortho_view_frame(camera_data) if is_ortho else None - clipping = is_ortho and target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW") - elevating = is_ortho and target_view in ("ELEVATION_VIEW", "SECTION_VIEW") - - def clone(src: bpy.types.Object) -> bpy.types.Object: - dst = src.copy() - assert isinstance(dst.data, bpy.types.Mesh) - dst.data = dst.data.copy() - dst.name = dst.name.replace("IfcGridAxis/", "") - tool.Blender.get_object_bim_props(dst).ifc_definition_id = 0 - tool.Geometry.get_mesh_props(dst.data).ifc_definition_id = 0 - return dst - - def disassemble(obj: bpy.types.Object) -> tuple[bpy.types.Object, bmesh.types.BMesh]: - assert isinstance(obj.data, bpy.types.Mesh) - mesh = bmesh.new() - mesh.verts.ensure_lookup_table() - mesh.from_mesh(obj.data) - return obj, mesh - - def assemble(obj: bpy.types.Object, mesh: bmesh.types.BMesh) -> bpy.types.Object: - assert isinstance(obj.data, bpy.types.Mesh) - mesh.to_mesh(obj.data) - return obj - - def to_camera_coords( - obj: bpy.types.Object, mesh: bmesh.types.BMesh - ) -> tuple[bpy.types.Object, bmesh.types.BMesh]: - mesh.transform(camera.matrix_world.inverted() @ obj.matrix_world) - obj.matrix_world = camera.matrix_world - annotation_offset = mathutils.Vector((0, 0, -camera_data.clip_start - 0.05)) - annotation_offset = camera.matrix_world.to_quaternion() @ annotation_offset - obj.matrix_world.translation += annotation_offset - return obj, mesh - - def clip_to_camera_boundary(mesh: bmesh.types.BMesh) -> bmesh.types.BMesh: - mesh.verts.ensure_lookup_table() - points = [v.co for v in mesh.verts[0:2]] - points = helper.clip_segment(bounds, points) - if points is None: - return None - mesh.verts[0].co = points[0] - mesh.verts[1].co = points[1] - return mesh - - def draw_grids_vertically(mesh: bmesh.types.BMesh) -> bmesh.types.BMesh: - mesh.verts.ensure_lookup_table() - points = [v.co for v in mesh.verts[0:2]] - points = helper.elevate_segment(bounds, points) - if points is None: - return None - points = helper.clip_segment(bounds, points) - if points is None: - return None - mesh.verts[0].co = points[0] - mesh.verts[1].co = points[1] - return mesh - - obj = tool.Ifc.get_object(reference_element) - if not obj: - return - assert isinstance(obj, bpy.types.Object) - obj, mesh = to_camera_coords(*disassemble(clone(obj))) - - if clipping: - mesh = clip_to_camera_boundary(mesh) - elif elevating: - mesh = draw_grids_vertically(mesh) - - if mesh is None: - return - - assemble(obj, mesh) - + mesh = bpy.data.meshes.new("Mesh") + obj = bpy.data.objects.new(axis.AxisTag or "-", mesh) + obj.matrix_world = cls.get_default_annotation_matrix(camera) element = cls.run_root_assign_class( - obj=obj, - ifc_class="IfcAnnotation", - predefined_type="GRID", - should_add_representation=True, - context=context, - ifc_representation_class=None, + obj=obj, ifc_class="IfcAnnotation", predefined_type="GRID", should_add_representation=False ) - if representation := ifcopenshell.util.representation.get_representation(element, context): - cls.reload_representation(obj=obj, representation=representation) + element.Name = axis.AxisTag or "-" + builder = ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + points = [p / unit_scale for p in points] + representation = builder.get_representation(context, [builder.polyline(points)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation) return element + @classmethod + def regenerate_grid_axis_reference_annotation( + cls, + drawing: ifcopenshell.entity_instance, + annotation: ifcopenshell.entity_instance, + axis: ifcopenshell.entity_instance, + context: ifcopenshell.entity_instance, + ) -> Union[ifcopenshell.entity_instance, None]: + if not (points := cls.generate_grid_axis_reference_points(drawing, axis)): + return + + camera = tool.Ifc.get_object(drawing) + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, annotation) + m = ifcopenshell.util.shape.get_shape_matrix(shape) + mw = cls.get_default_annotation_matrix(camera) + existing_verts = [Vector(v) for v in ifcopenshell.util.shape.get_vertices(shape.geometry)] + + new_points = None + if not np.allclose(m, np.array(mw), atol=1e-4): + new_points = points + elif len(existing_verts) != 2: + new_points = points + else: + if not tool.Cad.are_edges_collinear(existing_verts, points): + # Attempt to update the section line by projecting existing verts onto the new line + v1 = tool.Cad.point_on_edge(existing_verts[0], points) + v2 = tool.Cad.point_on_edge(existing_verts[1], points) + existing_length = (existing_verts[0] - existing_verts[1]).length + new_length = (v2 - v1).length + if abs((existing_length - new_length) / existing_length) <= 0.10: + # If the projected line is within 10% of the previous length ... + new_points = (v1, v2) + else: + new_points = points + + if new_points: + if representation := ifcopenshell.util.representation.get_representation(annotation, context): + ifcopenshell.api.geometry.unassign_representation( + tool.Ifc.get(), product=annotation, representation=representation + ) + builder = ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + new_points = [p / unit_scale for p in new_points] + representation = builder.get_representation(context, [builder.polyline(new_points)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), annotation, representation) + if obj := tool.Ifc.get_object(annotation): + obj.matrix_world = mw + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=representation + ) + else: + ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), product=annotation, matrix=np.array(mw)) + annotation.Name = axis.AxisTag or "-" + return annotation + + @classmethod + def get_default_annotation_matrix(cls, camera, matrix_world=None): + if matrix_world is None: + matrix_world = camera.matrix_world.copy() + annotation_offset = Vector((0, 0, -camera.data.clip_start - 0.05)) + annotation_offset = camera.matrix_world.to_quaternion() @ annotation_offset + matrix_world.translation += annotation_offset + return matrix_world + @classmethod def is_perpendicular(cls, a: bpy.types.Object, b: bpy.types.Object) -> bool: axes = [mathutils.Vector((1, 0, 0)), mathutils.Vector((0, 1, 0)), mathutils.Vector((0, 0, 1))] @@ -1880,6 +1985,16 @@ class Drawing(bonsai.core.tool.Drawing): return True return False + @classmethod + def is_matrix_perpendicular(cls, a: Matrix, b: Matrix) -> bool: + axes = [mathutils.Vector((1, 0, 0)), mathutils.Vector((0, 1, 0)), mathutils.Vector((0, 0, 1))] + a_quaternion = a.to_quaternion() + b_quaternion = b.to_quaternion() + for axis in axes: + if abs((a_quaternion @ axis).angle(b_quaternion @ axis) - (math.pi / 2)) < 1e-5: + return True + return False + @classmethod def get_camera_block(cls, obj: bpy.types.Object) -> dict: assert isinstance(camera := obj.data, bpy.types.Camera) @@ -1923,6 +2038,16 @@ class Drawing(bonsai.core.tool.Drawing): b_tree = mathutils.bvhtree.BVHTree.FromPolygons(b_block["verts"], b_block["faces"]) return bool(a_tree.overlap(b_tree)) + @classmethod + def does_shape_intersect_camera(cls, shape, camera) -> bool: + a_block = cls.get_camera_block(camera) + a_tree = mathutils.bvhtree.BVHTree.FromPolygons(a_block["verts"], a_block["faces"]) + m = ifcopenshell.util.shape.get_shape_matrix(shape) + verts = [(m @ np.append(v, 1.0))[:3] for v in ifcopenshell.util.shape.get_vertices(shape.geometry)] + faces = ifcopenshell.util.shape.get_faces(shape.geometry) + b_tree = mathutils.bvhtree.BVHTree.FromPolygons(verts, faces) + return bool(a_tree.overlap(b_tree)) + @classmethod def replace_text_literal_variables( cls, @@ -2656,3 +2781,8 @@ class Drawing(bonsai.core.tool.Drawing): for element in tool.Ifc.get().by_type("IfcAnnotation"): if element.ObjectType == "DRAWING" and (obj := tool.Ifc.get_object(element)): tool.Blender.get_layer_collection(obj.users_collection[0]).hide_viewport = True + + @classmethod + def clear_annotation_relationships(cls, drawing: ifcopenshell.entity_instance) -> None: + for rel in drawing.ReferencedBy: + tool.Ifc.get().remove(rel)