mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 08:21:42 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 053d8e9901 | |||
| a6f2396476 | |||
| c699c01e91 | |||
| 12c3dabc34 | |||
| a27c9ef01c | |||
| 8c5d33f9e5 | |||
| f63d02b2b4 | |||
| 44bf8527f6 | |||
| 6be06bb6c5 | |||
| 904a4df651 | |||
| ca866b3669 | |||
| 28219973f6 | |||
| 63fb5635ee | |||
| 4be91c26c4 | |||
| ca652f4534 | |||
| ecd90e45b7 | |||
| f0482e7f0c | |||
| 3faa192db8 | |||
| 5d5be1ea65 | |||
| 86844eedf7 | |||
| 6ab935216f | |||
| 2a5685a7d0 | |||
| 5309b4614c | |||
| ffe7296973 | |||
| c92451e0fc | |||
| 38dc24336e | |||
| d2d47efdd4 | |||
| 8a186b3d01 | |||
| 288b716574 | |||
| 95025ad4b1 | |||
| b0aa54b37b | |||
| 2d8c9561b8 | |||
| fd343188b4 | |||
| a9790a3f18 | |||
| 40d1c20bcc | |||
| 2fcb8c17c7 | |||
| ad9192027c | |||
| b787d76ad6 | |||
| 49ecfbf494 | |||
| 22a17cd287 | |||
| c35023de88 | |||
| 9b39dd629b | |||
| 11543f19ca | |||
| 0874c4e59c | |||
| d2f18709cb | |||
| 101c4716ea | |||
| 22c8aa1960 | |||
| 6f3acc84ee | |||
| e077390e3d | |||
| 80cc603932 |
@@ -5,7 +5,7 @@ FILE_NAME('Psets_BBIM_Annotation.ifc','2020-01-01T00:00:00',$,$,'Psets_BBIM_Anno
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2));
|
||||
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2,#41,#42));
|
||||
#2=IFCSIMPLEPROPERTYTEMPLATE('2P7JN79n96Q9pElZ83LKe4',$,'ZIndex','',.P_SINGLEVALUE.,'IfcInteger',$,$,$,$,$,.READWRITE.);
|
||||
#3=IFCSIMPLEPROPERTYTEMPLATE('1Wpx_r2xj1_9w5JpI0QRJy',$,'Symbol','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#4=IFCSIMPLEPROPERTYTEMPLATE('3q0oxMUKP47vZ4jnyG$dDb',$,'Classes','Classes separated by spaces that end up in classes for this element in svg. Can be used to specify the text font size: small - 1.8mm; regular - 2.5mm; large - 3.5mm; header - 5mm; title - 7mm. By default regular size is used.',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -28,7 +28,7 @@ DATA;
|
||||
#21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30));
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39,#40));
|
||||
#25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -38,5 +38,14 @@ DATA;
|
||||
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
|
||||
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#40=IFCSIMPLEPROPERTYTEMPLATE('1LoNpKqR3sTuVwXyZaBcDe',$,'LinePosition','Absolute world-space coordinate (metres) of the dimension line along the horizontal offset axis (perpendicular to the dimension direction). When set, the dimension line is held at this fixed global position even if the measured geometry moves. When absent the line sits at the anchor points.',.P_SINGLEVALUE.,'IfcLengthMeasure',$,$,$,$,$,.READWRITE.);
|
||||
#41=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsAnnotFaux0001aB',$,'IsManualDrawingReference','Marks this annotation as a manually placed drawing reference, exempt from automatic drawing regeneration.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#42=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsDocRefFaux001aB',$,'IsDocumentReference','Marks this annotation as pointing to an external document reference (not a Bonsai drawing camera).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -32,6 +32,7 @@ classes = (
|
||||
operator.ActivateModel,
|
||||
operator.AddAnnotation,
|
||||
operator.AddAnnotationType,
|
||||
operator.AddElevationAnnotation,
|
||||
operator.AddDrawing,
|
||||
operator.AddDrawingStyle,
|
||||
operator.AddDrawingToSheet,
|
||||
@@ -111,6 +112,16 @@ classes = (
|
||||
operator.ToggleDrawingCategorySelection,
|
||||
operator.OpenDocumentationWebUi,
|
||||
operator.FilterSelectedObjectsIfIntersectedByCamera,
|
||||
operator.DrawParametricDimension,
|
||||
operator.SetDimensionAnchor,
|
||||
operator.RegenerateDimensions,
|
||||
operator.DriveDimensionLength,
|
||||
operator.RemoveDimensionAnchor,
|
||||
operator.InsertDimensionAnchor,
|
||||
operator.ClickNearestDimensionAnchor,
|
||||
operator.MakeDimensionParametric,
|
||||
operator.BakeParametricDimension,
|
||||
operator.DebugDimensionClicks,
|
||||
prop.Variable,
|
||||
prop.Drawing,
|
||||
prop.Document,
|
||||
@@ -172,11 +183,19 @@ classes = (
|
||||
gizmos.UglyDotGizmo,
|
||||
gizmos.ExtrusionGuidesGizmo,
|
||||
gizmos.ExtrusionWidget,
|
||||
gizmos.GizmoAnchorHandle,
|
||||
gizmos.GizmoDriveDimLabel,
|
||||
gizmos.DimensionAnchorWidget,
|
||||
gizmos.DimensionLinePositionWidget,
|
||||
gizmos.DimensionDriveLabelWidget,
|
||||
workspace.LaunchAnnotationTypeManager,
|
||||
workspace.Hotkey,
|
||||
)
|
||||
|
||||
|
||||
_keymaps = []
|
||||
|
||||
|
||||
def menu_func(self, context):
|
||||
active_obj = context.active_object
|
||||
if active_obj:
|
||||
@@ -196,9 +215,21 @@ def register():
|
||||
bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
|
||||
bpy.app.handlers.depsgraph_update_post.append(handler.depsgraph_update_post_handler)
|
||||
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
|
||||
|
||||
wm = bpy.context.window_manager
|
||||
kc = wm.keyconfigs.addon
|
||||
if kc:
|
||||
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
|
||||
kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS")
|
||||
_keymaps.append((km, kmi))
|
||||
kmi_alt = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS", alt=True)
|
||||
_keymaps.append((km, kmi_alt))
|
||||
kmi_ctrl = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS", ctrl=True)
|
||||
_keymaps.append((km, kmi_ctrl))
|
||||
|
||||
|
||||
def unregister():
|
||||
if not bpy.app.background:
|
||||
@@ -211,5 +242,10 @@ def unregister():
|
||||
del bpy.types.TextCurve.BIMTextProperties
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler)
|
||||
bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler)
|
||||
|
||||
for km, kmi in _keymaps:
|
||||
km.keymap_items.remove(kmi)
|
||||
_keymaps.clear()
|
||||
bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func)
|
||||
|
||||
@@ -55,6 +55,14 @@ class ProductAssignmentsData:
|
||||
element = tool.Ifc.get_entity(bpy.context.active_object)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
return
|
||||
# Document-reference annotations link to an IfcDocumentInformation, not a product.
|
||||
if tool.Drawing.is_document_reference(element):
|
||||
for rel in element.HasAssociations:
|
||||
if rel.is_a("IfcRelAssociatesDocument"):
|
||||
doc = rel.RelatingDocument
|
||||
if doc.is_a("IfcDocumentInformation"):
|
||||
return doc.Name or "Unnamed"
|
||||
return None
|
||||
for rel in element.HasAssignments:
|
||||
if rel.is_a("IfcRelAssignsToProduct"):
|
||||
name = rel.RelatingProduct.Name or "Unnamed"
|
||||
@@ -312,6 +320,9 @@ class DecoratorData:
|
||||
"StartArrowSymbol": "",
|
||||
"ShowEndArrow": True,
|
||||
"EndArrowSymbol": "",
|
||||
"BorderOffset": 8.0,
|
||||
"AutoStartPosition": "",
|
||||
"AutoEndPosition": "",
|
||||
}
|
||||
obj_pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {}
|
||||
pset_data.update(obj_pset_data)
|
||||
@@ -331,6 +342,9 @@ class DecoratorData:
|
||||
"symbol": end_symbol or "section-arrow",
|
||||
},
|
||||
"connect_markers": pset_data["HasConnectedSectionLine"],
|
||||
"border_offset": float(pset_data["BorderOffset"]),
|
||||
"auto_start_position": pset_data["AutoStartPosition"] or "",
|
||||
"auto_end_position": pset_data["AutoEndPosition"] or "",
|
||||
}
|
||||
|
||||
cls.data[obj.name] = display_data
|
||||
@@ -799,19 +813,24 @@ class DecoratorData:
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") or {}
|
||||
show_description_only = pset_data.get("ShowDescriptionOnly", False)
|
||||
suppress_zero_inches = pset_data.get("SuppressZeroInches", False)
|
||||
suppress_zero_feet = pset_data.get("SuppressZeroFeet", False)
|
||||
is_ordinate = pset_data.get("IsOrdinate", False)
|
||||
text_prefix = pset_data.get("TextPrefix", None) or ""
|
||||
text_suffix = pset_data.get("TextSuffix", None) or ""
|
||||
custom_unit_list = pset_data.get("CustomUnit", None) or ""
|
||||
custom_unit = custom_unit_list[0] if custom_unit_list else ""
|
||||
custom_units = list(pset_data.get("CustomUnit", None) or [])
|
||||
separator = pset_data.get("Separator", None) or " / "
|
||||
|
||||
return {
|
||||
"dimension_style": dimension_style,
|
||||
"show_description_only": show_description_only,
|
||||
"suppress_zero_inches": suppress_zero_inches,
|
||||
"suppress_zero_feet": suppress_zero_feet,
|
||||
"is_ordinate": is_ordinate,
|
||||
"text_prefix": text_prefix,
|
||||
"text_suffix": text_suffix,
|
||||
"fill_bg": fill_bg,
|
||||
"custom_unit": custom_unit,
|
||||
"custom_units": custom_units,
|
||||
"separator": separator,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -494,7 +494,7 @@ class BaseDecorator:
|
||||
self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs)
|
||||
|
||||
@cache
|
||||
def format_value(self, context, value, suppress_zero_inches=False, custom_unit=None, in_unit_length=False):
|
||||
def format_value(self, context, value, suppress_zero_inches=False, suppress_zero_feet=False, custom_unit=None, in_unit_length=False):
|
||||
drawing_pset_data = DrawingsData.data["active_drawing_pset_data"]
|
||||
precision = drawing_pset_data.get("MetricPrecision", None)
|
||||
if not precision:
|
||||
@@ -506,6 +506,7 @@ class BaseDecorator:
|
||||
precision=precision,
|
||||
decimal_places=decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
suppress_zero_feet=suppress_zero_feet,
|
||||
custom_unit=custom_unit,
|
||||
in_unit_length=in_unit_length,
|
||||
)
|
||||
@@ -722,11 +723,13 @@ class DimensionDecorator(BaseDecorator):
|
||||
if not dimension_data:
|
||||
return
|
||||
show_description_only = dimension_data["show_description_only"]
|
||||
is_ordinate = dimension_data["is_ordinate"]
|
||||
text_prefix = dimension_data["text_prefix"]
|
||||
text_suffix = dimension_data["text_suffix"]
|
||||
viewportDrawingScale = self.get_viewport_drawing_scale(context)
|
||||
text_offset_value = viewportDrawingScale * 3
|
||||
|
||||
ordinate_total = 0.0
|
||||
for i0, i1 in indices:
|
||||
v0 = Vector(vertices[i0])
|
||||
v1 = Vector(vertices[i1])
|
||||
@@ -745,16 +748,25 @@ class DimensionDecorator(BaseDecorator):
|
||||
"multiline": True,
|
||||
"text_dir": text_dir,
|
||||
}
|
||||
base_pos = p0 + text_dir * 0.5
|
||||
base_pos = p1 if is_ordinate else p0 + text_dir * 0.5
|
||||
|
||||
if not show_description_only:
|
||||
length = (v1 - v0).length
|
||||
text = self.format_value(
|
||||
context,
|
||||
length,
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
segment_length = (v1 - v0).length
|
||||
if is_ordinate:
|
||||
ordinate_total += segment_length
|
||||
length = ordinate_total if is_ordinate else segment_length
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [
|
||||
self.format_value(
|
||||
context,
|
||||
length,
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
text = dimension_data["separator"].join(str(p) for p in parts)
|
||||
if isinstance(self, DiameterDecorator):
|
||||
text = "D" + text
|
||||
text = text_prefix + text + text_suffix
|
||||
@@ -765,15 +777,18 @@ class DimensionDecorator(BaseDecorator):
|
||||
|
||||
self.draw_label(
|
||||
text=text,
|
||||
pos=base_pos + text_offset,
|
||||
box_alignment="bottom-middle",
|
||||
pos=base_pos + text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
|
||||
box_alignment="bottom-right" if is_ordinate else "bottom-middle",
|
||||
multiline_to_bottom=False,
|
||||
**common_label_attrs,
|
||||
)
|
||||
|
||||
if not show_description_only and description:
|
||||
self.draw_label(
|
||||
text=description, pos=base_pos - text_offset, box_alignment="top-middle", **common_label_attrs
|
||||
text=description,
|
||||
pos=base_pos - text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
|
||||
box_alignment="top-right" if is_ordinate else "top-middle",
|
||||
**common_label_attrs,
|
||||
)
|
||||
|
||||
|
||||
@@ -969,7 +984,9 @@ class RadiusDecorator(BaseDecorator):
|
||||
|
||||
def get_text():
|
||||
length = (spline_points[-1] - spline_points[-2]).length
|
||||
return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"])
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [self.format_value(context, length, suppress_zero_feet=dimension_data["suppress_zero_feet"], custom_unit=unit) for unit in units_to_format]
|
||||
return "R" + dimension_data["separator"].join(str(p) for p in parts)
|
||||
|
||||
self.draw_dimension_text(
|
||||
context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center"
|
||||
@@ -1505,6 +1522,20 @@ class ElevationDecorator(BaseDecorator):
|
||||
"output_edges": output_edges,
|
||||
}
|
||||
|
||||
# Determine the arrow direction in camera-image-plane (XY) space.
|
||||
# The elevation tag's local -Z is intentionally parallel to the drawing
|
||||
# camera's view direction, so projecting it always gives a near-zero XY
|
||||
# delta. Fall through to local +X (which is perpendicular to the view
|
||||
# and rotates visibly when the user spins the tag).
|
||||
view_mat = context.region_data.view_matrix
|
||||
edge_dir_2d = Vector((1.0, 0.0)) # final fallback
|
||||
for local_axis in (Vector((0, 0, -1)), Vector((1, 0, 0)), Vector((0, 1, 0))):
|
||||
world_axis = obj.matrix_world.to_3x3() @ local_axis
|
||||
cam_xy = (view_mat.to_3x3() @ world_axis).xy
|
||||
if cam_xy.length > 1e-6:
|
||||
edge_dir_2d = cam_xy.normalized()
|
||||
break
|
||||
|
||||
# process edges
|
||||
for edge in edges_original:
|
||||
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
|
||||
@@ -1513,7 +1544,7 @@ class ElevationDecorator(BaseDecorator):
|
||||
circle_head = get_circle_head(circle_size)
|
||||
start_i = add_verts_sequence(add_offsets(v0, circle_head), start_i, **out_kwargs, closed=True)
|
||||
|
||||
edge_dir = (v1 - v0).normalized()
|
||||
edge_dir = edge_dir_2d.to_3d()
|
||||
side = (edge_dir.yx * Vector((1, -1))).to_3d()
|
||||
triangle_head = get_triangle_head(side, edge_dir, triangle_length, triangle_width)
|
||||
start_i = add_verts_sequence(add_offsets(v0, triangle_head), start_i, **out_kwargs, closed=True)
|
||||
@@ -2129,4 +2160,8 @@ class DecorationsHandler:
|
||||
|
||||
object_decorators = DecoratorData.data.get("object_decorators", [])
|
||||
for obj, decorator in object_decorators:
|
||||
decorator.decorate(context, obj)
|
||||
try:
|
||||
decorator.decorate(context, obj)
|
||||
except ReferenceError:
|
||||
DecoratorData.is_loaded = False
|
||||
break
|
||||
|
||||
@@ -2297,6 +2297,19 @@ DISC = (
|
||||
(1.0, 0.0, 0),
|
||||
)
|
||||
|
||||
# Anchor index currently being edited by SetDimensionAnchor (-1 = none).
|
||||
_active_anchor_idx: int = -1
|
||||
# The annotation curve object being edited (kept so the gizmo group stays
|
||||
# visible even when SetDimensionAnchor temporarily changes the active object).
|
||||
_editing_annotation_obj = None
|
||||
|
||||
|
||||
def set_active_anchor(idx: int, annotation_obj=None) -> None:
|
||||
global _active_anchor_idx, _editing_annotation_obj
|
||||
_active_anchor_idx = idx
|
||||
_editing_annotation_obj = annotation_obj if idx >= 0 else None
|
||||
|
||||
|
||||
X3DISC = (
|
||||
(0.0, 0.0, 0.0),
|
||||
(1.0, 0.0, 0),
|
||||
@@ -2621,6 +2634,370 @@ class ExtrusionWidget(types.GizmoGroup):
|
||||
self.handle.target_set_prop("offset", prop, "value")
|
||||
self.guides.target_set_prop("depth", prop, "value")
|
||||
|
||||
|
||||
class GizmoAnchorHandle(bpy.types.Gizmo):
|
||||
"""Visual-only dot at a parametric dimension vertex.
|
||||
|
||||
No draw_select/invoke — draw_select puts the gizmo in Blender's select buffer
|
||||
and causes the gizmo system to consume clicks even without an explicit invoke,
|
||||
blocking ClickNearestDimensionAnchor from receiving them. All click handling
|
||||
is done by the bim.click_nearest_dimension_anchor keymap operator.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_anchor_handle"
|
||||
|
||||
__slots__ = ("anchor_index", "custom_shape")
|
||||
|
||||
def setup(self):
|
||||
self.anchor_index = 0
|
||||
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
|
||||
|
||||
class DimensionAnchorWidget(types.GizmoGroup):
|
||||
"""Anchor handle gizmos at each vertex of the active parametric dimension.
|
||||
|
||||
Green dots indicate vertices that are anchored to an IFC element face;
|
||||
orange dots are free world-point anchors. Clicking any dot fires
|
||||
``bim.set_dimension_anchor`` pre-targeted at that vertex index.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GGT_dimension_anchors"
|
||||
bl_label = "Dimension Anchor Handles"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
|
||||
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
|
||||
_MAX_ANCHORS = 16
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not tool.Ifc.get():
|
||||
return False
|
||||
# Stay visible while SetDimensionAnchor is running (active obj may temporarily
|
||||
# be an IFC element in the face-picking phase rather than the annotation).
|
||||
if _active_anchor_idx >= 0 and _editing_annotation_obj is not None:
|
||||
active = context.active_object
|
||||
if active is _editing_annotation_obj:
|
||||
return True # annotation still active
|
||||
if active is not None and tool.Ifc.get_entity(active) is not None:
|
||||
return True # face-picking phase: active obj is a target element
|
||||
# Active object is None or a non-IFC object — the modal ended without
|
||||
# calling set_active_anchor(-1). Reset stale state and fall through.
|
||||
set_active_anchor(-1)
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "CURVE":
|
||||
return False
|
||||
if not obj.select_get():
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
return False
|
||||
import ifcopenshell.util.element as _ue
|
||||
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
|
||||
return False
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
return bool(pset and pset.get("Anchors"))
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
self._handles: list = []
|
||||
for _ in range(self._MAX_ANCHORS):
|
||||
gz = self.gizmos.new("BIM_GT_anchor_handle")
|
||||
gz.scale_basis = 0.2
|
||||
gz.use_draw_modal = True
|
||||
gz.hide = True
|
||||
self._handles.append(gz)
|
||||
|
||||
def refresh(self, context: bpy.types.Context) -> None:
|
||||
import json
|
||||
import ifcopenshell.util.element as _ue
|
||||
|
||||
obj = _editing_annotation_obj if _active_anchor_idx >= 0 and _editing_annotation_obj else context.active_object
|
||||
if not obj or not obj.data or not getattr(obj.data, "splines", None):
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if not pset or not pset.get("Anchors"):
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
try:
|
||||
anchors = json.loads(pset["Anchors"])
|
||||
except Exception:
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
spline = obj.data.splines[0]
|
||||
n = min(len(spline.points), len(anchors), self._MAX_ANCHORS)
|
||||
|
||||
import ifcopenshell.util.element as _ue_gz
|
||||
_ptype_gz = _ue_gz.get_predefined_type(element)
|
||||
_is_elevation_gz = _ptype_gz in ("SECTION_LEVEL", "PLAN_LEVEL")
|
||||
|
||||
for i in range(n):
|
||||
gz = self._handles[i]
|
||||
if _is_elevation_gz:
|
||||
# The object origin IS the anchor reference point (placed at face hit).
|
||||
# Spline vertices are offset from the origin and should not be used.
|
||||
world_co = obj.matrix_world.translation.copy()
|
||||
else:
|
||||
raw_co = spline.points[i].co
|
||||
world_co = obj.matrix_world @ raw_co.to_3d()
|
||||
gz.matrix_basis = Matrix.Translation(world_co)
|
||||
gz.anchor_index = i
|
||||
if i == _active_anchor_idx and obj is _editing_annotation_obj:
|
||||
gz.color = (0.2, 0.7, 1.0)
|
||||
gz.color_highlight = (0.4, 0.85, 1.0)
|
||||
elif anchors[i].get("guid"):
|
||||
gz.color = (0.2, 0.85, 0.2)
|
||||
gz.color_highlight = (0.4, 1.0, 0.4)
|
||||
else:
|
||||
gz.color = (0.9, 0.6, 0.1)
|
||||
gz.color_highlight = (1.0, 0.85, 0.2)
|
||||
gz.alpha = 0.85
|
||||
gz.alpha_highlight = 1.0
|
||||
gz.hide = False
|
||||
|
||||
for i in range(n, self._MAX_ANCHORS):
|
||||
self._handles[i].hide = True
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
self.refresh(context)
|
||||
|
||||
|
||||
class DimensionLinePositionWidget(types.GizmoGroup):
|
||||
"""Drag handle for the LinePosition of a parametric dimension annotation.
|
||||
|
||||
Shows two opposing cones at the midpoint of the dimension curve, oriented
|
||||
along the horizontal offset axis (cross(world_Z, dim_direction)). Dragging
|
||||
either cone updates BBIM_Dimension.LinePosition and regenerates the curve in
|
||||
real time. The forward cone points in +offset_dir; the reverse cone in
|
||||
-offset_dir — both respond to mouse movement along the shared axis so the
|
||||
user can drag in either direction from either handle.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GGT_dimension_line_position"
|
||||
bl_label = "Dimension Line Position"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
|
||||
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not tool.Ifc.get():
|
||||
return False
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "CURVE":
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
return False
|
||||
import ifcopenshell.util.element as _ue
|
||||
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
|
||||
return False
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
return bool(pset and pset.get("Anchors"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
@staticmethod
|
||||
def _cam_dir() -> "Vector | None":
|
||||
"""Scene camera forward direction, or None."""
|
||||
cam = bpy.context.scene.camera
|
||||
if not cam:
|
||||
return None
|
||||
return (cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
|
||||
|
||||
@classmethod
|
||||
def _offset_dir(cls, obj: bpy.types.Object) -> "Vector | None":
|
||||
"""World-space direction perpendicular to the dimension line and in the view plane.
|
||||
|
||||
Plan view (camera mostly vertical): cross(world_Z, dim_dir) — preserves
|
||||
existing stored LinePosition values.
|
||||
Section/elevation (camera mostly horizontal): cross(cam_forward, dim_dir) —
|
||||
keeps the offset axis inside the view plane so the gizmo moves the line
|
||||
visually sideways (up/down in section) rather than into/out of the screen.
|
||||
"""
|
||||
if not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
|
||||
return None
|
||||
spline = obj.data.splines[0]
|
||||
if len(spline.points) < 2:
|
||||
return None
|
||||
a = obj.matrix_world @ spline.points[0].co.to_3d()
|
||||
b = obj.matrix_world @ spline.points[-1].co.to_3d()
|
||||
dim = b - a
|
||||
if dim.length < 1e-10:
|
||||
return None
|
||||
dim.normalize()
|
||||
cam_view = cls._cam_dir()
|
||||
cam_is_plan = (cam_view is None) or abs(cam_view.z) > 0.7
|
||||
ref = Vector((0.0, 0.0, 1.0)) if cam_is_plan else cam_view
|
||||
od = ref.cross(dim)
|
||||
if od.length < 1e-6:
|
||||
od = Vector((1.0, 0.0, 0.0)).cross(dim)
|
||||
if od.length < 1e-6:
|
||||
return None
|
||||
return od.normalized()
|
||||
|
||||
@staticmethod
|
||||
def _midpoint(obj: bpy.types.Object) -> "Vector":
|
||||
spline = obj.data.splines[0]
|
||||
pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
|
||||
return sum(pts, Vector()) / len(pts)
|
||||
|
||||
@staticmethod
|
||||
def _basis(origin: "Vector", x_axis: "Vector") -> "Matrix":
|
||||
"""4×4 matrix with translation=origin, local-X=x_axis."""
|
||||
ref = Vector((0.0, 0.0, 1.0)) if abs(x_axis.dot(Vector((0.0, 0.0, 1.0)))) < 0.9 else Vector((1.0, 0.0, 0.0))
|
||||
y_ax = x_axis.cross(ref).normalized()
|
||||
z_ax = x_axis.cross(y_ax)
|
||||
return Matrix([
|
||||
[x_axis.x, y_ax.x, z_ax.x, origin.x],
|
||||
[x_axis.y, y_ax.y, z_ax.y, origin.y],
|
||||
[x_axis.z, y_ax.z, z_ax.z, origin.z],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Value callbacks
|
||||
|
||||
def _get_pos(self) -> float:
|
||||
obj = bpy.context.active_object
|
||||
if not obj:
|
||||
return 0.0
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return 0.0
|
||||
import ifcopenshell.util.element as _ue
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if not pset:
|
||||
return 0.0
|
||||
stored = pset.get("LinePosition")
|
||||
if stored is not None:
|
||||
return float(stored)
|
||||
# Natural position: projection of midpoint onto offset axis
|
||||
od = self._offset_dir(obj)
|
||||
if od is None:
|
||||
return 0.0
|
||||
return self._midpoint(obj).dot(od)
|
||||
|
||||
def _set_pos(self, value: float) -> None:
|
||||
bpy.ops.ed.undo_push(message="Set Line Position")
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element as _ue
|
||||
import ifcopenshell.api.pset as _pset_api
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
obj = bpy.context.active_object
|
||||
if not obj:
|
||||
return
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
pset_data = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
return
|
||||
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
_pset_api.edit_pset(file, pset=pset_entity, properties={"LinePosition": value})
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
placement_override: dict = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cam_view = self._cam_dir()
|
||||
cam_dir_tuple = tuple(cam_view) if cam_view is not None else None
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, element, placement_override=placement_override, camera_dir=cam_dir_tuple
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GizmoGroup interface
|
||||
|
||||
def _make_cone(self, color: tuple, highlight: tuple) -> "bpy.types.Gizmo":
|
||||
gz = self.gizmos.new("BIM_GT_gizmo_cone")
|
||||
gz.color = color
|
||||
gz.alpha = 0.8
|
||||
gz.color_highlight = highlight
|
||||
gz.alpha_highlight = 1.0
|
||||
gz.scale_basis = 0.15
|
||||
gz.use_draw_modal = True
|
||||
gz.prop_name = "Line Position"
|
||||
gz.move_get_cb = self._get_pos
|
||||
gz.move_set_cb = self._set_pos
|
||||
gz.gizmo_group = self
|
||||
gz.delta_scale = 1.0
|
||||
return gz
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
color = (0.9, 0.6, 0.1)
|
||||
highlight = (1.0, 0.9, 0.2)
|
||||
self.gz_fwd = self._make_cone(color, highlight)
|
||||
self.gz_rev = self._make_cone(color, highlight)
|
||||
|
||||
def refresh(self, context: bpy.types.Context) -> None:
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
self.gz_fwd.hide = self.gz_rev.hide = True
|
||||
return
|
||||
|
||||
od = self._offset_dir(obj)
|
||||
if od is None:
|
||||
self.gz_fwd.hide = self.gz_rev.hide = True
|
||||
return
|
||||
|
||||
mid = self._midpoint(obj)
|
||||
# Lift each cone off the dimension line so the arrow base doesn't
|
||||
# overlap anchor dots. 0.3 m gives clear separation at typical zoom.
|
||||
_GAP = 0.15
|
||||
fwd_origin = mid + _GAP * od
|
||||
rev_origin = mid - _GAP * od
|
||||
|
||||
self.gz_fwd.matrix_basis = self._basis(fwd_origin, od)
|
||||
self.gz_fwd.axis = od.copy()
|
||||
self.gz_fwd.hide = False
|
||||
|
||||
# Reverse cone: visually points in -od; same drag axis so both cones
|
||||
# respond identically — drag toward either tip to move the line.
|
||||
self.gz_rev.matrix_basis = self._basis(rev_origin, -od)
|
||||
self.gz_rev.axis = od.copy()
|
||||
self.gz_rev.hide = False
|
||||
|
||||
@staticmethod
|
||||
def get_scale_value(system: str, length_unit: str) -> float:
|
||||
scale_value = 1
|
||||
@@ -2645,6 +3022,76 @@ class ExtrusionWidget(types.GizmoGroup):
|
||||
return scale_value
|
||||
|
||||
|
||||
class DimensionDriveLabelWidget(types.GizmoGroup):
|
||||
"""Pen-icon gizmos at each segment midpoint of the active parametric dimension.
|
||||
|
||||
Clicking a pen invokes ``bim.drive_dimension_length`` for that segment,
|
||||
opening a dialog pre-filled with the current length.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GGT_dimension_drive_label"
|
||||
bl_label = "Dimension Drive Label"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
|
||||
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
_MAX_SEGMENTS = 15
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not tool.Ifc.get():
|
||||
return False
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "CURVE":
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
return False
|
||||
import ifcopenshell.util.element as _ue
|
||||
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
|
||||
return False
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
return bool(pset and pset.get("Anchors"))
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
self._labels: list = []
|
||||
for _ in range(self._MAX_SEGMENTS):
|
||||
gz = self.gizmos.new("BIM_GT_drive_dim_label")
|
||||
gz.color = (0.9, 0.75, 0.1)
|
||||
gz.color_highlight = (1.0, 0.95, 0.3)
|
||||
gz.alpha = 0.85
|
||||
gz.alpha_highlight = 1.0
|
||||
gz.scale_basis = 0.18
|
||||
gz.use_draw_modal = True
|
||||
gz.hide = True
|
||||
self._labels.append(gz)
|
||||
|
||||
def refresh(self, context: bpy.types.Context) -> None:
|
||||
obj = context.active_object
|
||||
if not obj or not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
|
||||
for gz in self._labels:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
spline = obj.data.splines[0]
|
||||
pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
|
||||
n_segs = min(len(pts) - 1, self._MAX_SEGMENTS)
|
||||
|
||||
for i in range(n_segs):
|
||||
gz = self._labels[i]
|
||||
mid = (pts[i] + pts[i + 1]) * 0.5
|
||||
gz.matrix_basis = Matrix.Translation(mid)
|
||||
gz.segment_index = i
|
||||
gz.hide = False
|
||||
|
||||
for i in range(n_segs, self._MAX_SEGMENTS):
|
||||
self._labels[i].hide = True
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
self.refresh(context)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Core Gizmo Classes
|
||||
# ============================================================================
|
||||
@@ -3611,6 +4058,24 @@ class GizmoPen(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
)
|
||||
|
||||
|
||||
class GizmoDriveDimLabel(bpy.types.Gizmo):
|
||||
"""Visual-only pen icon at a parametric dimension segment midpoint.
|
||||
|
||||
No draw_select/invoke — click handling is done by ClickNearestDimensionAnchor,
|
||||
which dispatches bim.drive_dimension_length on a plain LMB at a midpoint.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_drive_dim_label"
|
||||
__slots__ = ("segment_index", "custom_shape")
|
||||
|
||||
def setup(self):
|
||||
self.segment_index = 0
|
||||
self.custom_shape = self.new_custom_shape("TRIS", GizmoPen.tris)
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
|
||||
class GizmoValidate(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Validate/checkmark icon gizmo for confirming edits."""
|
||||
|
||||
|
||||
@@ -16,15 +16,148 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
import bonsai.bim.module.drawing.decoration as decoration
|
||||
import bonsai.tool as tool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parametric dimension auto-regeneration state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Maps element GUID → list of annotation STEP IDs that reference it.
|
||||
_dim_guid_index: dict = {}
|
||||
# Persistent tessellation cache for the depsgraph handler (element id → shape).
|
||||
_dim_shape_cache: dict = {}
|
||||
# Set True whenever BBIM_Dimension anchors change or a new file loads.
|
||||
_dim_index_dirty: bool = True
|
||||
# Re-entry guard so curve updates don't trigger a second handler call.
|
||||
_dim_handler_running: bool = False
|
||||
|
||||
|
||||
def invalidate_dim_index() -> None:
|
||||
"""Mark the GUID index as stale so it is rebuilt on the next handler call."""
|
||||
global _dim_index_dirty, _dim_shape_cache
|
||||
_dim_index_dirty = True
|
||||
_dim_shape_cache.clear()
|
||||
|
||||
|
||||
def _rebuild_dim_guid_index(file) -> None:
|
||||
global _dim_guid_index, _dim_index_dirty
|
||||
import ifcopenshell.util.element
|
||||
|
||||
_dim_guid_index = {}
|
||||
for annotation in file.by_type("IfcAnnotation"):
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
continue
|
||||
try:
|
||||
anchors = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
continue
|
||||
ann_id = annotation.id()
|
||||
for anchor in anchors:
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
ids = _dim_guid_index.setdefault(guid, [])
|
||||
if ann_id not in ids:
|
||||
ids.append(ann_id)
|
||||
_dim_index_dirty = False
|
||||
|
||||
|
||||
def regenerate_dims_for_layer(file, layer) -> None:
|
||||
"""Regenerate all parametric dimensions anchored to elements that use *layer*."""
|
||||
global _dim_shape_cache, _dim_index_dirty, _dim_guid_index
|
||||
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
affected_guids: set = set()
|
||||
for layer_set in file.get_inverse(layer):
|
||||
if not layer_set.is_a("IfcMaterialLayerSet"):
|
||||
continue
|
||||
for inv in file.get_inverse(layer_set):
|
||||
if inv.is_a("IfcRelAssociatesMaterial"):
|
||||
rels = [inv]
|
||||
elif inv.is_a("IfcMaterialLayerSetUsage"):
|
||||
rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")]
|
||||
else:
|
||||
continue
|
||||
for rel in rels:
|
||||
for element in rel.RelatedObjects:
|
||||
if hasattr(element, "GlobalId"):
|
||||
affected_guids.add(element.GlobalId)
|
||||
_dim_shape_cache.pop(element.id(), None)
|
||||
|
||||
if not affected_guids:
|
||||
return
|
||||
|
||||
annotation_ids: set = set()
|
||||
for guid in affected_guids:
|
||||
for ann_id in _dim_guid_index.get(guid, []):
|
||||
annotation_ids.add(ann_id)
|
||||
|
||||
if not annotation_ids:
|
||||
return
|
||||
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import ifcopenshell.geom
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
|
||||
|
||||
cam = bpy.context.scene.camera
|
||||
cam_dir_tuple = None
|
||||
if cam:
|
||||
from mathutils import Vector as _Vec
|
||||
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
|
||||
|
||||
for ann_id in annotation_ids:
|
||||
try:
|
||||
annotation = file.by_id(ann_id)
|
||||
except Exception:
|
||||
continue
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
placement_override: dict = {}
|
||||
try:
|
||||
anchors_raw = json.loads(pset.get("Anchors") or "[]")
|
||||
for anchor in anchors_raw:
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
settings=geom_settings,
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
camera_dir=cam_dir_tuple,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
|
||||
|
||||
@persistent
|
||||
def load_post(*args):
|
||||
invalidate_dim_index()
|
||||
props = tool.Drawing.get_document_props()
|
||||
if props.should_draw_decorations:
|
||||
decoration.DecorationsHandler.install(bpy.context)
|
||||
@@ -61,3 +194,193 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
|
||||
raster_x, raster_y = props.update_camera_resolution()
|
||||
scene_render.resolution_x = raster_x
|
||||
scene_render.resolution_y = raster_y
|
||||
|
||||
|
||||
def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
|
||||
"""Sync BBIM_Dimension.Anchors length to match the curve's spline point count.
|
||||
|
||||
Called when the user adds or removes vertices from a dimension annotation in
|
||||
Edit Mode. New vertices get a free WORLD-type anchor at their current world
|
||||
position; removed tail vertices simply lose their anchor entries.
|
||||
|
||||
Returns True if the pset was changed.
|
||||
"""
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
|
||||
if not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
|
||||
return False
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
return False
|
||||
|
||||
try:
|
||||
anchors: list = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
spline = obj.data.splines[0]
|
||||
spline_world = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
|
||||
n_pts = len(spline_world)
|
||||
n_anchors = len(anchors)
|
||||
|
||||
if n_pts == n_anchors:
|
||||
return False
|
||||
|
||||
# Match each spline point to the nearest unused anchor by proximity.
|
||||
# This handles insertions (subdivide) and deletions correctly regardless
|
||||
# of where in the polyline the edit happened.
|
||||
_MATCH_THRESH_SQ = 1e-4 # 1 cm² — distinguishes existing pts from new midpoints
|
||||
used: set = set()
|
||||
new_anchors: list = []
|
||||
|
||||
for pt in spline_world:
|
||||
best_idx, best_sq = None, float("inf")
|
||||
for i, anc in enumerate(anchors):
|
||||
if i in used:
|
||||
continue
|
||||
stored = anc.get("pt")
|
||||
if not stored:
|
||||
continue
|
||||
dx, dy, dz = stored[0] - pt.x, stored[1] - pt.y, stored[2] - pt.z
|
||||
sq = dx * dx + dy * dy + dz * dz
|
||||
if sq < best_sq:
|
||||
best_sq, best_idx = sq, i
|
||||
if best_idx is not None and best_sq < _MATCH_THRESH_SQ:
|
||||
new_anchors.append(anchors[best_idx])
|
||||
used.add(best_idx)
|
||||
else:
|
||||
new_anchors.append({
|
||||
"guid": None,
|
||||
"type": "WORLD",
|
||||
"addr": {},
|
||||
"hint": None,
|
||||
"pt": [pt.x, pt.y, pt.z],
|
||||
})
|
||||
|
||||
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
|
||||
invalidate_dim_index()
|
||||
return True
|
||||
|
||||
|
||||
@persistent
|
||||
def depsgraph_update_post_handler(scene, depsgraph):
|
||||
"""Auto-regenerate parametric dimensions when referenced elements are moved."""
|
||||
global _dim_handler_running, _dim_index_dirty, _dim_guid_index, _dim_shape_cache
|
||||
|
||||
if _dim_handler_running:
|
||||
return
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
moved_guids: set = set()
|
||||
edited_annotation_ids: set = set()
|
||||
|
||||
for update in depsgraph.updates:
|
||||
obj = update.id
|
||||
if not isinstance(obj, bpy.types.Object):
|
||||
continue
|
||||
if not (update.is_updated_transform or update.is_updated_geometry):
|
||||
continue
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not hasattr(element, "GlobalId"):
|
||||
continue
|
||||
|
||||
|
||||
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
|
||||
import ifcopenshell.util.element as _ue
|
||||
ptype = _ue.get_predefined_type(element)
|
||||
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"):
|
||||
changed = _sync_dimension_anchors_to_curve(file, element, obj)
|
||||
if changed:
|
||||
edited_annotation_ids.add(element.id())
|
||||
continue
|
||||
|
||||
moved_guids.add(element.GlobalId)
|
||||
if update.is_updated_geometry:
|
||||
_dim_shape_cache.pop(element.id(), None)
|
||||
|
||||
annotation_ids: set = set(edited_annotation_ids)
|
||||
for guid in moved_guids:
|
||||
for ann_id in _dim_guid_index.get(guid, []):
|
||||
annotation_ids.add(ann_id)
|
||||
|
||||
if not annotation_ids:
|
||||
return
|
||||
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import ifcopenshell.geom
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve, _update_elevation_marker_z
|
||||
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
|
||||
|
||||
cam = bpy.context.scene.camera
|
||||
cam_dir_tuple = None
|
||||
if cam:
|
||||
from mathutils import Vector as _Vec
|
||||
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
|
||||
|
||||
_dim_handler_running = True
|
||||
try:
|
||||
for ann_id in annotation_ids:
|
||||
try:
|
||||
annotation = file.by_id(ann_id)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
|
||||
placement_override: dict = {}
|
||||
try:
|
||||
anchors_raw = json.loads(pset.get("Anchors") or "[]")
|
||||
for anchor in anchors_raw:
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_id = elem.id()
|
||||
if elem_id in placement_override:
|
||||
continue
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem_id] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
|
||||
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
|
||||
_update_elevation_marker_z(
|
||||
file, annotation,
|
||||
settings=geom_settings,
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
else:
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
settings=geom_settings,
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
camera_dir=cam_dir_tuple,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
finally:
|
||||
_dim_handler_running = False
|
||||
|
||||
@@ -170,6 +170,7 @@ def format_distance(
|
||||
precision=None,
|
||||
decimal_places=None,
|
||||
suppress_zero_inches=False,
|
||||
suppress_zero_feet=False,
|
||||
in_unit_length=False,
|
||||
custom_unit=None,
|
||||
):
|
||||
@@ -319,10 +320,10 @@ def format_distance(
|
||||
tx_dist = ""
|
||||
if feet:
|
||||
tx_dist += str(feet) + "'"
|
||||
if not feet and not add_inches:
|
||||
if not feet and not add_inches and not suppress_zero_feet:
|
||||
tx_dist += str(feet) + "'"
|
||||
|
||||
if not feet and add_inches and unit_length != "INCHES":
|
||||
if not feet and add_inches and unit_length != "INCHES" and not suppress_zero_feet:
|
||||
if value < 0:
|
||||
tx_dist += "-0' - "
|
||||
else:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,17 @@ def update_diagram_scale(self: "BIMCameraProperties", context: bpy.types.Context
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=diagram_scale)
|
||||
self.update_camera_resolution()
|
||||
|
||||
group = tool.Drawing.get_drawing_group(element)
|
||||
print(f"[SECTION] update_diagram_scale: camera={camera.name}, group={group}")
|
||||
if group:
|
||||
for annotation in tool.Drawing.get_group_elements(group) or []:
|
||||
print(f"[SECTION] checking group member: {annotation}")
|
||||
if annotation.is_a("IfcAnnotation") and ifcopenshell.util.element.get_predefined_type(annotation) == "SECTION":
|
||||
ann_obj = tool.Ifc.get_object(annotation)
|
||||
print(f"[SECTION] found SECTION annotation, ann_obj={ann_obj}")
|
||||
if ann_obj:
|
||||
tool.Drawing.update_section_endpoints(ann_obj, camera)
|
||||
|
||||
|
||||
def update_is_nts(self: "BIMCameraProperties", context: bpy.types.Context) -> None:
|
||||
if not self.update_props:
|
||||
@@ -1038,6 +1049,182 @@ def update_sheet_data(self, context):
|
||||
SheetsData.is_loaded = False
|
||||
|
||||
|
||||
def _update_force_perpendicular(self, context):
|
||||
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as tool
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
new_value = self.force_perpendicular_to_face
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
|
||||
targets = []
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||
continue
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
continue
|
||||
targets.append((obj, element, pset_data))
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
for obj, element, pset_data in targets:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"ForcePerpendicularToFace": new_value})
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
|
||||
|
||||
def _get_line_position(self) -> float:
|
||||
"""Return LinePosition from the active annotation's BBIM_Dimension pset.
|
||||
|
||||
Falls back to the natural anchor projection when LinePosition has not been
|
||||
explicitly set, so the field always shows a meaningful value.
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
try:
|
||||
import bpy as _bpy
|
||||
import ifcopenshell.util.element as _ue
|
||||
import bonsai.tool as _tool
|
||||
obj = getattr(_bpy.context, "active_object", None)
|
||||
if obj:
|
||||
element = _tool.Ifc.get_entity(obj)
|
||||
if element and element.is_a("IfcAnnotation"):
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if pset:
|
||||
stored = pset.get("LinePosition")
|
||||
if stored is not None:
|
||||
return float(stored)
|
||||
raw = pset.get("Anchors")
|
||||
if raw:
|
||||
anchors = json.loads(raw)
|
||||
if len(anchors) >= 2 and anchors[0].get("pt") and anchors[1].get("pt"):
|
||||
a, b = anchors[0]["pt"], anchors[1]["pt"]
|
||||
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
|
||||
m = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if m > 1e-10:
|
||||
ddx, ddy, ddz = dx / m, dy / m, dz / m
|
||||
cam = _bpy.context.scene.camera
|
||||
cam_is_plan = True
|
||||
cvx, cvy, cvz = 0.0, 0.0, 1.0
|
||||
if cam:
|
||||
from mathutils import Vector as _Vec
|
||||
cv = (cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized()
|
||||
cvx, cvy, cvz = cv.x, cv.y, cv.z
|
||||
cam_is_plan = abs(cvz) > 0.7
|
||||
if cam_is_plan:
|
||||
# cross(world_Z, dim_dir)
|
||||
ox, oy, oz = -ddy, ddx, 0.0
|
||||
else:
|
||||
# cross(cam_dir, dim_dir)
|
||||
ox = cvy * ddz - cvz * ddy
|
||||
oy = cvz * ddx - cvx * ddz
|
||||
oz = cvx * ddy - cvy * ddx
|
||||
om = math.sqrt(ox * ox + oy * oy + oz * oz)
|
||||
if om > 1e-6:
|
||||
od = (ox / om, oy / om, oz / om)
|
||||
pt = anchors[0]["pt"]
|
||||
return float(pt[0] * od[0] + pt[1] * od[1] + pt[2] * od[2])
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def _set_line_position(self, value: float) -> None:
|
||||
"""Write LinePosition to all selected dimension annotations and regenerate."""
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as tool
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
|
||||
targets = []
|
||||
import bpy as _bpy
|
||||
for obj in getattr(_bpy.context, "selected_objects", []):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||
continue
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
continue
|
||||
targets.append((obj, element, pset_data))
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
cam = _bpy.context.scene.camera
|
||||
cam_dir_tuple = None
|
||||
if cam:
|
||||
from mathutils import Vector as _Vec
|
||||
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
|
||||
|
||||
for obj, element, pset_data in targets:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"LinePosition": value})
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, element, placement_override=placement_override, camera_dir=cam_dir_tuple
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
|
||||
|
||||
class BIMAnnotationProperties(PropertyGroup):
|
||||
object_type: bpy.props.EnumProperty(
|
||||
name="Annotation Object Type", items=annotation_classes, default="TEXT", update=update_annotation_object_type
|
||||
@@ -1051,6 +1238,19 @@ class BIMAnnotationProperties(PropertyGroup):
|
||||
)
|
||||
is_adding_type: bpy.props.BoolProperty(default=False)
|
||||
type_name: bpy.props.StringProperty(name="Name", default="TYPEX")
|
||||
force_perpendicular_to_face: bpy.props.BoolProperty(
|
||||
name="Force ⊥ to Face",
|
||||
description="Constrain dimension vertices to the face normal of the first anchor. When dimensions are selected, toggling this updates them all.",
|
||||
default=False,
|
||||
update=_update_force_perpendicular,
|
||||
)
|
||||
line_position: bpy.props.FloatProperty(
|
||||
name="Line Position",
|
||||
description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.",
|
||||
unit="LENGTH",
|
||||
get=_get_line_position,
|
||||
set=_set_line_position,
|
||||
)
|
||||
tag_rotation_mode: bpy.props.EnumProperty(
|
||||
name="Tag Rotation Mode",
|
||||
description="How to orient the tag relative to the tagged object",
|
||||
|
||||
@@ -872,7 +872,11 @@ class SvgWriter:
|
||||
|
||||
v1 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, 0)))
|
||||
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, -1)))
|
||||
angle = -math.degrees((v2 - v1).xy.angle_signed(Vector((0, 1))))
|
||||
delta = (v2 - v1).xy
|
||||
if delta.length <= 1e-6:
|
||||
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((1, 0, 0)))
|
||||
delta = (v2 - v1).xy
|
||||
angle = -math.degrees(delta.angle_signed(Vector((0, 1)))) if delta.length > 1e-6 else 90.0
|
||||
|
||||
transform = "rotate({}, {}, {})".format(angle, *symbol_position_svg.xy)
|
||||
|
||||
@@ -895,6 +899,8 @@ class SvgWriter:
|
||||
reference_id = "-"
|
||||
sheet_id = "-"
|
||||
drawing = tool.Drawing.get_annotation_element(element)
|
||||
if not drawing:
|
||||
return ("-", "-")
|
||||
reference = tool.Drawing.get_drawing_reference(drawing)
|
||||
if reference:
|
||||
for sheet_reference in tool.Ifc.get().by_type("IfcDocumentReference"):
|
||||
@@ -1367,14 +1373,18 @@ class SvgWriter:
|
||||
|
||||
def get_text():
|
||||
radius = (points[-1].co - points[-2].co).length
|
||||
radius = helper.format_distance(
|
||||
radius,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
text = f"R{radius}"
|
||||
return text
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [
|
||||
helper.format_distance(
|
||||
radius,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
return "R" + dimension_data["separator"].join(str(p) for p in parts)
|
||||
|
||||
self.draw_dimension_text(
|
||||
get_text, tag, dimension_data, text_position=text_position, class_str="RADIUS", box_alignment="center"
|
||||
@@ -1501,10 +1511,12 @@ class SvgWriter:
|
||||
text_format=lambda x: "D" + x,
|
||||
show_description_only=dimension_data["show_description_only"],
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
text_prefix=dimension_data["text_prefix"],
|
||||
text_suffix=dimension_data["text_suffix"],
|
||||
fill_bg=dimension_data["fill_bg"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
custom_units=dimension_data["custom_units"],
|
||||
separator=dimension_data["separator"],
|
||||
)
|
||||
|
||||
def draw_dimension_annotations(self, obj: bpy.types.Object) -> None:
|
||||
@@ -1515,11 +1527,15 @@ class SvgWriter:
|
||||
dimension_data = DecoratorData.get_dimension_data(obj)
|
||||
|
||||
assert isinstance(obj.data, bpy.types.Curve)
|
||||
is_ordinate = dimension_data["is_ordinate"]
|
||||
for spline in obj.data.splines:
|
||||
points = self.get_spline_points(spline)
|
||||
ordinate_total = 0.0
|
||||
for i in range(len(points) - 1):
|
||||
v0_global = matrix_world @ points[i].co.xyz
|
||||
v1_global = matrix_world @ points[i + 1].co.xyz
|
||||
if is_ordinate:
|
||||
ordinate_total += (v1_global - v0_global).length
|
||||
self.draw_dimension_annotation(
|
||||
v0_global,
|
||||
v1_global,
|
||||
@@ -1527,10 +1543,13 @@ class SvgWriter:
|
||||
dimension_text=dimension_text,
|
||||
show_description_only=dimension_data["show_description_only"],
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
text_prefix=dimension_data["text_prefix"],
|
||||
text_suffix=dimension_data["text_suffix"],
|
||||
fill_bg=dimension_data["fill_bg"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
custom_units=dimension_data["custom_units"],
|
||||
separator=dimension_data["separator"],
|
||||
distance_override=ordinate_total if is_ordinate else None,
|
||||
)
|
||||
|
||||
def draw_measureit_arch_dimension_annotations(self) -> None:
|
||||
@@ -1554,10 +1573,13 @@ class SvgWriter:
|
||||
text_format=lambda x: x,
|
||||
show_description_only=False,
|
||||
suppress_zero_inches=False,
|
||||
suppress_zero_feet=False,
|
||||
text_prefix="",
|
||||
text_suffix="",
|
||||
fill_bg=False,
|
||||
custom_unit=None,
|
||||
custom_units=None,
|
||||
separator=" / ",
|
||||
distance_override=None,
|
||||
) -> None:
|
||||
offset = Vector([self.raw_width, self.raw_height]) / 2
|
||||
v0 = self.project_point_onto_camera(v0_global)
|
||||
@@ -1570,7 +1592,10 @@ class SvgWriter:
|
||||
sheet_dimension = (end - start).length
|
||||
|
||||
# if annotation can't fit offset text to the right of marker
|
||||
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
|
||||
if distance_override is not None:
|
||||
text_position = end
|
||||
else:
|
||||
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
|
||||
angle = math.degrees(vector.angle_signed(Vector((1, 0))))
|
||||
|
||||
line = self.svg.line(start=start, end=end, class_=" ".join(classes))
|
||||
@@ -1585,15 +1610,20 @@ class SvgWriter:
|
||||
}
|
||||
|
||||
if not show_description_only:
|
||||
dimension = (v1_global - v0_global).length
|
||||
dimension = helper.format_distance(
|
||||
dimension,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
custom_unit=custom_unit,
|
||||
)
|
||||
text = text_prefix + str(dimension) + text_suffix
|
||||
dimension = distance_override if distance_override is not None else (v1_global - v0_global).length
|
||||
units_to_format = custom_units if custom_units else [None]
|
||||
parts = [
|
||||
helper.format_distance(
|
||||
dimension,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
suppress_zero_feet=suppress_zero_feet,
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
text = text_prefix + separator.join(str(p) for p in parts) + text_suffix
|
||||
else:
|
||||
if not dimension_text:
|
||||
return
|
||||
@@ -1601,8 +1631,8 @@ class SvgWriter:
|
||||
|
||||
text_tags += self.create_text_tag(
|
||||
text,
|
||||
text_position + perpendicular,
|
||||
box_alignment="bottom-middle",
|
||||
text_position + perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
|
||||
box_alignment="bottom-right" if distance_override is not None else "bottom-middle",
|
||||
multiline_to_bottom=False,
|
||||
**text_tag_kwargs,
|
||||
)
|
||||
@@ -1610,8 +1640,8 @@ class SvgWriter:
|
||||
if not show_description_only and dimension_text:
|
||||
text_tags += self.create_text_tag(
|
||||
dimension_text,
|
||||
text_position - perpendicular,
|
||||
box_alignment="top-middle",
|
||||
text_position - perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
|
||||
box_alignment="top-right" if distance_override is not None else "top-middle",
|
||||
multiline_to_bottom=True,
|
||||
**text_tag_kwargs,
|
||||
)
|
||||
|
||||
@@ -555,6 +555,17 @@ class BIM_PT_product_assignments(Panel):
|
||||
|
||||
assert self.layout
|
||||
assert (obj := context.active_object)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element and tool.Drawing.is_manual_drawing_reference(element):
|
||||
row = self.layout.row(align=True)
|
||||
fallback = "No Reference Assigned" if element.ObjectType == "REFERENCE" else "No Drawing Assigned"
|
||||
row.label(
|
||||
text=ProductAssignmentsData.data["relating_product"] or fallback, icon="IMAGE_DATA"
|
||||
)
|
||||
row.operator("bim.assign_manual_drawing_reference", icon="GREASEPENCIL", text="")
|
||||
return
|
||||
|
||||
props = tool.Drawing.get_object_assigned_product_props(obj)
|
||||
|
||||
if props.is_editing_product:
|
||||
@@ -572,6 +583,8 @@ class BIM_PT_product_assignments(Panel):
|
||||
col.enabled = bool(ProductAssignmentsData.data["relating_product"])
|
||||
|
||||
|
||||
|
||||
|
||||
def get_category_icon(category_name):
|
||||
"""Get appropriate icon for each category"""
|
||||
icons = {
|
||||
|
||||
@@ -114,7 +114,11 @@ class AnnotationTool(WorkSpaceTool):
|
||||
bl_description = "Gives you Annotation related superpowers"
|
||||
bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.annotation")
|
||||
bl_widget = None
|
||||
bl_keymap = tool.Blender.get_default_selection_keypmap() + (
|
||||
bl_keymap = (
|
||||
# Before view3d.select: tool keymaps take priority over the addon keymap
|
||||
# where ClickNearestDimensionAnchor is also registered.
|
||||
("bim.click_nearest_dimension_anchor", {"type": "LEFTMOUSE", "value": "PRESS"}, None),
|
||||
) + tool.Blender.get_default_selection_keypmap() + (
|
||||
("bim.annotation_hotkey", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}),
|
||||
("bim.annotation_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}),
|
||||
("bim.annotation_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}),
|
||||
@@ -221,14 +225,68 @@ class AnnotationToolUI:
|
||||
props = tool.Drawing.get_document_props()
|
||||
row.prop(props, "should_draw_decorations", text="Viewport Annotations")
|
||||
|
||||
_DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
|
||||
|
||||
@classmethod
|
||||
def draw_edit_object_interface(cls, context):
|
||||
if DecoratorData.get_text_data(bpy.context.active_object):
|
||||
obj = bpy.context.active_object
|
||||
if tool.Ifc.get_entity(obj) and DecoratorData.get_text_data(obj):
|
||||
add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "")
|
||||
if bpy.ops.bim.copy_annotation_to_drawing.poll():
|
||||
row = cls.layout.row(align=True)
|
||||
row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing")
|
||||
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj) if obj else None
|
||||
if element and element.is_a("IfcAnnotation"):
|
||||
ptype = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if ptype in cls._DIMENSION_TYPES:
|
||||
cls.layout.separator()
|
||||
ann_props = tool.Drawing.get_annotation_props()
|
||||
if ann_props.force_perpendicular_to_face:
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(ann_props, "line_position")
|
||||
cls.layout.separator()
|
||||
row = cls.layout.row(align=True)
|
||||
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
|
||||
op.active_only = True
|
||||
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj) if obj else None
|
||||
if element and element.is_a("IfcAnnotation"):
|
||||
ptype = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if ptype in cls._DIMENSION_TYPES:
|
||||
cls.layout.separator()
|
||||
ann_props = tool.Drawing.get_annotation_props()
|
||||
if ann_props.force_perpendicular_to_face:
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(ann_props, "line_position")
|
||||
cls.layout.separator()
|
||||
row = cls.layout.row(align=True)
|
||||
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
|
||||
op.active_only = True
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if pset and pset.get("Anchors"):
|
||||
row = cls.layout.row(align=True)
|
||||
row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED")
|
||||
else:
|
||||
row = cls.layout.row(align=True)
|
||||
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
|
||||
elif ptype in cls._ELEVATION_TYPES:
|
||||
cls.layout.separator()
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if pset and pset.get("Anchors"):
|
||||
row = cls.layout.row(align=True)
|
||||
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
|
||||
op.active_only = True
|
||||
row = cls.layout.row(align=True)
|
||||
row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED")
|
||||
else:
|
||||
row = cls.layout.row(align=True)
|
||||
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
|
||||
|
||||
@classmethod
|
||||
def draw_type_selection_interface(cls):
|
||||
# shared by both sidebar and header
|
||||
@@ -251,6 +309,11 @@ class AnnotationToolUI:
|
||||
|
||||
add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation")
|
||||
|
||||
_DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE"}
|
||||
if object_type in _DIMENSION_TYPES:
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(cls.props, "force_perpendicular_to_face")
|
||||
|
||||
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
|
||||
row = cls.layout.row(align=True)
|
||||
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
|
||||
@@ -333,8 +396,20 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if created_objects:
|
||||
bpy.context.view_layer.objects.active = created_objects[-1]
|
||||
|
||||
_PARAMETRIC_DIMENSION_TYPES = frozenset(
|
||||
("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")
|
||||
)
|
||||
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
|
||||
|
||||
def hotkey_S_A(self):
|
||||
if bpy.ops.bim.add_annotation.poll():
|
||||
props = tool.Drawing.get_annotation_props()
|
||||
if props.object_type in self._PARAMETRIC_DIMENSION_TYPES:
|
||||
if bpy.ops.bim.draw_parametric_dimension.poll():
|
||||
bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT")
|
||||
elif props.object_type in self._ELEVATION_TYPES:
|
||||
if bpy.ops.bim.add_elevation_annotation.poll():
|
||||
bpy.ops.bim.add_elevation_annotation("INVOKE_DEFAULT")
|
||||
elif bpy.ops.bim.add_annotation.poll():
|
||||
bpy.ops.bim.add_annotation()
|
||||
|
||||
def hotkey_S_E(self):
|
||||
|
||||
@@ -1325,6 +1325,10 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
if new_active_obj:
|
||||
context.view_layer.objects.active = new_active_obj
|
||||
|
||||
if any(e.is_a("IfcAnnotation") for e in old_to_new):
|
||||
import bonsai.bim.module.drawing.handler as _drawing_handler
|
||||
_drawing_handler.invalidate_dim_index()
|
||||
|
||||
return old_to_new
|
||||
|
||||
|
||||
|
||||
@@ -834,6 +834,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
slab.DumbSlabPlaner().regenerate_from_layer(layer)
|
||||
wall.DumbWallPlaner().regenerate_from_layer(layer)
|
||||
from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer
|
||||
regenerate_dims_for_layer(self.file, layer)
|
||||
elif material.is_a("IfcMaterialProfileSet"):
|
||||
profile_def = None
|
||||
if mprops.profiles:
|
||||
|
||||
@@ -796,6 +796,8 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
|
||||
rv3d = region.data
|
||||
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
if not polyline_props.snap_mouse_point:
|
||||
return
|
||||
snap_prop = polyline_props.snap_mouse_point[0]
|
||||
mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z))
|
||||
|
||||
@@ -863,6 +865,8 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
|
||||
gpu.state.point_size_set(6)
|
||||
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
if not polyline_props.snap_mouse_point:
|
||||
return
|
||||
snap_prop = polyline_props.snap_mouse_point[0]
|
||||
# Point related to the mouse
|
||||
mouse_point = [Vector((snap_prop.x, snap_prop.y, snap_prop.z))]
|
||||
|
||||
@@ -462,14 +462,26 @@ class PolylineOperator:
|
||||
self.tool_state.axis_method = None
|
||||
self.tool_state.plane_method = None
|
||||
self.tool_state.mode = "Mouse"
|
||||
tool.Raycast.clear_snap_objs()
|
||||
# Do not call clear_snap_objs() here — create_snap_obj() validates stale
|
||||
# entries per-object (vertex count + position check), so the BVH cache can
|
||||
# safely persist across invocations. Clearing it caused an 11-second stall
|
||||
# on every Shift+A because SnapObj rebuilds a pure-Python BVH tree.
|
||||
self.visible_objs = tool.Raycast.get_visible_objects(context)
|
||||
for obj in self.visible_objs:
|
||||
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
|
||||
self.objs_2d_bbox.append(bbox_2d)
|
||||
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
|
||||
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
|
||||
self._init_snapping_points(context, event)
|
||||
tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
context.window_manager.modal_handler_add(self)
|
||||
|
||||
def _init_snapping_points(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
|
||||
"""Populate self.snapping_points at operator start.
|
||||
|
||||
Override in subclasses to skip the full BVH snap detection when a cheap
|
||||
placeholder is sufficient. The default runs the full detection pass.
|
||||
"""
|
||||
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
|
||||
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
|
||||
|
||||
|
||||
@@ -88,6 +88,44 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
props.active_pset_type = "-"
|
||||
|
||||
|
||||
def _regenerate_parametric_dimension(file, annotation):
|
||||
"""Regenerate a single parametric dimension annotation after a pset edit."""
|
||||
try:
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as _tool
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
return
|
||||
|
||||
anchors = json.loads(pset_data["Anchors"])
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = _tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, annotation, placement_override=placement_override
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_pset"
|
||||
bl_label = "Edit Pset"
|
||||
@@ -152,7 +190,12 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
if tool.Cost.has_schedules():
|
||||
tool.Cost.update_cost_items(pset=pset)
|
||||
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
|
||||
|
||||
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
|
||||
if is_bbim_dimension:
|
||||
_regenerate_parametric_dimension(self.file, element)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
|
||||
|
||||
@@ -520,6 +520,7 @@ def add_annotation(
|
||||
relating_type: ifcopenshell.entity_instance,
|
||||
enable_editing: bool = False,
|
||||
) -> bpy.types.Object:
|
||||
print(f"[SECTION] core.add_annotation called: object_type={object_type}")
|
||||
target_view = drawing_tool.get_drawing_target_view(drawing)
|
||||
context = drawing_tool.get_annotation_context(target_view, object_type)
|
||||
if not context:
|
||||
@@ -542,6 +543,11 @@ def add_annotation(
|
||||
if relating_type:
|
||||
drawing_tool.run_type_assign_type(element=element, relating_type=relating_type)
|
||||
ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), products=[element])
|
||||
if object_type == "SECTION":
|
||||
camera = ifc.get_object(drawing)
|
||||
print(f"[SECTION] add_annotation: object_type=SECTION, camera={camera}")
|
||||
if camera:
|
||||
drawing_tool.update_section_endpoints(obj, camera)
|
||||
if representation := drawing_tool.get_representation(element, context):
|
||||
drawing_tool.reload_representation(obj=obj, representation=representation)
|
||||
collector.assign(obj, should_clean_users_collection=True)
|
||||
@@ -550,6 +556,26 @@ def add_annotation(
|
||||
return obj
|
||||
|
||||
|
||||
def assign_manual_drawing_reference(
|
||||
ifc: type[tool.Ifc],
|
||||
drawing_tool: type[tool.Drawing],
|
||||
element: ifcopenshell.entity_instance,
|
||||
drawing: Optional[ifcopenshell.entity_instance],
|
||||
) -> None:
|
||||
for existing in drawing_tool.get_assigned_product_workaround(element):
|
||||
ifc.run("drawing.unassign_product", relating_product=existing, related_object=element)
|
||||
if drawing:
|
||||
ifc.run("drawing.assign_product", relating_product=drawing, related_object=element)
|
||||
|
||||
|
||||
def assign_manual_reference_document(
|
||||
drawing_tool: type[tool.Drawing],
|
||||
element: ifcopenshell.entity_instance,
|
||||
document: Optional[ifcopenshell.entity_instance],
|
||||
) -> None:
|
||||
drawing_tool.set_annotation_reference_doc(element, document)
|
||||
|
||||
|
||||
def build_schedule(drawing: type[tool.Drawing], schedule: ifcopenshell.entity_instance) -> None:
|
||||
drawing.create_svg_schedule(schedule)
|
||||
drawing.open_svg(drawing.get_path_with_ext(drawing.get_document_uri(schedule), "svg"))
|
||||
|
||||
@@ -77,6 +77,9 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties
|
||||
|
||||
|
||||
print("[SECTION] tool/drawing.py module loaded")
|
||||
|
||||
|
||||
class Drawing(bonsai.core.tool.Drawing):
|
||||
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
|
||||
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
|
||||
@@ -209,6 +212,17 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
co_end = co1 + vec * scaled_length
|
||||
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
|
||||
obj.matrix_world = obj.matrix_world @ Matrix.Rotation(math.radians(-90), 4, "Z")
|
||||
elif object_type == "SECTION_LEVEL":
|
||||
co1, _, co3, _ = annotation.Annotator.get_placeholder_coords()
|
||||
# co3 - co1 is the camera X direction (horizontal in a section view).
|
||||
vec = co3 - co1
|
||||
if vec.length == 0:
|
||||
vec = Vector((1, 0, 0))
|
||||
else:
|
||||
vec = vec.normalized()
|
||||
scaled_length = 0.023 * scale
|
||||
co_end = co1 + vec * scaled_length
|
||||
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
|
||||
elif object_type != "TEXT":
|
||||
obj = annotation.Annotator.add_line_to_annotation(obj)
|
||||
|
||||
@@ -1632,7 +1646,14 @@ 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")
|
||||
if not (element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL")):
|
||||
return False
|
||||
if ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsManualDrawingReference"):
|
||||
return False
|
||||
ptype = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL") and ifcopenshell.util.element.get_pset(element, "BBIM_Dimension"):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_drawing_reference_annotation(
|
||||
@@ -1964,6 +1985,95 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
element.Name = elevation.Name or "Unnamed"
|
||||
return element
|
||||
|
||||
@classmethod
|
||||
def create_manual_elevation_reference(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
||||
cursor_location = bpy.context.scene.cursor.location.copy()
|
||||
obj = bpy.data.objects.new("Unnamed", None)
|
||||
obj.empty_display_size = 0.1
|
||||
obj.matrix_world = Matrix.Translation(cursor_location) @ Matrix.Rotation(math.radians(90), 4, "X")
|
||||
element = cls.run_root_assign_class(
|
||||
obj=obj, ifc_class="IfcAnnotation", predefined_type="ELEVATION", should_add_representation=False
|
||||
)
|
||||
element.Name = "Unnamed"
|
||||
return element
|
||||
|
||||
@classmethod
|
||||
def create_manual_section_reference(
|
||||
cls, drawing: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance
|
||||
) -> ifcopenshell.entity_instance:
|
||||
cursor_location = bpy.context.scene.cursor.location.copy()
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
obj = bpy.data.objects.new("Unnamed", mesh)
|
||||
obj.matrix_world = Matrix.Translation(cursor_location)
|
||||
element = cls.run_root_assign_class(
|
||||
obj=obj, ifc_class="IfcAnnotation", predefined_type="SECTION", should_add_representation=False
|
||||
)
|
||||
element.Name = "Unnamed"
|
||||
builder = ShapeBuilder(tool.Ifc.get())
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
p1 = cursor_location + Vector((-0.5, 0, 0))
|
||||
p2 = cursor_location + Vector((0.5, 0, 0))
|
||||
points = [p1 / unit_scale, p2 / unit_scale]
|
||||
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 set_manual_drawing_reference(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
|
||||
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"IsManualDrawingReference": True})
|
||||
|
||||
@classmethod
|
||||
def is_manual_drawing_reference(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return bool(ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsManualDrawingReference"))
|
||||
|
||||
@classmethod
|
||||
def is_document_reference(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""Return True if this annotation links to an external document (not a Bonsai drawing camera)."""
|
||||
return bool(ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsDocumentReference"))
|
||||
|
||||
@classmethod
|
||||
def set_document_reference_flag(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
"""Mark this annotation as pointing to an external document reference."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
|
||||
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"IsDocumentReference": True})
|
||||
|
||||
@classmethod
|
||||
def get_annotation_reference_doc(
|
||||
cls, element: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the IfcDocumentInformation linked to a document-reference annotation."""
|
||||
for rel in element.HasAssociations:
|
||||
if rel.is_a("IfcRelAssociatesDocument"):
|
||||
doc = rel.RelatingDocument
|
||||
if doc.is_a("IfcDocumentInformation"):
|
||||
return doc
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def set_annotation_reference_doc(
|
||||
cls,
|
||||
element: ifcopenshell.entity_instance,
|
||||
document: Union[ifcopenshell.entity_instance, None],
|
||||
) -> None:
|
||||
"""Associate (or clear) an IfcDocumentInformation on a document-reference annotation."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
# Remove existing document associations on this annotation.
|
||||
for rel in list(element.HasAssociations):
|
||||
if rel.is_a("IfcRelAssociatesDocument"):
|
||||
ifcopenshell.api.document.unassign_document(
|
||||
ifc_file, products=[element], document=rel.RelatingDocument
|
||||
)
|
||||
if document:
|
||||
ifcopenshell.api.document.assign_document(ifc_file, products=[element], document=document)
|
||||
|
||||
@classmethod
|
||||
def regenerate_elevation_reference_annotation(
|
||||
cls,
|
||||
@@ -2810,6 +2920,176 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
numerator, denominator = scale.split("/")
|
||||
return float(numerator) / float(denominator)
|
||||
|
||||
@classmethod
|
||||
def get_camera_dimensions(cls, camera: bpy.types.Object) -> tuple[float, float]:
|
||||
render = bpy.context.scene.render
|
||||
assert isinstance(camera.data, bpy.types.Camera)
|
||||
if render.resolution_x > render.resolution_y:
|
||||
width = camera.data.ortho_scale
|
||||
height = width / render.resolution_x * render.resolution_y
|
||||
else:
|
||||
height = camera.data.ortho_scale
|
||||
width = height / render.resolution_y * render.resolution_x
|
||||
return width, height
|
||||
|
||||
@staticmethod
|
||||
def _section_ray_rect_intersections(
|
||||
origin: Vector, direction: Vector, half_w: float, half_h: float
|
||||
) -> list[float]:
|
||||
"""Return t values where the ray origin+t*direction intersects the ±half_w/±half_h rectangle."""
|
||||
results: list[float] = []
|
||||
eps = 1e-6
|
||||
if abs(direction.x) > eps:
|
||||
for x_bound in (-half_w, half_w):
|
||||
t = (x_bound - origin.x) / direction.x
|
||||
if abs(origin.y + t * direction.y) <= half_h + eps:
|
||||
results.append(t)
|
||||
if abs(direction.y) > eps:
|
||||
for y_bound in (-half_h, half_h):
|
||||
t = (y_bound - origin.y) / direction.y
|
||||
if abs(origin.x + t * direction.x) <= half_w + eps:
|
||||
results.append(t)
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def get_section_border_positions(
|
||||
cls,
|
||||
camera: bpy.types.Object,
|
||||
v0_world: Vector,
|
||||
v1_world: Vector,
|
||||
border_offset_mm: float,
|
||||
) -> tuple[Vector, Vector]:
|
||||
"""Return world-space positions for section endpoints placed at the camera border + border_offset_mm (paper mm)."""
|
||||
diagram_scale = cls.get_diagram_scale(camera)
|
||||
if not diagram_scale:
|
||||
print("[SECTION] get_section_border_positions: no diagram_scale, returning original")
|
||||
return v0_world, v1_world
|
||||
scale = cls.get_scale_ratio(diagram_scale["Scale"])
|
||||
model_offset = (border_offset_mm / 1000.0) / scale
|
||||
print(f"[SECTION] scale={scale}, border_offset_mm={border_offset_mm}, model_offset={model_offset:.4f}m")
|
||||
|
||||
width, height = cls.get_camera_dimensions(camera)
|
||||
half_w, half_h = width / 2, height / 2
|
||||
print(f"[SECTION] camera dims: width={width:.3f}, height={height:.3f}, half_w={half_w:.3f}, half_h={half_h:.3f}")
|
||||
|
||||
cam_inv = camera.matrix_world.inverted()
|
||||
v0_local = cam_inv @ v0_world
|
||||
v1_local = cam_inv @ v1_world
|
||||
print(f"[SECTION] v0_local={v0_local}, v1_local={v1_local}")
|
||||
|
||||
origin = Vector(((v0_local.x + v1_local.x) / 2, (v0_local.y + v1_local.y) / 2))
|
||||
dir_xy = Vector((v1_local.x - v0_local.x, v1_local.y - v0_local.y))
|
||||
if dir_xy.length < 1e-6:
|
||||
print("[SECTION] get_section_border_positions: degenerate edge, returning original")
|
||||
return v0_world, v1_world
|
||||
dir_xy = dir_xy.normalized()
|
||||
z = v0_local.z
|
||||
print(f"[SECTION] origin={origin}, dir_xy={dir_xy}, z={z:.4f}")
|
||||
|
||||
t_values = cls._section_ray_rect_intersections(origin, dir_xy, half_w, half_h)
|
||||
print(f"[SECTION] ray-rect t_values={t_values}")
|
||||
pos_ts = sorted(t for t in t_values if t >= 0)
|
||||
neg_ts = sorted((t for t in t_values if t < 0), reverse=True)
|
||||
print(f"[SECTION] pos_ts={pos_ts}, neg_ts={neg_ts}")
|
||||
|
||||
if not pos_ts or not neg_ts:
|
||||
print("[SECTION] get_section_border_positions: no valid border intersections, returning original")
|
||||
return v0_world, v1_world
|
||||
|
||||
t_end = pos_ts[0]
|
||||
t_start = neg_ts[0]
|
||||
new_v0_local = Vector((
|
||||
origin.x + (t_start + model_offset) * dir_xy.x,
|
||||
origin.y + (t_start + model_offset) * dir_xy.y,
|
||||
z,
|
||||
))
|
||||
new_v1_local = Vector((
|
||||
origin.x + (t_end - model_offset) * dir_xy.x,
|
||||
origin.y + (t_end - model_offset) * dir_xy.y,
|
||||
z,
|
||||
))
|
||||
return camera.matrix_world @ new_v0_local, camera.matrix_world @ new_v1_local
|
||||
|
||||
@classmethod
|
||||
def update_section_endpoints(cls, obj: bpy.types.Object, camera: bpy.types.Object) -> None:
|
||||
"""Move section line endpoints to camera border + BorderOffset, skipping any manually moved vertex."""
|
||||
print(f"[SECTION] update_section_endpoints called: obj={obj.name}, camera={camera.name}")
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
print("[SECTION] SKIP: no IFC element on obj")
|
||||
return
|
||||
if not obj.data or not hasattr(obj.data, "edges") or not obj.data.edges:
|
||||
print("[SECTION] SKIP: obj has no mesh edges")
|
||||
return
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {}
|
||||
border_offset = float(pset_data.get("BorderOffset", 8.0))
|
||||
print(f"[SECTION] pset_data={pset_data}, border_offset={border_offset}")
|
||||
if border_offset <= 0:
|
||||
print("[SECTION] SKIP: BorderOffset <= 0")
|
||||
return
|
||||
|
||||
auto_v0 = cls._parse_vector3(pset_data.get("AutoStartPosition") or "")
|
||||
auto_v1 = cls._parse_vector3(pset_data.get("AutoEndPosition") or "")
|
||||
print(f"[SECTION] stored auto_v0={auto_v0}, auto_v1={auto_v1}")
|
||||
|
||||
edge = obj.data.edges[0]
|
||||
v0 = obj.data.vertices[edge.vertices[0]]
|
||||
v1 = obj.data.vertices[edge.vertices[1]]
|
||||
v0_world = obj.matrix_world @ v0.co
|
||||
v1_world = obj.matrix_world @ v1.co
|
||||
print(f"[SECTION] current v0_world={v0_world}, v1_world={v1_world}")
|
||||
|
||||
# A vertex is "auto" if it has never been auto-positioned, or still sits at the stored auto position.
|
||||
v0_is_auto = auto_v0 is None or (v0_world - auto_v0).length < 1e-4
|
||||
v1_is_auto = auto_v1 is None or (v1_world - auto_v1).length < 1e-4
|
||||
print(f"[SECTION] v0_is_auto={v0_is_auto}, v1_is_auto={v1_is_auto}")
|
||||
|
||||
if not v0_is_auto and not v1_is_auto:
|
||||
print("[SECTION] SKIP: both vertices are manually overridden")
|
||||
return
|
||||
|
||||
new_v0_world, new_v1_world = cls.get_section_border_positions(camera, v0_world, v1_world, border_offset)
|
||||
print(f"[SECTION] new_v0_world={new_v0_world}, new_v1_world={new_v1_world}")
|
||||
|
||||
if v0_is_auto:
|
||||
v0.co = obj.matrix_world.inverted() @ new_v0_world
|
||||
if v1_is_auto:
|
||||
v1.co = obj.matrix_world.inverted() @ new_v1_world
|
||||
obj.data.update()
|
||||
|
||||
stored_v0 = new_v0_world if v0_is_auto else v0_world
|
||||
stored_v1 = new_v1_world if v1_is_auto else v1_world
|
||||
|
||||
pset_id = pset_data.get("id")
|
||||
if pset_id:
|
||||
pset_entity = tool.Ifc.get().by_id(pset_id)
|
||||
else:
|
||||
pset_entity = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Section")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
tool.Ifc.get(),
|
||||
pset=pset_entity,
|
||||
properties={
|
||||
"BorderOffset": border_offset,
|
||||
"AutoStartPosition": cls._format_vector3(stored_v0),
|
||||
"AutoEndPosition": cls._format_vector3(stored_v1),
|
||||
},
|
||||
)
|
||||
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="")
|
||||
print(f"[SECTION] done. stored auto_v0={cls._format_vector3(stored_v0)}, auto_v1={cls._format_vector3(stored_v1)}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_vector3(s: str) -> Optional[Vector]:
|
||||
try:
|
||||
x, y, z = map(float, s.split(","))
|
||||
return Vector((x, y, z))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_vector3(v: Vector) -> str:
|
||||
return f"{v.x:.6f},{v.y:.6f},{v.z:.6f}"
|
||||
|
||||
@classmethod
|
||||
def get_diagram_scale(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> dict[str, str]:
|
||||
props = cls.get_camera_props(camera)
|
||||
|
||||
@@ -970,18 +970,31 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
|
||||
return None
|
||||
for i, snap_obj in enumerate(cls.snap_objs):
|
||||
if obj.name == snap_obj.obj.name:
|
||||
# Handle objects modified while a modal operator is active.
|
||||
# Example: adding a door or window alters the wall geometry.
|
||||
try:
|
||||
cached_name = snap_obj.obj.name
|
||||
except ReferenceError:
|
||||
cls.snap_objs.pop(i)
|
||||
break
|
||||
if obj.name == cached_name:
|
||||
# Fast O(1) invalidation: vertex count change (mesh edit) or
|
||||
# world matrix change (object moved/rotated).
|
||||
if len(obj.data.vertices) != len(snap_obj.verts_3d):
|
||||
cls.snap_objs.pop(i)
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
|
||||
if (obj.matrix_world @ v1.co) != v2:
|
||||
return snap_obj
|
||||
if obj.matrix_world != snap_obj.matrix_world:
|
||||
cls.snap_objs.pop(i)
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
return snap_obj
|
||||
# Sample one vertex to catch mesh edits that preserve vertex count.
|
||||
if obj.data.vertices and snap_obj.verts_3d:
|
||||
if (obj.matrix_world @ obj.data.vertices[0].co) != snap_obj.verts_3d[0]:
|
||||
cls.snap_objs.pop(i)
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
return snap_obj
|
||||
return snap_obj
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
@@ -1020,6 +1033,7 @@ class SnapObj:
|
||||
self.root = None
|
||||
self._bvh_built = False
|
||||
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
||||
self.matrix_world = obj.matrix_world.copy()
|
||||
self.snap_points = []
|
||||
|
||||
def _ensure_bvh(self):
|
||||
|
||||
@@ -94,9 +94,15 @@ def list_functions(module: str) -> list[dict]:
|
||||
|
||||
|
||||
def function_docs(module: str, function: str) -> dict:
|
||||
"""Full documentation for a single API function.
|
||||
"""Show the full documentation for one ifcopenshell.api function.
|
||||
|
||||
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
|
||||
Returns the summary and long description, every parameter with its type,
|
||||
default and description, and the return type. Read this before calling
|
||||
``run_api()`` so that parameter names and value types are correct.
|
||||
|
||||
:param module: API module name, for example ``'root'``.
|
||||
:param function: Function name within the module, for example
|
||||
``'create_entity'``.
|
||||
"""
|
||||
fn = _get_underlying_function(module, function)
|
||||
if fn is None:
|
||||
|
||||
@@ -14,10 +14,21 @@ def list_rules() -> list[dict[str, str]]:
|
||||
|
||||
|
||||
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
"""Compute base quantities for elements and write them into the model.
|
||||
|
||||
Modifies the model in-place by adding/updating IfcElementQuantity psets.
|
||||
Returns a summary dict with ok, rule, and elements_quantified.
|
||||
This is a write operation: it derives lengths, areas and volumes from
|
||||
element geometry and adds or updates their ``IfcElementQuantity`` sets.
|
||||
It does not report a schedule — see ``ifcquery.schedule()`` for the
|
||||
construction programme and ``ifcquery.cost()`` for cost schedules. An
|
||||
unrecognised ``rule`` is reported as an error listing the rules that are
|
||||
available.
|
||||
|
||||
:param model: The in-memory IFC model. Modified in-place.
|
||||
:param rule: Quantity take-off rule set, for example
|
||||
``'IFC4QtoBaseQuantities'`` or ``'IFC4X3QtoBaseQuantities'``.
|
||||
:param selector: ifcopenshell selector restricting which elements are
|
||||
measured, e.g. ``'IfcWall'``. Omit to measure every ``IfcElement`` and
|
||||
``IfcSpace``.
|
||||
"""
|
||||
from ifc5d.qto import edit_qtos, quantify
|
||||
from ifc5d.qto import rules as rule_sets
|
||||
|
||||
+133
-45
@@ -35,6 +35,27 @@ from ifcquery import (
|
||||
from ifcquery import validate as validate_mod
|
||||
|
||||
|
||||
def _use_doc(source: Callable, extra: str = "") -> Callable:
|
||||
"""Decorator: copy `source`'s docstring onto the decorated method.
|
||||
|
||||
Keeps the query/edit logic in ``ifcquery``/``ifcedit`` as the single
|
||||
source of truth for what a delegating ``IfcSession`` method does, rather
|
||||
than maintaining a second prose description here. Only ``__doc__`` is
|
||||
copied — unlike `functools.wraps`, this leaves the method's own signature
|
||||
(and MCP tool schema derived from it) untouched.
|
||||
|
||||
:param extra: Optional session-specific note appended after `source`'s
|
||||
docstring, for the handful of methods that translate an argument
|
||||
(e.g. a JSON/MCP-friendly default) before delegating.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
fn.__doc__ = (source.__doc__ or "").rstrip() + extra
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _jsonify(x: Any) -> Any:
|
||||
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
|
||||
if x is None or isinstance(x, (str, int, float, bool)):
|
||||
@@ -231,20 +252,47 @@ class IfcSession:
|
||||
return self.model
|
||||
|
||||
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
|
||||
"""Create a new empty IFC model in memory."""
|
||||
"""Create a new empty IFC model in memory.
|
||||
|
||||
Replaces the model currently held by the session, discarding any unsaved
|
||||
edits. The new model has no file path of its own, so ``ifc_save`` must be
|
||||
given an explicit path.
|
||||
|
||||
:param schema: IFC schema version — ``IFC2X3``, ``IFC4``, ``IFC4X1``,
|
||||
``IFC4X2`` or ``IFC4X3`` — passed straight to ``ifcopenshell.file()``
|
||||
(default ``IFC4``). ``IFC4X3_ADD2`` is also accepted and, like
|
||||
``IFC4X3``, produces a model whose ``schema`` reports ``IFC4X3``.
|
||||
"""
|
||||
self.model = ifcopenshell.file(schema=schema)
|
||||
self.model_path = None
|
||||
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
|
||||
|
||||
def ifc_load(self, path: str) -> str:
|
||||
"""Open an IFC file into memory. Returns confirmation string."""
|
||||
"""Open an IFC file from disk into the session.
|
||||
|
||||
Replaces the model currently held by the session, discarding any unsaved
|
||||
edits, and remembers the path so a later ``ifc_save`` can overwrite it.
|
||||
Call this before any query or edit method. Returns a confirmation string
|
||||
naming the schema version and entity count.
|
||||
|
||||
:param path: Filesystem path of the IFC file to open.
|
||||
"""
|
||||
self.model = ifcopenshell.open(path)
|
||||
self.model_path = path
|
||||
count = sum(1 for _ in self.model)
|
||||
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
|
||||
|
||||
def ifc_save(self, path: str = "") -> str:
|
||||
"""Write the in-memory model to disk. Empty path overwrites the original file."""
|
||||
"""Write the in-memory model to disk.
|
||||
|
||||
Overwrites the target file without further confirmation. Edits made by
|
||||
``ifc_edit``, ``ifc_shape`` and ``ifc_quantify`` exist only in memory
|
||||
until this is called.
|
||||
|
||||
:param path: Destination path. Omit to overwrite the file the model was
|
||||
loaded from; this fails for a model created by ``ifc_new``, which has
|
||||
no original path.
|
||||
"""
|
||||
model = self._require_model()
|
||||
target = path if path else self.model_path
|
||||
if not target:
|
||||
@@ -253,7 +301,11 @@ class IfcSession:
|
||||
return f"Saved to {target}"
|
||||
|
||||
def ifc_reset(self) -> dict[str, Any]:
|
||||
"""Drop the in-memory model."""
|
||||
"""Discard the in-memory model.
|
||||
|
||||
Drops the model and its file path, throwing away any edits not already
|
||||
written with ``ifc_save``. Succeeds even when no model is loaded.
|
||||
"""
|
||||
self.model = None
|
||||
self.model_path = None
|
||||
return {"ok": True}
|
||||
@@ -261,39 +313,42 @@ class IfcSession:
|
||||
# -------------
|
||||
# Query tools
|
||||
# -------------
|
||||
@_use_doc(summary.summary)
|
||||
def ifc_summary(self) -> dict[str, Any]:
|
||||
"""Model overview: schema, entity counts, project info."""
|
||||
return summary.summary(self._require_model())
|
||||
|
||||
@_use_doc(tree.tree)
|
||||
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
|
||||
return tree.tree(self._require_model())
|
||||
|
||||
@_use_doc(info.info)
|
||||
def ifc_info(self, element_id: int) -> dict[str, Any]:
|
||||
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return info.info(model, element)
|
||||
|
||||
@_use_doc(select.select)
|
||||
def ifc_select(self, query: str) -> list[dict[str, Any]]:
|
||||
"""Filter elements using ifcopenshell selector syntax.
|
||||
|
||||
Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``,
|
||||
``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``,
|
||||
``material = "Concrete"``.
|
||||
"""
|
||||
return select.select(self._require_model(), query)
|
||||
|
||||
@_use_doc(relations.relations)
|
||||
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return relations.relations(model, element, traverse=traverse if traverse else None)
|
||||
|
||||
@_use_doc(
|
||||
clash_mod.clash,
|
||||
extra=(
|
||||
"\n\nNote: this method takes a plain ``clearance: float`` rather than\n"
|
||||
'``clearance: float | None`` — ``0.0`` (the default) means "skip the\n'
|
||||
'clearance check", matching ``None`` in ``ifcquery.clash.clash()``.'
|
||||
),
|
||||
)
|
||||
def ifc_clash(
|
||||
self,
|
||||
element_id: int,
|
||||
@@ -301,7 +356,6 @@ class IfcSession:
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
@@ -314,33 +368,53 @@ class IfcSession:
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@_use_doc(contexts_mod.contexts)
|
||||
def ifc_contexts(self) -> list[dict[str, Any]]:
|
||||
"""List all geometric representation contexts and subcontexts with their step IDs."""
|
||||
return contexts_mod.contexts(self._require_model())
|
||||
|
||||
@_use_doc(materials_mod.materials)
|
||||
def ifc_materials(self) -> list[dict[str, Any]]:
|
||||
"""List all materials and material sets (layers, constituents, profiles)."""
|
||||
return materials_mod.materials(self._require_model())
|
||||
|
||||
# ------------------------
|
||||
# Edit discovery + execute
|
||||
# ------------------------
|
||||
def ifc_list(self, module: str = "") -> list[dict]:
|
||||
"""List all API modules, or functions within a module. Empty module = all modules."""
|
||||
"""Discover the ifcopenshell.api functions available for editing.
|
||||
|
||||
With no argument returns every API module with its description,
|
||||
function names and function count. With a module name returns that
|
||||
module's functions, each with a one-line description and its
|
||||
parameters. This is the starting point for ``ifc_docs`` and
|
||||
``ifc_edit``; it inspects the installed ifcopenshell package and works
|
||||
without a model loaded.
|
||||
|
||||
:param module: API module name, for example ``'root'``, ``'geometry'``
|
||||
or ``'pset'``. Omit to list all modules.
|
||||
"""
|
||||
return list_functions(module) if module else list_modules()
|
||||
|
||||
@_use_doc(function_docs)
|
||||
def ifc_docs(self, function_path: str) -> dict:
|
||||
"""Show full documentation for an API function. Input format: 'module.function'."""
|
||||
module, function = function_path.split(".", 1)
|
||||
return function_docs(module, function)
|
||||
|
||||
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
|
||||
"""Execute an ifcopenshell.api mutation.
|
||||
"""Run an ifcopenshell.api function to modify the model.
|
||||
|
||||
params may be:
|
||||
- JSON string
|
||||
- dict (from tool calling / JS)
|
||||
- JsProxy (handled upstream in embedded.py)
|
||||
This is the general-purpose edit method; use ``ifc_list`` and
|
||||
``ifc_docs`` first to find the function and its parameters. Changes
|
||||
are made to the in-memory model only, so ``ifc_save`` is needed to
|
||||
persist them. Returns ``{"ok": True, "result": ...}``, or
|
||||
``{"ok": False, "error": ...}`` when the function is unknown, a
|
||||
parameter cannot be converted, or the call raises.
|
||||
|
||||
:param function_path: ``'module.function'``, for example
|
||||
``'root.create_entity'``.
|
||||
:param params: Keyword arguments as a JSON string, a dict (tool
|
||||
calling) or a JsProxy (handled upstream in embedded.py). Pass
|
||||
entity references as integer step IDs, and arguments typed as an
|
||||
IFC file as a file path string.
|
||||
"""
|
||||
model = self._require_model()
|
||||
module, function = function_path.split(".", 1)
|
||||
@@ -359,28 +433,20 @@ class IfcSession:
|
||||
# ------------------------
|
||||
# Extended query + edit tools
|
||||
# ------------------------
|
||||
@_use_doc(validate_mod.validate)
|
||||
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
|
||||
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
|
||||
return validate_mod.validate(self._require_model(), express_rules=express_rules)
|
||||
|
||||
@_use_doc(schedule.schedule)
|
||||
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List work schedules and nested tasks from the model.
|
||||
|
||||
max_depth limits subtask expansion (None = unlimited). At the cutoff,
|
||||
subtasks is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return schedule.schedule(self._require_model(), max_depth=max_depth)
|
||||
|
||||
@_use_doc(cost_mod.cost)
|
||||
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List cost schedules and nested cost items from the model.
|
||||
|
||||
max_depth limits cost item expansion (None = unlimited). At the cutoff,
|
||||
subitems is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return cost_mod.cost(self._require_model(), max_depth=max_depth)
|
||||
|
||||
@_use_doc(schema.schema)
|
||||
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
|
||||
"""Return IFC class documentation for entity_type using the model's schema version."""
|
||||
return schema.schema(self._require_model(), entity_type)
|
||||
|
||||
def ifc_plot(
|
||||
@@ -456,18 +522,43 @@ class IfcSession:
|
||||
# Shape builder tools
|
||||
# ------------------------
|
||||
def ifc_shape_list(self) -> list[dict]:
|
||||
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
|
||||
"""List the ShapeBuilder methods available for constructing geometry.
|
||||
|
||||
Returns every public ``ifcopenshell.util.shape_builder.ShapeBuilder``
|
||||
method with a one-line description and its parameter names, read
|
||||
directly from that class's own docstrings. Use it to find a method,
|
||||
then ``ifc_shape_docs`` for the details and ``ifc_shape`` to call it.
|
||||
Works without a model loaded.
|
||||
"""
|
||||
return _list_shape_methods()
|
||||
|
||||
def ifc_shape_docs(self, method: str) -> dict:
|
||||
"""Full documentation for a ShapeBuilder method: params, types, return value."""
|
||||
"""Show the full documentation for one ShapeBuilder method.
|
||||
|
||||
Returns the summary and long description, every parameter with its
|
||||
type and default, and the return type — read directly from
|
||||
``ShapeBuilder``'s own docstring. Read this before ``ifc_shape`` so
|
||||
that argument names and value shapes are correct. Works without a
|
||||
model loaded.
|
||||
|
||||
:param method: ShapeBuilder method name, for example ``'polyline'``,
|
||||
``'rectangle'`` or ``'extrude'``.
|
||||
"""
|
||||
return _shape_method_docs(method)
|
||||
|
||||
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
|
||||
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
|
||||
"""Call a ShapeBuilder method to build geometry in the model.
|
||||
|
||||
params is a JSON string of keyword arguments. Pass entity references as integer
|
||||
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
|
||||
The created entities are added to the in-memory model, so
|
||||
``ifc_save`` is needed to persist them. On success the result
|
||||
identifies the created entity by step ID and type; an unknown method
|
||||
or a failed call is reported as an error instead.
|
||||
|
||||
:param method: ShapeBuilder method name, as listed by
|
||||
``ifc_shape_list``.
|
||||
:param params: JSON string of keyword arguments. Pass entity
|
||||
references as integer step IDs and vectors as JSON arrays, e.g.
|
||||
``[1.0, 0.0, 0.0]``.
|
||||
"""
|
||||
model = self._require_model()
|
||||
|
||||
@@ -493,11 +584,8 @@ class IfcSession:
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
@_use_doc(run_quantify, extra="\n\nCall ``ifc_save`` afterwards to persist the result.")
|
||||
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place; call ifc_save() after.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return run_quantify(model, rule, selector=selector if selector else None)
|
||||
|
||||
|
||||
+29
-23
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
@@ -23,6 +24,11 @@ def build_server() -> Any:
|
||||
|
||||
session = IfcSession()
|
||||
|
||||
def _tool(fn):
|
||||
"""Register a tool, taking its MCP description from the identically-named
|
||||
IfcSession method rather than duplicating it here."""
|
||||
return server.tool(description=inspect.getdoc(getattr(IfcSession, fn.__name__)))(fn)
|
||||
|
||||
server = FastMCP(
|
||||
name="ifc-mcp",
|
||||
instructions=(
|
||||
@@ -33,44 +39,44 @@ def build_server() -> Any:
|
||||
)
|
||||
|
||||
# ---- Lifecycle ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
|
||||
return session.ifc_new(schema=schema)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_load(path: str) -> str:
|
||||
return session.ifc_load(path)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_save(path: str = "") -> str:
|
||||
return session.ifc_save(path)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_reset() -> dict[str, Any]:
|
||||
return session.ifc_reset()
|
||||
|
||||
# ---- Query ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_summary() -> dict[str, Any]:
|
||||
return session.ifc_summary()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_tree()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_info(element_id: int) -> dict[str, Any]:
|
||||
return session.ifc_info(element_id)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_select(query: str) -> list[dict[str, Any]]:
|
||||
return session.ifc_select(query)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_relations(element_id, traverse=traverse)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_clash(
|
||||
element_id: int,
|
||||
clearance: float = 0.0,
|
||||
@@ -84,58 +90,58 @@ def build_server() -> Any:
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_contexts() -> list[dict[str, Any]]:
|
||||
return session.ifc_contexts()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_materials() -> list[dict[str, Any]]:
|
||||
return session.ifc_materials()
|
||||
|
||||
# ---- Edit ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_list(module: str = "") -> list[dict]:
|
||||
return session.ifc_list(module=module)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_docs(function_path: str) -> dict:
|
||||
return session.ifc_docs(function_path=function_path)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_edit(function_path: str, params: str = "{}") -> dict:
|
||||
return session.ifc_edit(function_path=function_path, params=params)
|
||||
|
||||
# ---- Extended query + edit ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
|
||||
return session.ifc_validate(express_rules=express_rules)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_schedule(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_cost(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_schema(entity_type: str) -> dict[str, Any]:
|
||||
return session.ifc_schema(entity_type=entity_type)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||
return session.ifc_quantify(rule=rule, selector=selector)
|
||||
|
||||
# ---- Shape builder ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_shape_list() -> list[dict]:
|
||||
return session.ifc_shape_list()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_shape_docs(method: str) -> dict:
|
||||
return session.ifc_shape_docs(method=method)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_shape(method: str, params: str = "{}") -> dict:
|
||||
return session.ifc_shape(method=method, params=params)
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ class TestServerRegistration:
|
||||
for name in expected:
|
||||
assert name in tools, f"Tool {name} not registered"
|
||||
|
||||
def test_all_tools_have_descriptions(self):
|
||||
server = build_server()
|
||||
tools = server._tool_manager.list_tools()
|
||||
missing = [t.name for t in tools if not (t.description or "").strip()]
|
||||
assert not missing, f"Tools with no description: {missing}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_fns():
|
||||
|
||||
@@ -79,7 +79,7 @@ from .get_layout_curve import get_layout_curve
|
||||
from .get_layout_segments import get_layout_segments
|
||||
from .get_mapped_segments import get_mapped_segments
|
||||
from .get_parent_alignment import get_parent_alignment
|
||||
from .get_referent_nest import get_referent_nest
|
||||
from .get_stationing_nest import get_stationing_nest
|
||||
from .get_vertical_layout import get_vertical_layout
|
||||
from .has_zero_length_segment import has_zero_length_segment
|
||||
from .layout_horizontal_alignment_by_pi_method import (
|
||||
@@ -91,6 +91,7 @@ from .layout_vertical_alignment_by_pi_method import (
|
||||
from .name_segments import name_segments
|
||||
from .update_end_point import update_end_point
|
||||
from .update_fallback_position import update_fallback_position
|
||||
from .update_key_point_referents import update_key_point_referents
|
||||
from .util import *
|
||||
|
||||
__all__ = [
|
||||
@@ -124,7 +125,7 @@ __all__ = [
|
||||
"get_layout_curve",
|
||||
"get_layout_segments",
|
||||
"get_parent_alignment",
|
||||
"get_referent_nest",
|
||||
"get_stationing_nest",
|
||||
"get_vertical_layout",
|
||||
"has_zero_length_segment",
|
||||
"layout_horizontal_alignment_by_pi_method",
|
||||
@@ -133,5 +134,6 @@ __all__ = [
|
||||
"register_referent_name_callback",
|
||||
"update_end_point",
|
||||
"update_fallback_position",
|
||||
"update_key_point_referents",
|
||||
"get_mapped_segments",
|
||||
]
|
||||
|
||||
+6
-5
@@ -26,9 +26,10 @@ _cant_callback = None
|
||||
|
||||
def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
|
||||
"""
|
||||
Referents are automatically created at the start of each horizontal, vertical, and cant segment.
|
||||
The referents represent key points in the alignment layout such as Point of Curvature, Point of Tangent, and others.
|
||||
Different juristicions use different naming systems for these key points.
|
||||
Referents are created at the start of each horizontal, vertical, and cant segment by
|
||||
ifcopenshell.api.alignment.update_key_point_referents. The referents represent key points in the
|
||||
alignment layout such as Point of Curvature, Point of Tangent, and others. Different
|
||||
juristicions use different naming systems for these key points.
|
||||
|
||||
The referent name callback functions provide a customizable method for naming these referents. If a callback is registered,
|
||||
it is called when creating the referent name, otherwise the default naming is used.
|
||||
@@ -39,8 +40,8 @@ def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
|
||||
|
||||
The callback function returns a string that is used in the referent name for the referent at the start of `segment`.
|
||||
The callback must accomodate the following cases:
|
||||
* prev_segment = None and segment != None - this indicates the last segment so the "End of Alignment" name is returned
|
||||
* prev_segment != None and segment == None - this indicates the first segment so the "Beginning of Alignment" name is returned
|
||||
* prev_segment = None and segment != None - this indicates the first segment so the "Beginning of Alignment" name is returned
|
||||
* prev_segment != None and segment == None - this indicates the last segment so the "End of Alignment" name is returned
|
||||
* prev_segment != None and segment != None - this indicates an intermediate segment so a name representitive of the transition is returned
|
||||
|
||||
Setting any or all of the callbacks to None causes the default naming to be used.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def _sort_nest(nest: entity_instance, key: Callable) -> entity_instance:
|
||||
"""Sorts the RelatedObjects of an IfcRelNests in place, by an arbitrary key function."""
|
||||
nest.RelatedObjects = sorted(nest.RelatedObjects, key=key)
|
||||
return nest
|
||||
@@ -20,6 +20,7 @@ from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment._sort_nest import _sort_nest
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.guid
|
||||
@@ -114,7 +115,7 @@ def add_stationing_referent(
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
|
||||
|
||||
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
if nest is None:
|
||||
nest = file.createIfcRelNests(
|
||||
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,)
|
||||
@@ -122,8 +123,6 @@ def add_stationing_referent(
|
||||
else:
|
||||
nest.RelatedObjects += (referent,)
|
||||
|
||||
nest.RelatedObjects = sorted(
|
||||
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
||||
)
|
||||
_sort_nest(nest, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
|
||||
|
||||
return referent
|
||||
|
||||
@@ -64,22 +64,22 @@ def create_representation(
|
||||
|
||||
# if the alignment is created without geometry it's stationing referent isn't related to the alignment geometry.
|
||||
# the stationing referent needs to be updated to have an IfcLinearPlacement that references the basis curve geometry
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
if (
|
||||
referent_nest
|
||||
and 0 < len(referent_nest.RelatedObjects)
|
||||
and referent_nest.RelatedObjects[0].ObjectPlacement
|
||||
and not referent_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
stationing_nest
|
||||
and 0 < len(stationing_nest.RelatedObjects)
|
||||
and stationing_nest.RelatedObjects[0].ObjectPlacement
|
||||
and not stationing_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
):
|
||||
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
|
||||
if referent_nest.RelatedObjects[0].ObjectPlacement:
|
||||
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
|
||||
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement)
|
||||
if stationing_nest.RelatedObjects[0].ObjectPlacement:
|
||||
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
|
||||
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement)
|
||||
|
||||
lp = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
@@ -93,4 +93,4 @@ def create_representation(
|
||||
)
|
||||
)
|
||||
update_fallback_position(file, lp)
|
||||
referent_nest.RelatedObjects[0].ObjectPlacement = lp
|
||||
stationing_nest.RelatedObjects[0].ObjectPlacement = lp
|
||||
|
||||
@@ -67,8 +67,8 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
|
||||
print(dist_along) # 100.00
|
||||
"""
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
if referent_nest is None:
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
if stationing_nest is None:
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
return station - start_station
|
||||
|
||||
@@ -77,7 +77,7 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
|
||||
_distance_along_of_referent(referent),
|
||||
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
|
||||
)
|
||||
for referent in referent_nest.RelatedObjects
|
||||
for referent in stationing_nest.RelatedObjects
|
||||
]
|
||||
stations.sort(key=lambda entry: entry[0])
|
||||
|
||||
|
||||
+9
-3
@@ -20,12 +20,18 @@ import ifcopenshell
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
|
||||
def get_stationing_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
|
||||
"""
|
||||
Searches for the IfcRelNest that contains IfcReferent.
|
||||
Searches for the IfcRelNests that defines the alignment's stationing scheme.
|
||||
|
||||
The returned nest is nested to the IfcAlignment and its RelatedObjects contains only the
|
||||
IfcReferent(s) (PredefinedType="STATION") that establish the alignment's starting station and
|
||||
any station equations along it, as created by add_stationing_referent. It does not contain any
|
||||
other kind of referent (e.g. key-point referents from update_key_point_referents live in their
|
||||
own, separate IfcRelNests).
|
||||
|
||||
:param file:
|
||||
:param alignment: The IfcAlignment which hosts IfcReferent
|
||||
:param alignment: The IfcAlignment which hosts the stationing IfcReferent(s)
|
||||
:return: Returns the IfcRelNests or None
|
||||
"""
|
||||
if not alignment.is_a("IfcAlignment"):
|
||||
@@ -0,0 +1,222 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.alignment
|
||||
import ifcopenshell.util.element
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
from ifcopenshell.api.alignment._sort_nest import _sort_nest
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
|
||||
|
||||
def _get_key_point_referent_nest(layout: entity_instance) -> Optional[entity_instance]:
|
||||
"""
|
||||
Searches layout.IsNestedBy for the IfcRelNests whose RelatedObjects are IfcReferent.
|
||||
|
||||
This is distinct from both get_stationing_nest (scoped to the parent IfcAlignment, and
|
||||
specifically the STATION/station-equation nest) and get_alignment_segment_nest (the *segment*
|
||||
nest that also lives on layout.IsNestedBy, holding IfcAlignmentSegment, never IfcReferent).
|
||||
"""
|
||||
for nest in layout.IsNestedBy:
|
||||
for related_object in nest.RelatedObjects:
|
||||
if related_object.is_a("IfcReferent"):
|
||||
return nest
|
||||
return None
|
||||
|
||||
|
||||
def _remove_referent(file: ifcopenshell.file, referent: entity_instance) -> None:
|
||||
"""Cleanly deletes a key-point IfcReferent: its Pset_Stationing, its ObjectPlacement (if
|
||||
exclusively owned by it), and finally the referent itself."""
|
||||
for inverse in list(file.get_inverse(referent)):
|
||||
if inverse.is_a("IfcRelDefinesByProperties"):
|
||||
ifcopenshell.api.pset.remove_pset(file, product=referent, pset=inverse.RelatingPropertyDefinition)
|
||||
|
||||
object_placement = referent.ObjectPlacement
|
||||
if object_placement and file.get_total_inverses(object_placement) == 1:
|
||||
referent.ObjectPlacement = None
|
||||
ifcopenshell.util.element.remove_deep2(file, object_placement)
|
||||
|
||||
file.remove(referent) # also strips referent out of any IfcRelNests.RelatedObjects referencing it
|
||||
|
||||
|
||||
def _create_key_point_referent(
|
||||
file: ifcopenshell.file,
|
||||
alignment: entity_instance,
|
||||
curve: Optional[entity_instance],
|
||||
label: str,
|
||||
distance_along: float,
|
||||
station: float,
|
||||
) -> entity_instance:
|
||||
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
|
||||
object_placement = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
Location=file.createIfcPointByDistanceExpression(
|
||||
DistanceAlong=file.createIfcLengthMeasure(distance_along),
|
||||
OffsetLateral=None,
|
||||
OffsetVertical=None,
|
||||
OffsetLongitudinal=None,
|
||||
BasisCurve=curve,
|
||||
)
|
||||
),
|
||||
)
|
||||
update_fallback_position(file, object_placement)
|
||||
else:
|
||||
object_placement = file.createIfcLocalPlacement(
|
||||
PlacementRelTo=None,
|
||||
RelativePlacement=file.createIfcAxis2Placement2D(
|
||||
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
||||
),
|
||||
)
|
||||
|
||||
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
|
||||
|
||||
referent = file.createIfcReferent(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
OwnerHistory=None,
|
||||
Name=name,
|
||||
Description=None,
|
||||
ObjectType=None,
|
||||
ObjectPlacement=object_placement,
|
||||
Representation=None,
|
||||
PredefinedType="POSITION",
|
||||
)
|
||||
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
||||
|
||||
return referent
|
||||
|
||||
|
||||
def update_key_point_referents(
|
||||
file: ifcopenshell.file,
|
||||
layout: entity_instance,
|
||||
rel_nests: Optional[entity_instance] = None,
|
||||
clear: bool = False,
|
||||
) -> entity_instance:
|
||||
"""
|
||||
Creates IfcReferent key-point markers for every segment transition in an alignment layout.
|
||||
|
||||
Labels are derived from _get_segment_start_point_label (e.g. "P.C.", "P.T.", "P.O.B.",
|
||||
"P.V.C.", ...), with the station appended, e.g. "P.C. (145+98.32)". Different jurisdictions use
|
||||
different naming systems for these key points -- register_referent_name_callback() lets a
|
||||
caller override the default horizontal/vertical/cant labeling before calling this function; if
|
||||
a callback is registered, its output is used here instead of the built-in labels. Referents are
|
||||
nested to `rel_nests`, an IfcRelNests distinct from the layout's segment nest (found via
|
||||
get_alignment_segment_nest) and from the alignment's stationing nest (found via
|
||||
get_stationing_nest) -- key-point referents never belong in either of those.
|
||||
|
||||
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
|
||||
:param rel_nests: an existing IfcRelNests to (re)populate. May live anywhere (e.g. the parent
|
||||
IfcAlignment, the layout, or elsewhere) -- the caller decides. If omitted, an existing
|
||||
referent-nest already on `layout` is reused, or a new one is created and related to `layout`.
|
||||
:param clear: if True, deletes all IfcReferent currently in rel_nests.RelatedObjects (and their
|
||||
Pset_Stationing) before regenerating. If False (default), new referents are appended to
|
||||
whatever already exists -- no deduplication.
|
||||
:return: the IfcRelNests, with RelatedObjects sorted ascending by Pset_Stationing.Station
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
|
||||
|
||||
Example, with custom labels for a jurisdiction that doesn't use the built-in abbreviations:
|
||||
|
||||
.. code:: python
|
||||
|
||||
def my_horizontal_labels(prev_segment, segment):
|
||||
if prev_segment is None:
|
||||
return "Start"
|
||||
if segment is None:
|
||||
return "End"
|
||||
return "Curve Point" # a name representative of the prev_segment -> segment transition
|
||||
|
||||
ifcopenshell.api.alignment.register_referent_name_callback(horizontal=my_horizontal_labels)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
|
||||
# nest.RelatedObjects[0].Name starts with "Start (" instead of the default "P.O.B. ("
|
||||
"""
|
||||
|
||||
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
|
||||
if not layout.is_a() in expected_types:
|
||||
raise TypeError(
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
|
||||
)
|
||||
|
||||
if rel_nests is None:
|
||||
rel_nests = _get_key_point_referent_nest(layout)
|
||||
if rel_nests is None:
|
||||
rel_nests = file.createIfcRelNests(
|
||||
GlobalId=ifcopenshell.guid.new(), RelatingObject=layout, RelatedObjects=()
|
||||
)
|
||||
|
||||
if clear:
|
||||
for referent in list(rel_nests.RelatedObjects):
|
||||
_remove_referent(file, referent)
|
||||
rel_nests.RelatedObjects = ()
|
||||
|
||||
segments = list(ifcopenshell.api.alignment.get_layout_segments(layout))
|
||||
if segments and ifcopenshell.api.alignment.has_zero_length_segment(layout):
|
||||
segments = segments[:-1]
|
||||
|
||||
if not segments:
|
||||
_sort_nest(
|
||||
rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
||||
)
|
||||
return rel_nests
|
||||
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
|
||||
|
||||
new_referents = []
|
||||
distance_along = 0.0
|
||||
prev_segment = None
|
||||
for segment in segments:
|
||||
dp = segment.DesignParameters
|
||||
seg_distance_along = distance_along if is_horizontal else dp.StartDistAlong
|
||||
|
||||
label = _get_segment_start_point_label(prev_segment, segment)
|
||||
station = start_station + seg_distance_along
|
||||
new_referents.append(_create_key_point_referent(file, alignment, curve, label, seg_distance_along, station))
|
||||
|
||||
if is_horizontal:
|
||||
distance_along += dp.SegmentLength
|
||||
else:
|
||||
distance_along = dp.StartDistAlong + dp.HorizontalLength
|
||||
|
||||
prev_segment = segment
|
||||
|
||||
label = _get_segment_start_point_label(prev_segment, None)
|
||||
station = start_station + distance_along
|
||||
new_referents.append(_create_key_point_referent(file, alignment, curve, label, distance_along, station))
|
||||
|
||||
rel_nests.RelatedObjects = tuple(rel_nests.RelatedObjects) + tuple(new_referents)
|
||||
_sort_nest(rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
|
||||
|
||||
return rel_nests
|
||||
@@ -25,12 +25,25 @@ annotations may have relationships which indicate smart data being populated.
|
||||
from .. import wrap_usecases
|
||||
from .assign_product import assign_product
|
||||
from .edit_text_literal import edit_text_literal
|
||||
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths
|
||||
from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_local_point, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor
|
||||
from .unassign_product import unassign_product
|
||||
|
||||
wrap_usecases(__path__, __name__)
|
||||
|
||||
__all__ = [
|
||||
"assign_product",
|
||||
"build_anchor_from_hit",
|
||||
"build_anchor_from_layer_boundary",
|
||||
"build_anchor_from_local_point",
|
||||
"build_anchor_from_profile_edge",
|
||||
"build_anchor_from_profile_vert",
|
||||
"edit_text_literal",
|
||||
"get_dimension_segment_lengths",
|
||||
"get_layer_snap_candidates",
|
||||
"get_profile_snap_candidates",
|
||||
"make_world_anchor",
|
||||
"regenerate_dimension",
|
||||
"resolve_anchor",
|
||||
"unassign_product",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors.
|
||||
|
||||
This module operates purely on IFC data. It:
|
||||
1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an
|
||||
``IfcAnnotation``.
|
||||
2. Resolves each anchor to a world-space point (IFC project units) using
|
||||
``resolve_anchor``.
|
||||
3. Computes per-segment distances and updates (or creates) the linked
|
||||
``IfcMetric`` + ``IfcRelAssociatesConstraint`` entities.
|
||||
4. Returns the ordered list of resolved world-space points so that the
|
||||
Bonsai operator layer can update the Blender curve object.
|
||||
|
||||
Updating the Blender curve (converting IFC world coords → annotation local
|
||||
coords) is the *caller's* responsibility and does **not** happen here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.owner
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.element
|
||||
|
||||
from .resolve_anchor import resolve_anchor
|
||||
|
||||
|
||||
_PSET_NAME = "BBIM_Dimension"
|
||||
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
|
||||
|
||||
|
||||
def regenerate_dimension(
|
||||
file: ifcopenshell.file,
|
||||
annotation: ifcopenshell.entity_instance,
|
||||
settings: Optional[ifcopenshell.geom.settings] = None,
|
||||
shape_cache: Optional[dict] = None,
|
||||
placement_override: Optional[dict] = None,
|
||||
camera_dir: Optional[tuple[float, float, float]] = None,
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Regenerate a parametric dimension from its stored anchor references.
|
||||
|
||||
Resolves every anchor in ``BBIM_Dimension.Anchors``, updates the
|
||||
per-segment ``IfcMetric`` values (creating them when absent), and returns
|
||||
the resolved world-space points in metres.
|
||||
|
||||
:param file: The open IFC file.
|
||||
:param annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` pset.
|
||||
:param settings: Geometry settings for tessellation (shared across calls).
|
||||
:param shape_cache: Shape cache dict (shared across calls for performance).
|
||||
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
|
||||
matrix (metres, row-major). Pass ``{elem.id(): np.array(obj.matrix_world)}``
|
||||
for each referenced element so that viewport moves not yet synced to the
|
||||
IFC ``ObjectPlacement`` are reflected. See ``resolve_anchor`` for details.
|
||||
:return: Ordered list of ``(x, y, z)`` tuples, one per anchor.
|
||||
Empty list if the pset is missing or malformed.
|
||||
"""
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
|
||||
if not pset_data or "Anchors" not in pset_data:
|
||||
return []
|
||||
|
||||
try:
|
||||
anchors: list[dict] = json.loads(pset_data["Anchors"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
if not anchors:
|
||||
return []
|
||||
|
||||
if shape_cache is None:
|
||||
shape_cache = {}
|
||||
|
||||
resolved: list[Optional[tuple]] = []
|
||||
for anchor in anchors:
|
||||
pt = resolve_anchor(file, anchor, settings, shape_cache, placement_override)
|
||||
if pt is None:
|
||||
pt = tuple(anchor["pt"]) if anchor.get("pt") else (0.0, 0.0, 0.0)
|
||||
resolved.append(pt)
|
||||
anchor["pt"] = list(pt)
|
||||
|
||||
# ForcePerpendicularToFace: project vertices 1…n onto the line through
|
||||
# pt[0] in the direction of anchor[0]'s face normal, so the polyline is
|
||||
# constrained perpendicular to the face the first vertex is anchored to.
|
||||
if pset_data.get("ForcePerpendicularToFace") and len(resolved) >= 2 and resolved[0] is not None:
|
||||
normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
||||
if normal:
|
||||
base = resolved[0]
|
||||
for i in range(1, len(resolved)):
|
||||
if resolved[i] is None:
|
||||
continue
|
||||
pt = resolved[i]
|
||||
t = ((pt[0] - base[0]) * normal[0]
|
||||
+ (pt[1] - base[1]) * normal[1]
|
||||
+ (pt[2] - base[2]) * normal[2])
|
||||
resolved[i] = (base[0] + t * normal[0],
|
||||
base[1] + t * normal[1],
|
||||
base[2] + t * normal[2])
|
||||
anchors[i]["pt"] = list(resolved[i])
|
||||
|
||||
pset_entity_id = pset_data.get("id")
|
||||
if pset_entity_id:
|
||||
pset_entity = file.by_id(pset_entity_id)
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
file,
|
||||
pset=pset_entity,
|
||||
properties={"Anchors": json.dumps(anchors)},
|
||||
)
|
||||
|
||||
n_segments = len(resolved) - 1
|
||||
if n_segments >= 1:
|
||||
existing_metrics = _get_segment_metrics(file, annotation)
|
||||
_sync_segment_metrics(file, annotation, resolved, existing_metrics)
|
||||
|
||||
# LinePosition: project all points to a fixed absolute world coordinate along the
|
||||
# horizontal offset axis (perpendicular to the dimension direction). Applied after
|
||||
# the pset write so anchor["pt"] always stores the true geometry surface hit.
|
||||
# Because it is absolute, the dimension line stays put even if the geometry moves.
|
||||
line_position = pset_data.get("LinePosition")
|
||||
if line_position is not None and resolved:
|
||||
face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
||||
offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None], camera_dir)
|
||||
if offset_dir:
|
||||
resolved = [
|
||||
_project_to_line_position(pt, offset_dir, float(line_position)) if pt is not None else None
|
||||
for pt in resolved
|
||||
]
|
||||
|
||||
return [pt for pt in resolved if pt is not None]
|
||||
|
||||
|
||||
def get_dimension_segment_lengths(
|
||||
file: ifcopenshell.file,
|
||||
annotation: ifcopenshell.entity_instance,
|
||||
) -> list[float]:
|
||||
"""Return the segment lengths for a parametric dimension from stored anchor pts.
|
||||
|
||||
Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors``
|
||||
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
|
||||
is absent or malformed.
|
||||
"""
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
return []
|
||||
try:
|
||||
anchors: list[dict] = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
return []
|
||||
lengths: list[float] = []
|
||||
for i in range(len(anchors) - 1):
|
||||
pt_a = anchors[i].get("pt")
|
||||
pt_b = anchors[i + 1].get("pt")
|
||||
if pt_a and pt_b:
|
||||
lengths.append(_dist(tuple(pt_a), tuple(pt_b)))
|
||||
else:
|
||||
lengths.append(0.0)
|
||||
return lengths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IfcMetric / IfcRelAssociatesConstraint management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_segment_metrics(
|
||||
file: ifcopenshell.file,
|
||||
annotation: ifcopenshell.entity_instance,
|
||||
) -> dict[int, ifcopenshell.entity_instance]:
|
||||
"""Return {segment_index: IfcMetric} for all constraint rels on the annotation."""
|
||||
metrics: dict[int, ifcopenshell.entity_instance] = {}
|
||||
for rel in annotation.HasAssociations:
|
||||
if not rel.is_a("IfcRelAssociatesConstraint"):
|
||||
continue
|
||||
intent: str = rel.Intent or ""
|
||||
if not intent.startswith(_METRIC_INTENT_PREFIX):
|
||||
continue
|
||||
try:
|
||||
seg_idx = int(intent[len(_METRIC_INTENT_PREFIX):])
|
||||
except ValueError:
|
||||
continue
|
||||
constraint = rel.RelatingConstraint
|
||||
if constraint.is_a("IfcMetric"):
|
||||
metrics[seg_idx] = constraint
|
||||
return metrics
|
||||
|
||||
|
||||
def _sync_segment_metrics(
|
||||
file: ifcopenshell.file,
|
||||
annotation: ifcopenshell.entity_instance,
|
||||
resolved_pts: list[tuple],
|
||||
existing: dict[int, ifcopenshell.entity_instance],
|
||||
) -> None:
|
||||
"""Create missing and update existing IfcMetric entities for each segment."""
|
||||
n_segments = len(resolved_pts) - 1
|
||||
seen_guids: set[str] = set()
|
||||
|
||||
# Build a lookup of which elements are at each anchor endpoint
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
|
||||
anchors: list[dict] = []
|
||||
if pset_data and pset_data.get("Anchors"):
|
||||
try:
|
||||
anchors = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for seg_idx in range(n_segments):
|
||||
if seg_idx in existing:
|
||||
pass # metric already exists; association is still valid
|
||||
else:
|
||||
# Create new IfcMetric + IfcRelAssociatesConstraint
|
||||
# DataValue is IfcMetricValueSelect (entity-only SELECT in IFC4) — omit it;
|
||||
# the measured distance is derivable from the anchor pt fields.
|
||||
metric = file.create_entity(
|
||||
"IfcMetric",
|
||||
Name=f"seg_{seg_idx}",
|
||||
ConstraintGrade="ADVISORY",
|
||||
Benchmark="EQUALTO",
|
||||
)
|
||||
# Gather related products for this segment (the two anchor elements)
|
||||
related: list[ifcopenshell.entity_instance] = [annotation]
|
||||
for anchor_idx in (seg_idx, seg_idx + 1):
|
||||
if anchor_idx < len(anchors):
|
||||
guid = anchors[anchor_idx].get("guid")
|
||||
if guid and guid not in seen_guids:
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
related.append(elem)
|
||||
seen_guids.add(guid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
file.create_entity(
|
||||
"IfcRelAssociatesConstraint",
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
|
||||
Intent=f"{_METRIC_INTENT_PREFIX}{seg_idx}",
|
||||
RelatingConstraint=metric,
|
||||
RelatedObjects=related,
|
||||
)
|
||||
|
||||
# Remove orphaned metrics for segments that no longer exist
|
||||
for seg_idx, metric in existing.items():
|
||||
if seg_idx >= n_segments:
|
||||
for rel in file.get_inverse(metric):
|
||||
if rel.is_a("IfcRelAssociatesConstraint"):
|
||||
file.remove(rel)
|
||||
file.remove(metric)
|
||||
|
||||
|
||||
def _dist(a: tuple, b: tuple) -> float:
|
||||
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
|
||||
|
||||
|
||||
def _project_to_line_position(
|
||||
pt: tuple, offset_dir: tuple, target: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""Shift *pt* along *offset_dir* so its projection onto that axis equals *target*.
|
||||
|
||||
Keeps every other component of the point unchanged, so only the dimension line
|
||||
is repositioned — the measured length stays the same.
|
||||
"""
|
||||
current = pt[0] * offset_dir[0] + pt[1] * offset_dir[1] + pt[2] * offset_dir[2]
|
||||
delta = target - current
|
||||
return (
|
||||
pt[0] + delta * offset_dir[0],
|
||||
pt[1] + delta * offset_dir[1],
|
||||
pt[2] + delta * offset_dir[2],
|
||||
)
|
||||
|
||||
|
||||
def _get_anchor_face_normal_world(
|
||||
file: ifcopenshell.file,
|
||||
anchor: dict,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Return the world-space unit face normal stored in a FACE anchor, or None.
|
||||
|
||||
Reads ``normal_local`` (element-local, rotation-invariant) from the anchor
|
||||
addr and rotates it to world space via the current element placement.
|
||||
Also accepts the legacy ``addr.fingerprint.normal_local`` format.
|
||||
"""
|
||||
if anchor.get("type") != "FACE":
|
||||
return None
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
return None
|
||||
try:
|
||||
element = file.by_guid(guid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
addr = anchor.get("addr") or {}
|
||||
from .resolve_anchor import _rotate_local_to_world
|
||||
|
||||
if addr.get("method") == "LAYER_BOUNDARY":
|
||||
import ifcopenshell.util.element as _ifc_elem
|
||||
usage = _ifc_elem.get_material(element, should_inherit=True)
|
||||
if not usage or not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
return None
|
||||
axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2")
|
||||
if axis == "AXIS1":
|
||||
normal_local: tuple = (1.0, 0.0, 0.0)
|
||||
elif axis == "AXIS3":
|
||||
normal_local = (0.0, 0.0, 1.0)
|
||||
else:
|
||||
normal_local = (0.0, 1.0, 0.0)
|
||||
else:
|
||||
# FACE_NORMAL: normal_local stored in addr (new) or addr.fingerprint (legacy).
|
||||
normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local")
|
||||
if not normal_local:
|
||||
return None
|
||||
|
||||
n = _rotate_local_to_world(element, normal_local, placement_override)
|
||||
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
||||
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
|
||||
|
||||
|
||||
def _get_line_offset_direction(
|
||||
face_normal: Optional[tuple[float, float, float]],
|
||||
resolved_pts: list[tuple],
|
||||
camera_dir: Optional[tuple[float, float, float]] = None,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Return the direction to slide the dimension line (perpendicular to it, in-view).
|
||||
|
||||
For plan views (camera mostly vertical) uses cross(world_Z, dim_dir) —
|
||||
unchanged from the original behaviour, so existing stored LinePosition
|
||||
values continue to work.
|
||||
|
||||
For section/elevation views (camera mostly horizontal) uses
|
||||
cross(camera_dir, dim_dir) so the offset lies in the camera's view plane.
|
||||
This makes dragging the gizmo move the line visually up/down (or
|
||||
left/right) rather than in/out of the screen.
|
||||
|
||||
Falls back to cross(face_normal, world_Z) when the dimension line is
|
||||
nearly parallel to the reference vector (e.g. vertical elevation dims).
|
||||
"""
|
||||
world_z = (0.0, 0.0, 1.0)
|
||||
|
||||
# In section/elevation (camera mostly horizontal) use camera_dir as the
|
||||
# reference so the offset axis lies in the view plane.
|
||||
cam_is_plan = camera_dir is None or abs(camera_dir[2]) > 0.7
|
||||
ref = world_z if cam_is_plan else camera_dir
|
||||
|
||||
# Primary: cross(ref, dim_dir)
|
||||
if len(resolved_pts) >= 2:
|
||||
a, b = resolved_pts[0], resolved_pts[1]
|
||||
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
|
||||
dim_mag = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if dim_mag > 1e-10:
|
||||
dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag)
|
||||
d = (
|
||||
ref[1] * dim_dir[2] - ref[2] * dim_dir[1],
|
||||
ref[2] * dim_dir[0] - ref[0] * dim_dir[2],
|
||||
ref[0] * dim_dir[1] - ref[1] * dim_dir[0],
|
||||
)
|
||||
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
|
||||
if mag > 1e-6:
|
||||
return (d[0] / mag, d[1] / mag, d[2] / mag)
|
||||
|
||||
# Fallback for dims parallel to ref (e.g. vertical dims in plan):
|
||||
# cross(face_normal, world_Z)
|
||||
if face_normal:
|
||||
n = face_normal
|
||||
d = (
|
||||
n[1] * world_z[2] - n[2] * world_z[1],
|
||||
n[2] * world_z[0] - n[0] * world_z[2],
|
||||
n[0] * world_z[1] - n[1] * world_z[0],
|
||||
)
|
||||
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
|
||||
if mag > 1e-6:
|
||||
return (d[0] / mag, d[1] / mag, d[2] / mag)
|
||||
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,9 +39,9 @@ def test_add_segment_to_layout():
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "")
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert (
|
||||
len(referent_nest.RelatedObjects) == 1
|
||||
len(stationing_nest.RelatedObjects) == 1
|
||||
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
|
||||
|
||||
horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
@@ -75,8 +75,8 @@ def test_add_segment_to_layout():
|
||||
assert len(horizontal_alignment.IsNestedBy) == 1
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_alignment)
|
||||
assert len(segment_nest.RelatedObjects) == 2
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
|
||||
|
||||
|
||||
test_add_segment_to_layout()
|
||||
|
||||
@@ -39,8 +39,8 @@ def test_add_stationing_to_alignment():
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
referent = referent_nest.RelatedObjects[0]
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
referent = stationing_nest.RelatedObjects[0]
|
||||
|
||||
assert referent.PredefinedType == "STATION"
|
||||
assert referent.Name == "2+000.000"
|
||||
@@ -54,10 +54,10 @@ def test_add_stationing_to_alignment():
|
||||
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
|
||||
)
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 2
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 2
|
||||
|
||||
assert second_referent == referent_nest.RelatedObjects[1]
|
||||
assert second_referent == stationing_nest.RelatedObjects[1]
|
||||
|
||||
assert second_referent.PredefinedType == "STATION"
|
||||
assert second_referent.Name == "4+000.000"
|
||||
|
||||
@@ -36,11 +36,11 @@ def test_add_vertical_alignment():
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert len(layout_nest.RelatedObjects) == 1
|
||||
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert (
|
||||
len(referent_nest.RelatedObjects) == 1
|
||||
len(stationing_nest.RelatedObjects) == 1
|
||||
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
|
||||
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
|
||||
curve = ifcopenshell.api.alignment.get_curve(alignment)
|
||||
assert curve.is_a("IfcCompositeCurve")
|
||||
|
||||
@@ -51,8 +51,8 @@ def test_create_by_pi_method():
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert len(layout_nest.RelatedObjects) == 2
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 1
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 1
|
||||
|
||||
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
|
||||
|
||||
@@ -46,9 +46,9 @@ def test_horizontal_layout_by_pi_method():
|
||||
|
||||
assert len(alignment.IsDecomposedBy) == 0 # no child alignments
|
||||
assert len(alignment.IsNestedBy) == 2
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout_nest.RelatedObjects[0])
|
||||
assert len(segment_nest.RelatedObjects) == 3 # segments in horizontal layout
|
||||
|
||||
@@ -97,16 +97,32 @@ def callback_alignment():
|
||||
|
||||
|
||||
def test_with_default_names(default_names_alignment):
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, default_names_alignment)
|
||||
file = default_names_alignment.file
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
|
||||
vertical = ifcopenshell.api.alignment.get_vertical_layout(default_names_alignment)
|
||||
|
||||
expected = ["P.O.B", "P.C.", "P.T.", "P.O.E.", "V.P.O.B.", "P.V.C.", "P.V.T.", "V.P.O.E"]
|
||||
for r in referent_nest.RelatedObjects:
|
||||
assert [x in r.Name for x in expected]
|
||||
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
|
||||
|
||||
expected_h = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
|
||||
expected_v = ["V.P.O.B.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "V.P.O.E."]
|
||||
|
||||
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
|
||||
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
|
||||
|
||||
|
||||
def test_with_callbacks(callback_alignment):
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, callback_alignment)
|
||||
file = callback_alignment.file
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(callback_alignment)
|
||||
vertical = ifcopenshell.api.alignment.get_vertical_layout(callback_alignment)
|
||||
|
||||
expected = ["A", "Q", "Z", "a", "q", "z"]
|
||||
for r in referent_nest.RelatedObjects:
|
||||
assert [x in r.Name for x in expected]
|
||||
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
|
||||
|
||||
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
|
||||
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
|
||||
|
||||
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
|
||||
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
|
||||
|
||||
ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.alignment
|
||||
import ifcopenshell.util.element
|
||||
|
||||
COORDINATES = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
|
||||
RADII = [1000.0, 1250.0, 950.0]
|
||||
VPOINTS = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
|
||||
LENGTHS = [1600.0, 1200.0, 2000.0, 800.0]
|
||||
|
||||
|
||||
def _new_file():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
return file
|
||||
|
||||
|
||||
def _new_file_no_context():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
return file
|
||||
|
||||
|
||||
def _build_alignment(file, start_station=0.0):
|
||||
return ifcopenshell.api.alignment.create_by_pi_method(
|
||||
file, "TestAlignment", COORDINATES, RADII, VPOINTS, LENGTHS, start_station
|
||||
)
|
||||
|
||||
|
||||
def _pset_station(referent):
|
||||
return ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station")
|
||||
|
||||
|
||||
def test_wrong_layout_type_raises_type_error():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
with pytest.raises(TypeError):
|
||||
ifcopenshell.api.alignment.update_key_point_referents(file, alignment)
|
||||
|
||||
|
||||
def test_default_rel_nests_created_when_none_provided():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
assert nest.is_a("IfcRelNests")
|
||||
assert nest.RelatingObject == horizontal
|
||||
assert nest.id() != segment_nest.id()
|
||||
assert len(nest.RelatedObjects) == 8
|
||||
assert all(r.is_a("IfcReferent") for r in nest.RelatedObjects)
|
||||
|
||||
|
||||
def test_second_call_without_rel_nests_reuses_existing_nest():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment_count_before = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
|
||||
|
||||
nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
assert nest1.id() == nest2.id()
|
||||
assert len(nest2.RelatedObjects) == 16
|
||||
segment_count_after = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
|
||||
assert segment_count_after == segment_count_before
|
||||
|
||||
|
||||
def test_provided_rel_nests_is_used_as_is():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
# the nest may live anywhere the caller chooses, e.g. hung off the parent IfcAlignment
|
||||
rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=())
|
||||
|
||||
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests)
|
||||
|
||||
assert result.id() == rel_nests.id()
|
||||
assert result.RelatingObject == alignment
|
||||
assert len(result.RelatedObjects) == 8
|
||||
|
||||
|
||||
def test_clear_true_removes_old_referents_and_psets():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
old_referent_ids = [r.id() for r in nest.RelatedObjects]
|
||||
old_pset_ids = [r.IsDefinedBy[0].RelatingPropertyDefinition.id() for r in nest.RelatedObjects]
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=True)
|
||||
|
||||
assert len(nest.RelatedObjects) == 8
|
||||
for old_id in old_referent_ids + old_pset_ids:
|
||||
with pytest.raises(RuntimeError):
|
||||
file.by_id(old_id)
|
||||
|
||||
|
||||
def test_clear_false_appends_without_dedup():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=False)
|
||||
|
||||
assert len(nest.RelatedObjects) == 16
|
||||
counts = Counter(r.Name for r in nest.RelatedObjects)
|
||||
assert len(counts) == 8
|
||||
assert all(count == 2 for count in counts.values())
|
||||
|
||||
|
||||
def test_default_horizontal_labels_and_order():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
expected = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
|
||||
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
|
||||
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == sorted(stations)
|
||||
assert stations[0] == 0.0
|
||||
|
||||
|
||||
def test_default_vertical_labels_and_order():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
|
||||
|
||||
expected = [
|
||||
"V.P.O.B.",
|
||||
"P.V.C.",
|
||||
"P.V.T.",
|
||||
"P.V.C.",
|
||||
"P.V.T.",
|
||||
"P.V.C.",
|
||||
"P.V.T.",
|
||||
"P.V.C.",
|
||||
"P.V.T.",
|
||||
"V.P.O.E.",
|
||||
]
|
||||
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
|
||||
|
||||
segments = ifcopenshell.api.alignment.get_layout_segments(vertical)
|
||||
real_segments = segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(vertical) else segments
|
||||
# spot check the interior referents' stations against the segments' StartDistAlong directly
|
||||
for referent, segment in zip(nest.RelatedObjects[1:-1], real_segments[1:]):
|
||||
assert _pset_station(referent) == pytest.approx(segment.DesignParameters.StartDistAlong)
|
||||
|
||||
|
||||
def test_name_format():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
referent = nest.RelatedObjects[0]
|
||||
station = _pset_station(referent)
|
||||
assert referent.Name == f"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
|
||||
|
||||
|
||||
def test_geometric_placement_when_layout_has_representation():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(horizontal)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
for referent in nest.RelatedObjects:
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
location = referent.ObjectPlacement.RelativePlacement.Location
|
||||
assert location.is_a("IfcPointByDistanceExpression")
|
||||
assert location.BasisCurve == curve
|
||||
assert referent.ObjectPlacement.CartesianPosition is not None
|
||||
|
||||
first, last = nest.RelatedObjects[0], nest.RelatedObjects[-1]
|
||||
assert first.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(0.0)
|
||||
assert last.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(
|
||||
_pset_station(last)
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_placement_when_layout_has_no_geometry():
|
||||
file = _new_file_no_context()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal, COORDINATES, RADII)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
expected_coordinates = alignment.ObjectPlacement.RelativePlacement.Location.Coordinates
|
||||
for referent in nest.RelatedObjects:
|
||||
assert referent.ObjectPlacement.is_a("IfcLocalPlacement")
|
||||
assert referent.ObjectPlacement.RelativePlacement.Location.Coordinates == expected_coordinates
|
||||
|
||||
|
||||
def test_cant_layout_boundary_labels():
|
||||
file = _new_file_no_context()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_cant=True, include_geometry=False)
|
||||
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
|
||||
|
||||
dp1 = file.createIfcAlignmentCantSegment(
|
||||
StartDistAlong=0.0,
|
||||
HorizontalLength=100.0,
|
||||
StartCantLeft=0.0,
|
||||
EndCantLeft=0.0,
|
||||
StartCantRight=0.0,
|
||||
EndCantRight=0.0,
|
||||
PredefinedType="CONSTANTCANT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp1)
|
||||
|
||||
dp2 = file.createIfcAlignmentCantSegment(
|
||||
StartDistAlong=100.0,
|
||||
HorizontalLength=50.0,
|
||||
StartCantLeft=0.0,
|
||||
EndCantLeft=0.0,
|
||||
StartCantRight=0.0,
|
||||
EndCantRight=0.0,
|
||||
PredefinedType="CONSTANTCANT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp2)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
|
||||
|
||||
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
|
||||
assert labels[0] == "C.P.O.B."
|
||||
assert labels[-1] == "C.P.O.E."
|
||||
# CONSTANTCANT -> CONSTANTCANT is currently an unfilled "xx" placeholder in the cant lookup
|
||||
# table (_get_segment_start_point_label.py) -- out of scope to fill in here.
|
||||
assert labels[1] == "xx"
|
||||
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == [0.0, 100.0, 150.0]
|
||||
|
||||
|
||||
def test_no_real_segments_produces_no_referents():
|
||||
file = _new_file_no_context()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
assert nest.RelatedObjects == ()
|
||||
|
||||
|
||||
def test_single_real_segment_produces_only_boundary_labels():
|
||||
file = _new_file_no_context()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
design_parameters = file.createIfcAlignmentHorizontalSegment(
|
||||
StartTag=None,
|
||||
EndTag=None,
|
||||
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
|
||||
StartDirection=0.0,
|
||||
StartRadiusOfCurvature=0.0,
|
||||
EndRadiusOfCurvature=0.0,
|
||||
SegmentLength=100.0,
|
||||
GravityCenterLineHeight=None,
|
||||
PredefinedType="LINE",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
|
||||
assert labels == ["P.O.B.", "P.O.E."]
|
||||
|
||||
|
||||
def test_start_station_composes_for_child_alignment():
|
||||
file = _new_file()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0)
|
||||
ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
|
||||
ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split
|
||||
|
||||
child_alignment = alignment.IsDecomposedBy[0].RelatedObjects[-1]
|
||||
child_vertical = ifcopenshell.api.alignment.get_vertical_layout(child_alignment)
|
||||
|
||||
dp1 = file.createIfcAlignmentVerticalSegment(
|
||||
StartDistAlong=0.0,
|
||||
HorizontalLength=500.0,
|
||||
StartHeight=10.0,
|
||||
StartGradient=0.01,
|
||||
EndGradient=0.01,
|
||||
PredefinedType="CONSTANTGRADIENT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp1)
|
||||
|
||||
dp2 = file.createIfcAlignmentVerticalSegment(
|
||||
StartDistAlong=500.0,
|
||||
HorizontalLength=300.0,
|
||||
StartHeight=15.0,
|
||||
StartGradient=0.01,
|
||||
EndGradient=0.01,
|
||||
PredefinedType="CONSTANTGRADIENT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp2)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, child_vertical)
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == pytest.approx([100.0, 600.0, 900.0])
|
||||
|
||||
|
||||
def test_returns_ifc_rel_nests():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
assert result.is_a("IfcRelNests")
|
||||
|
||||
|
||||
test_wrong_layout_type_raises_type_error()
|
||||
test_default_rel_nests_created_when_none_provided()
|
||||
test_second_call_without_rel_nests_reuses_existing_nest()
|
||||
test_provided_rel_nests_is_used_as_is()
|
||||
test_clear_true_removes_old_referents_and_psets()
|
||||
test_clear_false_appends_without_dedup()
|
||||
test_default_horizontal_labels_and_order()
|
||||
test_default_vertical_labels_and_order()
|
||||
test_name_format()
|
||||
test_geometric_placement_when_layout_has_representation()
|
||||
test_fallback_placement_when_layout_has_no_geometry()
|
||||
test_cant_layout_boundary_labels()
|
||||
test_no_real_segments_produces_no_referents()
|
||||
test_single_real_segment_produces_only_boundary_labels()
|
||||
test_start_station_composes_for_child_alignment()
|
||||
test_returns_ifc_rel_nests()
|
||||
@@ -64,8 +64,8 @@ def test_vertical_layout_by_pi_method():
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert len(layout_nest.RelatedObjects) == 2
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 1
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 1
|
||||
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(vlayout)
|
||||
assert len(segment_nest.RelatedObjects) == 3
|
||||
|
||||
@@ -102,13 +102,26 @@ def clash(
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes against other elements.
|
||||
"""Check one element for geometric clashes against other elements.
|
||||
|
||||
Reports hard intersections and, optionally, violations of a required
|
||||
clearance. Returns the overall ``pass``, the ``scope`` actually used, a
|
||||
``checks`` block in which each clash names the other ``element``, the
|
||||
clash ``type``, the ``distance`` and the two closest points ``p1``/``p2``,
|
||||
and a de-duplicated flat ``elements`` list of everything involved.
|
||||
Geometry is computed for every element in scope, so this is slow on large
|
||||
models; ``pass`` is ``None`` with an ``error`` when the element has no
|
||||
usable geometry.
|
||||
|
||||
:param model: The IFC model.
|
||||
:param element: The element to check.
|
||||
:param clearance: Minimum clearance distance; if provided, runs clearance check.
|
||||
:param clearance: Minimum required clearance distance; when given, also
|
||||
runs the clearance check alongside the intersection check.
|
||||
:param tolerance: Intersection tolerance in meters (default 0.002).
|
||||
:param scope: Which elements to check against: "storey" or "all".
|
||||
:param scope: ``"storey"`` (default) checks only elements sharing the
|
||||
same spatial container; ``"all"`` checks every ``IfcElement``.
|
||||
``"storey"`` falls back to ``"all"`` when the element has no spatial
|
||||
container.
|
||||
:return: Dict with clash results suitable for JSON serialization.
|
||||
"""
|
||||
result: dict[str, Any] = {"element": _ref(element)}
|
||||
|
||||
@@ -26,10 +26,19 @@ def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None
|
||||
|
||||
|
||||
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of IfcCostSchedule entries with nested cost item trees.
|
||||
"""List the cost schedules: bills of quantities and their cost items.
|
||||
|
||||
max_depth limits how many levels of subitems are expanded (None = unlimited).
|
||||
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
|
||||
Covers ``IfcCostSchedule`` only — this is the money dimension of the
|
||||
model; see ``schedule()`` in this module for the construction programme.
|
||||
Each cost item reports its cost ``values`` as ``formula`` label and
|
||||
``category`` pairs, together with its nested ``subitems``. Returns an
|
||||
empty list when the model has no cost schedules.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param max_depth: Levels of cost item nesting to expand, counting root
|
||||
items as level 1. Past the cutoff ``subitems`` is replaced by a
|
||||
``{"truncated": True, "count": N}`` marker giving the number of items
|
||||
not expanded. ``None`` (default) expands to unlimited depth.
|
||||
"""
|
||||
result = []
|
||||
for cost_schedule in model.by_type("IfcCostSchedule"):
|
||||
|
||||
@@ -188,7 +188,16 @@ def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str
|
||||
|
||||
|
||||
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Return deep inspection data for an element."""
|
||||
"""Inspect a single entity in depth.
|
||||
|
||||
Returns the entity's direct ``attributes`` plus, where present,
|
||||
``property_sets``, ``element_type``, ``material``, ``container``,
|
||||
``placement`` and ``geometry_summary``. Keys are omitted when the
|
||||
information is unavailable.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param element: The entity to inspect.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"id": element.id(),
|
||||
"type": element.is_a(),
|
||||
|
||||
@@ -22,7 +22,13 @@ import ifcopenshell
|
||||
|
||||
|
||||
def materials(model: ifcopenshell.file) -> list[dict]:
|
||||
"""Return all materials and material sets from the model.
|
||||
"""List the materials and material sets defined in the model.
|
||||
|
||||
Returns a single list covering ``IfcMaterial`` (with its category),
|
||||
``IfcMaterialLayerSet`` (each layer's name, thickness, material and
|
||||
ventilation flag), ``IfcMaterialConstituentSet`` (constituent names,
|
||||
materials and fractions) and ``IfcMaterialProfileSet`` (profile names and
|
||||
materials). Every entry carries the step ID of the material entity.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
|
||||
|
||||
@@ -182,7 +182,22 @@ def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -
|
||||
def relations(
|
||||
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
|
||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
|
||||
"""Show how an element relates to the rest of the model.
|
||||
|
||||
By default returns a dict whose optional blocks are ``hierarchy`` (parent,
|
||||
container, aggregate, nest, filled void, voided element), ``children``
|
||||
(contained, parts, components, openings), ``type_relationship``,
|
||||
``groups``, ``systems``, ``zones``, ``material``, ``referenced_structures``
|
||||
and ``connections`` (connected to/from, ports), plus a de-duplicated flat
|
||||
``elements`` list of everything referenced. Blocks with nothing to report
|
||||
are omitted.
|
||||
|
||||
:param model: The IFC model.
|
||||
:param element: The element to examine.
|
||||
:param traverse: Set to ``'up'`` to instead return the chain of ancestors
|
||||
from the element to ``IfcProject`` as a flat list. Any other value
|
||||
gives the default behaviour.
|
||||
"""
|
||||
if traverse == "up":
|
||||
return _traverse_up(element)
|
||||
result = _all_relations(model, element)
|
||||
|
||||
@@ -37,10 +37,20 @@ def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, dep
|
||||
|
||||
|
||||
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of IfcWorkSchedule entries with nested task trees.
|
||||
"""List the construction programme: work schedules and their task trees.
|
||||
|
||||
max_depth limits how many levels of subtasks are expanded (None = unlimited).
|
||||
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
|
||||
Covers ``IfcWorkSchedule`` only — this is the time dimension of the
|
||||
model; see ``cost()`` in this module for the money dimension. Each
|
||||
schedule lists its tasks recursively, and each task carries its scheduled
|
||||
``start`` and ``finish``, an ``is_milestone`` flag, the products it
|
||||
``outputs`` and its ``subtasks``. Returns an empty list when the model has
|
||||
no work schedules.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param max_depth: Levels of subtask nesting to expand, counting root tasks
|
||||
as level 1. Past the cutoff ``subtasks`` is replaced by a
|
||||
``{"truncated": True, "count": N}`` marker giving the number of tasks
|
||||
not expanded. ``None`` (default) expands to unlimited depth.
|
||||
"""
|
||||
result = []
|
||||
for work_schedule in model.by_type("IfcWorkSchedule"):
|
||||
|
||||
@@ -8,7 +8,15 @@ import ifcopenshell.util.doc
|
||||
|
||||
|
||||
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
|
||||
"""Return IFC class documentation for entity_type from model's schema version."""
|
||||
"""Look up the IFC documentation for an entity class.
|
||||
|
||||
Returns the class ``description``, its ``predefined_types``, per-attribute
|
||||
documentation and a ``spec_url``, resolved against the model's schema
|
||||
version. Returns an ``error`` key for an unknown class.
|
||||
|
||||
:param model: The in-memory IFC model, used only for its schema version.
|
||||
:param entity_type: IFC class name, for example ``'IfcWall'``.
|
||||
"""
|
||||
schema_name = model.schema
|
||||
try:
|
||||
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
|
||||
|
||||
@@ -26,7 +26,15 @@ import ifcopenshell
|
||||
|
||||
|
||||
def summary(model: ifcopenshell.file) -> dict[str, Any]:
|
||||
"""Return a model overview with schema, element counts, and project info."""
|
||||
"""Summarise the model: schema, entity counts and project info.
|
||||
|
||||
Returns the ``schema`` version, ``total_entities``, and a ``project``
|
||||
block with the id, name and description of the first ``IfcProject``
|
||||
(omitted if the model has none). The count covers every entity in the
|
||||
file, not just physical elements.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
"""
|
||||
# Count elements by IFC type, sorted by count descending
|
||||
type_counter: Counter[str] = Counter()
|
||||
total = 0
|
||||
|
||||
@@ -59,7 +59,17 @@ def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]
|
||||
|
||||
|
||||
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Return the spatial hierarchy tree starting from IfcProject."""
|
||||
"""Return the spatial hierarchy of the model as a nested tree.
|
||||
|
||||
Starts at ``IfcProject`` and descends through decomposition (site,
|
||||
building, storeys) and containment (the elements placed in each storey).
|
||||
Every node carries ``id``, ``type`` and ``name``; ``children`` holds
|
||||
decomposed sub-spaces and ``elements`` holds contained elements, and
|
||||
either key is omitted when empty. Returns a list when the file contains
|
||||
several projects, or an ``error`` key when it contains none.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
"""
|
||||
projects = model.by_type("IfcProject")
|
||||
if not projects:
|
||||
return {"error": "No IfcProject found in model"}
|
||||
|
||||
@@ -8,7 +8,16 @@ import ifcopenshell.validate
|
||||
|
||||
|
||||
def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
|
||||
"""Validate the model and return a dict with 'valid' bool and 'issues' list."""
|
||||
"""Validate the model against the IFC schema.
|
||||
|
||||
Returns ``valid`` together with a list of ``issues``, each carrying a
|
||||
``level`` and a ``message``. Worth running after a batch of edits and
|
||||
before writing the model back to disk.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param express_rules: Also evaluate the schema's EXPRESS rules. Catches
|
||||
more problems but is considerably slower (default ``False``).
|
||||
"""
|
||||
logger = ifcopenshell.validate.json_logger()
|
||||
ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
|
||||
issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]
|
||||
|
||||
Reference in New Issue
Block a user