mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 14:31:39 +00:00
Merge ifcopenshell/v0.8.0 into parametric-framework-pt2
Bring in 13 commits from upstream v0.8.0 (tip f158ae737):
- Add regenerate_wall_to_underside operator + has_underside_connection
Model interface (closes #7943)
- Extend/regenerate walls to multiple undersides
- Fix duplicate booleans in extend_walls_to_underside
- Fix extend_walls_to_underside ridge artifact
- Regenerate connected walls when recalculating a slab
- Fix validate_type corruption; remove debug prints
- Lazy BVH tree construction in SnapObj + early-terminate solid raycasts
in non-xray mode + optimize 2D projection in ray_cast_by_proximity_2d
- Fix crash in update_bim_tool_props when selected type isn't a valid
ifc_class
- Fix assign_container in spatial.py (#8079)
- Fix sign of temporary offset restore in sweep_along_curve
Auto-merge resolved all overlap files cleanly:
- bim/handler.py: work branch's update_bim_tool_props refactor and
upstream's try/except hardening converged on identical try/except
around props.ifc_class assignment (no net change).
- bim/module/model/__init__.py: upstream's wall.RegenerateWallToUnderside
entry and work branch's roof gizmo entries occupy disjoint sections.
- bim/module/model/wall.py: upstream's RegenerateWallToUnderside operator
and work branch's GizmoWallEdition/IconSlot refactors occupy disjoint
sections.
- core/tool.py: upstream's four new Model stubs and work branch's
Root.has_material_styles stub occupy different classes.
Partly generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -94,6 +94,7 @@ classes = (
|
|||||||
wall.EnableEditingWall,
|
wall.EnableEditingWall,
|
||||||
wall.ExtendWallHeightToCursor,
|
wall.ExtendWallHeightToCursor,
|
||||||
wall.ExtendWallsToUnderside,
|
wall.ExtendWallsToUnderside,
|
||||||
|
wall.RegenerateWallToUnderside,
|
||||||
wall.ExtendWallsToWall,
|
wall.ExtendWallsToWall,
|
||||||
wall.ExtendWallsToPolylinePoint,
|
wall.ExtendWallsToPolylinePoint,
|
||||||
wall.ExtendWallToCursor,
|
wall.ExtendWallToCursor,
|
||||||
|
|||||||
@@ -301,18 +301,39 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
# of the selected walls has an in-progress parametric draft, commit it before
|
# of the selected walls has an in-progress parametric draft, commit it before
|
||||||
# extending, so the slab clip operates on the just-finalised IFC state.
|
# extending, so the slab clip operates on the just-finalised IFC state.
|
||||||
_commit_pending_wall_edits_for_selection(context)
|
_commit_pending_wall_edits_for_selection(context)
|
||||||
slab = None
|
slabs: list[bpy.types.Object] = []
|
||||||
walls: list[bpy.types.Object] = []
|
walls: list[bpy.types.Object] = []
|
||||||
if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)):
|
for obj in tool.Blender.get_selected_objects():
|
||||||
slab = obj
|
element = tool.Ifc.get_entity(obj)
|
||||||
for obj in tool.Blender.get_selected_objects(include_active=False):
|
if not element:
|
||||||
if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2":
|
continue
|
||||||
|
if tool.Model.get_usage_type(element) == "LAYER2":
|
||||||
walls.append(obj)
|
walls.append(obj)
|
||||||
if slab and walls:
|
else:
|
||||||
core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls)
|
slabs.append(obj)
|
||||||
|
if slabs and walls:
|
||||||
|
core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slabs, walls)
|
||||||
_resync_walls_after_mutation(walls)
|
_resync_walls_after_mutation(walls)
|
||||||
else:
|
else:
|
||||||
self.report({"ERROR"}, "Please select at least one LAYER2 element and an active element")
|
self.report({"ERROR"}, "Please select at least one LAYER2 element and at least one other IFC element")
|
||||||
|
|
||||||
|
|
||||||
|
class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
|
bl_idname = "bim.regenerate_wall_to_underside"
|
||||||
|
bl_label = "Regenerate Wall to Underside"
|
||||||
|
bl_description = "Re-clip selected walls to their connected underside objects after the slab has moved"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
def _execute(self, context):
|
||||||
|
wall_objs = [
|
||||||
|
obj
|
||||||
|
for obj in tool.Blender.get_selected_objects()
|
||||||
|
if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2"
|
||||||
|
]
|
||||||
|
if wall_objs:
|
||||||
|
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
|
||||||
|
else:
|
||||||
|
self.report({"ERROR"}, "Please select at least one LAYER2 element")
|
||||||
|
|
||||||
|
|
||||||
class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator):
|
class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
|
|||||||
@@ -969,7 +969,9 @@ class EditObjectUI:
|
|||||||
|
|
||||||
if PortData.data["total_ports"] > 0:
|
if PortData.data["total_ports"] > 0:
|
||||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||||
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
|
add_layout_hotkey_operator(
|
||||||
|
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def draw_void(cls, context, row):
|
def draw_void(cls, context, row):
|
||||||
@@ -1294,9 +1296,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
bpy.ops.bim.generate_space()
|
bpy.ops.bim.generate_space()
|
||||||
return
|
return
|
||||||
if self.active_material_usage == "LAYER2":
|
if self.active_material_usage == "LAYER2":
|
||||||
bpy.ops.bim.recalculate_wall()
|
if element and tool.Model.has_underside_connection(element):
|
||||||
|
bpy.ops.bim.regenerate_wall_to_underside()
|
||||||
|
else:
|
||||||
|
bpy.ops.bim.recalculate_wall()
|
||||||
elif self.active_material_usage == "LAYER3":
|
elif self.active_material_usage == "LAYER3":
|
||||||
bpy.ops.bim.recalculate_slab()
|
bpy.ops.bim.recalculate_slab()
|
||||||
|
wall_objs = tool.Model.get_connected_wall_objs(element)
|
||||||
|
if wall_objs:
|
||||||
|
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
|
||||||
elif tool.System.get_ports(element):
|
elif tool.System.get_ports(element):
|
||||||
bpy.ops.bim.regenerate_distribution_element()
|
bpy.ops.bim.regenerate_distribution_element()
|
||||||
elif self.active_material_usage == "PROFILE":
|
elif self.active_material_usage == "PROFILE":
|
||||||
|
|||||||
@@ -161,23 +161,73 @@ def align_objects(
|
|||||||
model.align_objects(reference_obj, objs, align_type)
|
model.align_objects(reference_obj, objs, align_type)
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate_wall_to_underside(
|
||||||
|
ifc: type[tool.Ifc],
|
||||||
|
geometry: type[tool.Geometry],
|
||||||
|
model: type[tool.Model],
|
||||||
|
wall_objs: list[bpy.types.Object],
|
||||||
|
) -> None:
|
||||||
|
"""Re-clip walls to their connected underside objects after the slab has moved."""
|
||||||
|
clipped_objs = []
|
||||||
|
for obj in wall_objs:
|
||||||
|
wall = ifc.get_entity(obj)
|
||||||
|
slab_objs = model.get_connected_slab_objs(wall)
|
||||||
|
if not slab_objs:
|
||||||
|
continue
|
||||||
|
if ifc.is_moved(obj):
|
||||||
|
geometry.run_edit_object_placement(obj=obj)
|
||||||
|
# Sync each slab's Blender mesh to its current IFC representation before
|
||||||
|
# reading face geometry, so a changed profile is picked up correctly.
|
||||||
|
model.reload_body_representation(slab_objs)
|
||||||
|
model.remove_wall_to_underside_booleans(wall)
|
||||||
|
for slab_obj in slab_objs:
|
||||||
|
clip = model.get_slab_clipping_bmesh(slab_obj)
|
||||||
|
if clip:
|
||||||
|
model.clip_wall_to_slab(wall, clip)
|
||||||
|
clipped_objs.append(obj)
|
||||||
|
if clipped_objs:
|
||||||
|
model.reload_body_representation(clipped_objs)
|
||||||
|
|
||||||
|
|
||||||
def extend_wall_to_slab(
|
def extend_wall_to_slab(
|
||||||
ifc: type[tool.Ifc],
|
ifc: type[tool.Ifc],
|
||||||
geometry: type[tool.Geometry],
|
geometry: type[tool.Geometry],
|
||||||
model: type[tool.Model],
|
model: type[tool.Model],
|
||||||
slab_obj: bpy.types.Object,
|
slab_objs: list[bpy.types.Object],
|
||||||
wall_objs: list[bpy.types.Object],
|
wall_objs: list[bpy.types.Object],
|
||||||
) -> None:
|
) -> None:
|
||||||
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
|
# If any wall is currently in item mode, exit it before modifying the
|
||||||
return # Nothing to clip?
|
# representation. Leaving stale item objects around causes delete_ifc_item
|
||||||
slab = ifc.get_entity(slab_obj)
|
# to later remove the extrusion (or other pre-boolean items) from inside
|
||||||
|
# the boolean chain, corrupting the IFC model.
|
||||||
|
geom_props = geometry.get_geometry_props()
|
||||||
|
if geom_props.representation_obj in wall_objs:
|
||||||
|
geometry.disable_item_mode()
|
||||||
|
clipped_walls = []
|
||||||
for obj in wall_objs:
|
for obj in wall_objs:
|
||||||
if ifc.is_moved(obj):
|
if ifc.is_moved(obj):
|
||||||
geometry.run_edit_object_placement(obj=obj)
|
geometry.run_edit_object_placement(obj=obj)
|
||||||
wall = ifc.get_entity(obj)
|
wall = ifc.get_entity(obj)
|
||||||
model.clip_wall_to_slab(wall, clip)
|
# Merge previously connected slabs with newly requested ones so that
|
||||||
model.connect_wall_to_slab(wall, slab)
|
# re-running the operator never produces duplicate booleans and never
|
||||||
model.reload_body_representation(wall_objs)
|
# silently discards clips that were applied in an earlier call.
|
||||||
|
existing = model.get_connected_slab_objs(wall)
|
||||||
|
seen = {id(s) for s in existing}
|
||||||
|
all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen]
|
||||||
|
# Remove stale booleans once, then re-clip against the full set.
|
||||||
|
model.remove_wall_to_underside_booleans(wall)
|
||||||
|
did_clip = False
|
||||||
|
for slab_obj in all_slab_objs:
|
||||||
|
clip = model.get_slab_clipping_bmesh(slab_obj)
|
||||||
|
if not clip:
|
||||||
|
continue
|
||||||
|
model.clip_wall_to_slab(wall, clip)
|
||||||
|
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
|
||||||
|
did_clip = True
|
||||||
|
if did_clip:
|
||||||
|
clipped_walls.append(obj)
|
||||||
|
if clipped_walls:
|
||||||
|
model.reload_body_representation(clipped_walls)
|
||||||
|
|
||||||
|
|
||||||
class RequireTwoWallsError(Exception):
|
class RequireTwoWallsError(Exception):
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ def assign_container(
|
|||||||
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
|
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
|
||||||
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
||||||
for element in all_elements:
|
for element in all_elements:
|
||||||
collector.assign(ifc.get_object(element))
|
if obj := ifc.get_object(element):
|
||||||
|
collector.assign(obj)
|
||||||
|
|
||||||
|
|
||||||
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
|
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
|
||||||
|
|||||||
@@ -681,6 +681,9 @@ class Model:
|
|||||||
def export_profile(cls, obj, position=None): pass
|
def export_profile(cls, obj, position=None): pass
|
||||||
def generate_occurrence_name(cls, element_type, ifc_class): pass
|
def generate_occurrence_name(cls, element_type, ifc_class): pass
|
||||||
def get_extrusion(cls, representation): pass
|
def get_extrusion(cls, representation): pass
|
||||||
|
def get_connected_slab_objs(cls, wall): pass
|
||||||
|
def get_connected_wall_objs(cls, slab): pass
|
||||||
|
def has_underside_connection(cls, element): pass
|
||||||
def get_manual_booleans(cls, element): pass
|
def get_manual_booleans(cls, element): pass
|
||||||
def get_material_layer_parameters(cls, element): pass
|
def get_material_layer_parameters(cls, element): pass
|
||||||
def get_slab_clipping_bmesh(cls, obj): pass
|
def get_slab_clipping_bmesh(cls, obj): pass
|
||||||
@@ -696,6 +699,7 @@ class Model:
|
|||||||
def regenerate_profile(cls, obj): pass
|
def regenerate_profile(cls, obj): pass
|
||||||
def regenerate_slab(cls, obj): pass
|
def regenerate_slab(cls, obj): pass
|
||||||
def reload_body_representation(cls, obj_or_objects): pass
|
def reload_body_representation(cls, obj_or_objects): pass
|
||||||
|
def remove_wall_to_underside_booleans(cls, wall): pass
|
||||||
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
|
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,13 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
break
|
break
|
||||||
mesh = obj.data
|
mesh = obj.data
|
||||||
assert isinstance(mesh, bpy.types.Mesh)
|
assert isinstance(mesh, bpy.types.Mesh)
|
||||||
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
|
item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id
|
||||||
|
try:
|
||||||
|
item = tool.Ifc.get().by_id(item_id)
|
||||||
|
except RuntimeError:
|
||||||
|
# Entity already deleted (e.g. removed as part of a sibling boolean collapse).
|
||||||
|
bpy.data.objects.remove(obj)
|
||||||
|
return
|
||||||
rep_obj = props.representation_obj
|
rep_obj = props.representation_obj
|
||||||
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
|
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
|
||||||
cls.remove_representation_item(item, rep_element)
|
cls.remove_representation_item(item, rep_element)
|
||||||
@@ -1157,11 +1163,16 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
|
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
|
||||||
data = obj.data
|
data = obj.data
|
||||||
if (
|
if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES):
|
||||||
isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
|
return None
|
||||||
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
|
ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id
|
||||||
and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem"))
|
if not ifc_id:
|
||||||
):
|
return None
|
||||||
|
try:
|
||||||
|
item = tool.Ifc.get().by_id(ifc_id)
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
if item.is_a("IfcRepresentationItem"):
|
||||||
return item
|
return item
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1335,6 +1346,8 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
cls, representation: ifcopenshell.entity_instance
|
cls, representation: ifcopenshell.entity_instance
|
||||||
) -> ifcopenshell.entity_instance:
|
) -> ifcopenshell.entity_instance:
|
||||||
if representation.RepresentationType == "MappedRepresentation":
|
if representation.RepresentationType == "MappedRepresentation":
|
||||||
|
if not representation.Items:
|
||||||
|
return representation
|
||||||
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
|
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
|
||||||
return representation
|
return representation
|
||||||
|
|
||||||
|
|||||||
+108
-15
@@ -351,6 +351,8 @@ class Model(bonsai.core.tool.Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||||
"""Return first found IfcExtrudedAreaSolid"""
|
"""Return first found IfcExtrudedAreaSolid"""
|
||||||
|
if not representation.Items:
|
||||||
|
return None
|
||||||
item = representation.Items[0]
|
item = representation.Items[0]
|
||||||
while True:
|
while True:
|
||||||
if item.is_a("IfcExtrudedAreaSolid"):
|
if item.is_a("IfcExtrudedAreaSolid"):
|
||||||
@@ -843,6 +845,57 @@ class Model(bonsai.core.tool.Model):
|
|||||||
items.append(item.FirstOperand)
|
items.append(item.FirstOperand)
|
||||||
return booleans
|
return booleans
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||||
|
"""Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP)."""
|
||||||
|
result = []
|
||||||
|
for rel in wall.ConnectedFrom:
|
||||||
|
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
|
||||||
|
slab_obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||||
|
if slab_obj:
|
||||||
|
result.append(slab_obj)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||||
|
"""Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP)."""
|
||||||
|
result = []
|
||||||
|
for rel in slab.ConnectedTo:
|
||||||
|
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
|
||||||
|
wall_obj = tool.Ifc.get_object(rel.RelatedElement)
|
||||||
|
if wall_obj:
|
||||||
|
result.append(wall_obj)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||||
|
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
|
||||||
|
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
|
||||||
|
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
|
||||||
|
manual_booleans = cls.get_manual_booleans(wall)
|
||||||
|
if not manual_booleans:
|
||||||
|
return
|
||||||
|
ifc_file = tool.Ifc.get()
|
||||||
|
for b in manual_booleans:
|
||||||
|
sec = b.SecondOperand
|
||||||
|
if sec is None:
|
||||||
|
# The IfcPolygonalFaceSet was already deleted externally. Splice the
|
||||||
|
# orphaned IfcBooleanResult out of the chain so the representation stays valid.
|
||||||
|
parents = list(ifc_file.get_inverse(b))
|
||||||
|
for parent in parents:
|
||||||
|
if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b:
|
||||||
|
parent.FirstOperand = b.FirstOperand
|
||||||
|
elif parent.is_a("IfcShapeRepresentation"):
|
||||||
|
new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand})
|
||||||
|
parent.Items = new_items
|
||||||
|
cls.unmark_manual_booleans(wall, [b.id()])
|
||||||
|
ifc_file.remove(b)
|
||||||
|
elif sec.is_a("IfcTessellatedFaceSet"):
|
||||||
|
tool.Geometry.remove_representation_item(sec, wall)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_manual_booleans(
|
def get_manual_booleans(
|
||||||
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
|
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
|
||||||
@@ -855,7 +908,8 @@ class Model(bonsai.core.tool.Model):
|
|||||||
representation = tool.Geometry.get_body_representation(element)
|
representation = tool.Geometry.get_body_representation(element)
|
||||||
if not representation:
|
if not representation:
|
||||||
return []
|
return []
|
||||||
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
all_chain_booleans = cls.get_booleans(element, representation)
|
||||||
|
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
|
||||||
return booleans
|
return booleans
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2557,12 +2611,15 @@ class Model(bonsai.core.tool.Model):
|
|||||||
clipping_bm = bmesh.new()
|
clipping_bm = bmesh.new()
|
||||||
vertex_map = {}
|
vertex_map = {}
|
||||||
|
|
||||||
|
kept = 0
|
||||||
for face in bm.faces:
|
for face in bm.faces:
|
||||||
face.normal_update()
|
face.normal_update()
|
||||||
normal = face.normal.to_4d()
|
normal = face.normal.to_4d()
|
||||||
normal.w = 0
|
normal.w = 0
|
||||||
if (obj.matrix_world @ normal).z >= -0.5:
|
world_normal_z = (obj.matrix_world @ normal).z
|
||||||
|
if world_normal_z >= -0.5:
|
||||||
continue
|
continue
|
||||||
|
kept += 1
|
||||||
new_verts = []
|
new_verts = []
|
||||||
for vert in face.verts:
|
for vert in face.verts:
|
||||||
if not (new_vert := vertex_map.get(vert.index, None)):
|
if not (new_vert := vertex_map.get(vert.index, None)):
|
||||||
@@ -2575,6 +2632,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
return
|
return
|
||||||
|
|
||||||
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
|
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
|
||||||
|
clipping_bm.faces.ensure_lookup_table()
|
||||||
return clipping_bm # clipping_bm is in project units
|
return clipping_bm # clipping_bm is in project units
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2588,17 +2646,53 @@ class Model(bonsai.core.tool.Model):
|
|||||||
min_z = min(zs)
|
min_z = min(zs)
|
||||||
max_z = max(zs)
|
max_z = max(zs)
|
||||||
|
|
||||||
operand = None
|
ifc_file = tool.Ifc.get()
|
||||||
if (z := max_z - min_z) and not np.isclose(z, 0.0):
|
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
|
||||||
|
|
||||||
result = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
|
# Build one IfcPolygonalFaceSet clip solid per clipping face.
|
||||||
extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)]
|
# Each solid uses a rectangle on the slope plane rather than the exact face
|
||||||
bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z))
|
# footprint. The original approach (exact footprint) caused a kissing-solid /
|
||||||
|
# boundary-coincidence bug when the operator is called twice for a ridge roof: the
|
||||||
|
# two slope solids share an exact ridge edge, and OCCT produces spurious extra
|
||||||
|
# vertices. Extending each solid slightly past the ridge (by margin) creates a
|
||||||
|
# volumetric overlap instead of a kissing boundary — OCCT handles overlapping
|
||||||
|
# DIFFERENCE operands correctly.
|
||||||
|
margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge
|
||||||
|
operands = []
|
||||||
|
for face in bm.faces:
|
||||||
|
face.normal_update()
|
||||||
|
normal = Vector(face.normal).normalized()
|
||||||
|
|
||||||
verts = [v.co for v in bm.verts]
|
# Orthonormal basis spanning the slope plane.
|
||||||
faces = [[v.index for v in p.verts] for p in bm.faces]
|
ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0))
|
||||||
operand = builder.mesh(verts, faces)
|
tangent1 = normal.cross(ref).normalized()
|
||||||
|
tangent2 = normal.cross(tangent1).normalized()
|
||||||
|
|
||||||
|
centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts)
|
||||||
|
|
||||||
|
# Tight bounding rectangle in slope-plane coords, plus a small margin.
|
||||||
|
t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts]
|
||||||
|
t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts]
|
||||||
|
half1 = max(abs(c) for c in t1_coords) + margin
|
||||||
|
half2 = max(abs(c) for c in t2_coords) + margin
|
||||||
|
|
||||||
|
# Rectangle on the slope plane, extruded upward in wall-local Z.
|
||||||
|
clip_bm = bmesh.new()
|
||||||
|
v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2)
|
||||||
|
v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2)
|
||||||
|
v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2)
|
||||||
|
v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2)
|
||||||
|
bottom_face = clip_bm.faces.new([v0, v1, v2, v3])
|
||||||
|
result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face])
|
||||||
|
top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)]
|
||||||
|
bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z)))
|
||||||
|
clip_bm.verts.ensure_lookup_table()
|
||||||
|
|
||||||
|
clip_verts = [v.co for v in clip_bm.verts]
|
||||||
|
clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces]
|
||||||
|
operand = builder.mesh(clip_verts, clip_faces)
|
||||||
|
clip_bm.free()
|
||||||
|
operands.append(operand)
|
||||||
|
|
||||||
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
|
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
|
||||||
if extrusion.Position:
|
if extrusion.Position:
|
||||||
@@ -2615,10 +2709,9 @@ class Model(bonsai.core.tool.Model):
|
|||||||
|
|
||||||
extrusion.Depth = max_z / direction[2]
|
extrusion.Depth = max_z / direction[2]
|
||||||
|
|
||||||
if operand:
|
if operands:
|
||||||
booleans = ifcopenshell.api.geometry.add_boolean(
|
body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW")
|
||||||
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
|
booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands)
|
||||||
)
|
|
||||||
tool.Model.mark_manual_booleans(wall, booleans)
|
tool.Model.mark_manual_booleans(wall, booleans)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -373,26 +373,38 @@ class Raycast(bonsai.core.tool.Raycast):
|
|||||||
except:
|
except:
|
||||||
loc = Vector((0, 0, 0))
|
loc = Vector((0, 0, 0))
|
||||||
|
|
||||||
verts_2d = [
|
snap_obj._ensure_bvh()
|
||||||
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
|
|
||||||
] # Numpy version is worst in performance
|
|
||||||
|
|
||||||
intersected = snap_obj.raycast_boxes(
|
intersected = snap_obj.raycast_boxes(
|
||||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Collect edges from intersected BVH boxes
|
||||||
edges = []
|
edges = []
|
||||||
for it in intersected:
|
for it in intersected:
|
||||||
edges.extend(it.edges)
|
edges.extend(it.edges)
|
||||||
edges = set(edges)
|
edges = set(edges)
|
||||||
|
|
||||||
|
# Build only the vertices indices that belong to these edges
|
||||||
|
verts_idx: set[int] = set()
|
||||||
|
for e in edges:
|
||||||
|
ev = snap_obj.obj.data.edges[e].vertices
|
||||||
|
verts_idx.add(ev[0])
|
||||||
|
verts_idx.add(ev[1])
|
||||||
|
|
||||||
|
# Lazily project only the needed vertices to 2D screen space
|
||||||
|
verts_2d: dict[int, Vector] = {}
|
||||||
|
for idx in verts_idx:
|
||||||
|
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_obj.verts_3d[idx])
|
||||||
|
if v2d is not None:
|
||||||
|
verts_2d[idx] = v2d
|
||||||
|
|
||||||
edge_verts = {}
|
edge_verts = {}
|
||||||
for e in edges:
|
for e in edges:
|
||||||
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
|
verts_idx = snap_obj.obj.data.edges[e].vertices
|
||||||
verts = snap_obj.obj.data.vertices
|
v1 = snap_obj.verts_3d[verts_idx[0]]
|
||||||
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
|
v2 = snap_obj.verts_3d[verts_idx[1]]
|
||||||
v1_2d = verts_2d[verts_idx[0]]
|
v1_2d = verts_2d.get(verts_idx[0])
|
||||||
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
|
v2_2d = verts_2d.get(verts_idx[1])
|
||||||
v2_2d = verts_2d[verts_idx[1]]
|
|
||||||
if (v1_2d is None) ^ (v2_2d is None):
|
if (v1_2d is None) ^ (v2_2d is None):
|
||||||
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
|
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
|
||||||
if v1_2d is None:
|
if v1_2d is None:
|
||||||
@@ -404,10 +416,16 @@ class Raycast(bonsai.core.tool.Raycast):
|
|||||||
|
|
||||||
snap_threshold = 10.0
|
snap_threshold = 10.0
|
||||||
|
|
||||||
for i, point in enumerate(verts_2d):
|
# Check all vertices for proximity to mouse position.
|
||||||
if not point:
|
# Re-use the 2D projections already computed for edge endpoints.
|
||||||
continue
|
for i, v3d in enumerate(snap_obj.verts_3d):
|
||||||
distance = (Vector(mouse_pos) - point).length
|
if i in verts_2d:
|
||||||
|
v2d = verts_2d[i]
|
||||||
|
else:
|
||||||
|
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d)
|
||||||
|
if v2d is None:
|
||||||
|
continue
|
||||||
|
distance = (Vector(mouse_pos) - v2d).length
|
||||||
if distance <= snap_threshold:
|
if distance <= snap_threshold:
|
||||||
snap_point = {
|
snap_point = {
|
||||||
"object": snap_obj.obj,
|
"object": snap_obj.obj,
|
||||||
@@ -799,6 +817,30 @@ class Raycast(bonsai.core.tool.Raycast):
|
|||||||
else:
|
else:
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def process_wireframe_snap_obj(
|
||||||
|
cls,
|
||||||
|
context: bpy.types.Context,
|
||||||
|
event: bpy.types.Event,
|
||||||
|
snap_obj,
|
||||||
|
ray_origin: Vector,
|
||||||
|
closest_snaps: list,
|
||||||
|
):
|
||||||
|
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||||
|
hit_obj = None
|
||||||
|
hit = None
|
||||||
|
if snap_points:
|
||||||
|
closest_length_squared = float("inf")
|
||||||
|
for point in snap_points:
|
||||||
|
point["group"] = "Wireframe"
|
||||||
|
closest_snaps.append(point)
|
||||||
|
length = (point["point"] - ray_origin).length_squared
|
||||||
|
if length < closest_length_squared:
|
||||||
|
closest_length_squared = length
|
||||||
|
hit = point["point"]
|
||||||
|
hit_obj = point["object"]
|
||||||
|
return hit_obj, hit
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ray_cast_and_get_closest_to_camera_snaps(
|
def ray_cast_and_get_closest_to_camera_snaps(
|
||||||
cls,
|
cls,
|
||||||
@@ -813,35 +855,43 @@ class Raycast(bonsai.core.tool.Raycast):
|
|||||||
|
|
||||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||||
|
|
||||||
|
space = context.space_data
|
||||||
|
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||||
|
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
|
||||||
|
)
|
||||||
|
|
||||||
closest_snaps = []
|
closest_snaps = []
|
||||||
hit = None
|
|
||||||
|
|
||||||
for snap_obj in objs_to_raycast:
|
if not xray_mode and objs_to_raycast:
|
||||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
# Non-xray - only the closest solid object's Face snap is kept by
|
||||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
# the caller (detect_snapping_points). Process solids in distance
|
||||||
):
|
# order and stop at the first hit to minimise raycasts.
|
||||||
# For wireframe objects we have to test all the snaps to see which is closer
|
wireframe_objs = []
|
||||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
solid_objs = []
|
||||||
closest_wf_hit = None
|
for snap_obj in objs_to_raycast:
|
||||||
closest_wf_length_squared = 1.0
|
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||||
closest_wf_point = None
|
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||||
if snap_points:
|
):
|
||||||
for point in snap_points:
|
wireframe_objs.append(snap_obj)
|
||||||
point["group"] = "Wireframe"
|
else:
|
||||||
closest_snaps.append(point)
|
solid_objs.append(snap_obj)
|
||||||
length = (point["point"] - ray_origin).length_squared
|
|
||||||
if closest_wf_hit is None or length < closest_wf_length_squared:
|
|
||||||
closest_wf_length_squared = length
|
|
||||||
closest_wf_hit = point["point"]
|
|
||||||
closest_wf_point = point
|
|
||||||
|
|
||||||
if closest_wf_point:
|
# Rough distance - object origin to ray origin
|
||||||
hit_obj = closest_wf_point["object"]
|
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
|
||||||
hit = closest_wf_point["point"]
|
|
||||||
face_index = None
|
|
||||||
|
|
||||||
else:
|
# Process wireframe objects first (all of them, always collected)
|
||||||
# Solid objects
|
for snap_obj in wireframe_objs:
|
||||||
|
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
|
||||||
|
if hit is not None:
|
||||||
|
length_squared = (hit - ray_origin).length_squared
|
||||||
|
if closest_obj is None or length_squared < closest_length_squared:
|
||||||
|
closest_length_squared = length_squared
|
||||||
|
closest_obj = hit_obj
|
||||||
|
closest_hit = hit
|
||||||
|
closest_face_index = None
|
||||||
|
|
||||||
|
# Process solid objects in distance order, stop at first hit
|
||||||
|
for snap_obj in solid_objs:
|
||||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||||
|
|
||||||
if hit:
|
if hit:
|
||||||
@@ -855,14 +905,45 @@ class Raycast(bonsai.core.tool.Raycast):
|
|||||||
}
|
}
|
||||||
closest_snaps.append(snap_point)
|
closest_snaps.append(snap_point)
|
||||||
|
|
||||||
# Here we test which is closer, including wireframe and solid objects
|
length_squared = (hit - ray_origin).length_squared
|
||||||
if hit is not None:
|
if closest_obj is None or length_squared < closest_length_squared:
|
||||||
length_squared = (hit - ray_origin).length_squared
|
closest_length_squared = length_squared
|
||||||
if closest_obj is None or length_squared < closest_length_squared:
|
closest_obj = hit_obj
|
||||||
closest_length_squared = length_squared
|
closest_hit = hit
|
||||||
closest_obj = hit_obj
|
closest_face_index = face_index
|
||||||
closest_hit = hit
|
|
||||||
closest_face_index = face_index
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Xray mode - process all objects (all snaps are kept by the caller)
|
||||||
|
for snap_obj in objs_to_raycast:
|
||||||
|
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||||
|
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||||
|
):
|
||||||
|
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
|
||||||
|
face_index = None
|
||||||
|
else:
|
||||||
|
# Solid objects
|
||||||
|
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||||
|
|
||||||
|
if hit:
|
||||||
|
snap_point = {
|
||||||
|
"point": hit,
|
||||||
|
"type": "Face",
|
||||||
|
"group": "Object",
|
||||||
|
"object": hit_obj,
|
||||||
|
"face_index": face_index,
|
||||||
|
"distance": 9, # High value so it has low priority
|
||||||
|
}
|
||||||
|
closest_snaps.append(snap_point)
|
||||||
|
|
||||||
|
if hit is not None:
|
||||||
|
length_squared = (hit - ray_origin).length_squared
|
||||||
|
if closest_obj is None or length_squared < closest_length_squared:
|
||||||
|
closest_length_squared = length_squared
|
||||||
|
closest_obj = hit_obj
|
||||||
|
closest_hit = hit
|
||||||
|
closest_face_index = face_index
|
||||||
|
|
||||||
# Label snaps from the closest object
|
# Label snaps from the closest object
|
||||||
if closest_obj is not None:
|
if closest_obj is not None:
|
||||||
@@ -936,12 +1017,19 @@ class SnapObj:
|
|||||||
def __init__(self, obj: bpy.types.Object):
|
def __init__(self, obj: bpy.types.Object):
|
||||||
self.__class__.all.append(self)
|
self.__class__.all.append(self)
|
||||||
self.obj = obj
|
self.obj = obj
|
||||||
self.root = self._create_root_node()
|
self.root = None
|
||||||
self.root.edges = [e.index for e in obj.data.edges]
|
self._bvh_built = False
|
||||||
self.split_box(self.root, 0)
|
|
||||||
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
||||||
self.snap_points = []
|
self.snap_points = []
|
||||||
|
|
||||||
|
def _ensure_bvh(self):
|
||||||
|
if self._bvh_built:
|
||||||
|
return
|
||||||
|
self.root = self._create_root_node()
|
||||||
|
self.root.edges = [e.index for e in self.obj.data.edges]
|
||||||
|
self.split_box(self.root, 0)
|
||||||
|
self._bvh_built = True
|
||||||
|
|
||||||
def __clear_all__():
|
def __clear_all__():
|
||||||
for instance in SnapObj.all:
|
for instance in SnapObj.all:
|
||||||
del instance
|
del instance
|
||||||
|
|||||||
@@ -300,7 +300,11 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
|
|||||||
|
|
||||||
if (applied_temporary_offset) {
|
if (applied_temporary_offset) {
|
||||||
gp_Trsf trsf;
|
gp_Trsf trsf;
|
||||||
trsf.SetTranslation(gp_Vec(-mean.x(), -mean.y(), -mean.z()));
|
// Restore original position: add back the mean subtracted from the
|
||||||
|
// directrix points above. Previously negated, which placed the swept
|
||||||
|
// solid at -mean instead of its original location for geometry far
|
||||||
|
// from the origin.
|
||||||
|
trsf.SetTranslation(gp_Vec(mean.x(), mean.y(), mean.z()));
|
||||||
result.Move(trsf);
|
result.Move(trsf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ def validate_type(
|
|||||||
if not preferred_item and remaining_items:
|
if not preferred_item and remaining_items:
|
||||||
preferred_item = remaining_items[0]
|
preferred_item = remaining_items[0]
|
||||||
|
|
||||||
|
# preferred_item must not appear in remaining_items — if it was selected from
|
||||||
|
# that list, leaving it in causes add_boolean to union it with itself, and the
|
||||||
|
# subsequent Items filter then removes ALL items (including preferred_item),
|
||||||
|
# leaving Items=[] which guess_type maps to "MappedRepresentation".
|
||||||
|
if preferred_item in remaining_items:
|
||||||
|
remaining_items = [i for i in remaining_items if i != preferred_item]
|
||||||
|
|
||||||
if remaining_items:
|
if remaining_items:
|
||||||
ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION")
|
ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION")
|
||||||
representation.Items = [i for i in representation.Items if i not in remaining_items]
|
representation.Items = [i for i in representation.Items if i not in remaining_items]
|
||||||
|
|||||||
Reference in New Issue
Block a user