mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-25 06:16:48 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 253a6c56a1 | |||
| 99f95bee6a | |||
| 6cae194610 | |||
| 8c3bc3b1ca | |||
| cb775b71fc | |||
| 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 |
@@ -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;
|
||||
|
||||
@@ -236,8 +236,11 @@ def import_attribute(
|
||||
elif data_type == "integer":
|
||||
new.int_value = 0 if new.is_null else int(data[attribute.name()])
|
||||
elif data_type == "float":
|
||||
measure_class = attribute.type_of_attribute().declared_type().name()
|
||||
new.special_type = tool.Pset.get_special_type_for_measure_class(measure_class)
|
||||
attribute_type = attribute.type_of_attribute()
|
||||
if attribute_type._is("IfcLengthMeasure"):
|
||||
new.special_type = "LENGTH"
|
||||
elif attribute_type._is("IfcForceMeasure"):
|
||||
new.special_type = "FORCE"
|
||||
new.float_value = 0.0 if new.is_null else float(data[attribute.name()])
|
||||
elif data_type == "enum":
|
||||
attribute_type = attribute.type_of_attribute()
|
||||
|
||||
@@ -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])
|
||||
@@ -737,6 +740,10 @@ class DimensionDecorator(BaseDecorator):
|
||||
text_dir = p1 - p0
|
||||
if text_dir.length < 1:
|
||||
continue
|
||||
# Normalize so text always reads left-to-right (or bottom-to-top for
|
||||
# vertical dims) regardless of which end was drawn first.
|
||||
if text_dir.x < 0 or (abs(text_dir.x) < 1e-6 and text_dir.y < 0):
|
||||
text_dir = -text_dir
|
||||
perpendicular = Vector((-text_dir.y, text_dir.x)).normalized()
|
||||
text_offset = perpendicular * text_offset_value
|
||||
|
||||
@@ -745,16 +752,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 + p1) / 2
|
||||
|
||||
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 +781,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 +988,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 +1526,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 +1548,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 +2164,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,14 @@ 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)
|
||||
if group:
|
||||
for annotation in tool.Drawing.get_group_elements(group) or []:
|
||||
if annotation.is_a("IfcAnnotation") and ifcopenshell.util.element.get_predefined_type(annotation) == "SECTION":
|
||||
ann_obj = tool.Ifc.get_object(annotation)
|
||||
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 +1046,276 @@ def update_sheet_data(self, context):
|
||||
SheetsData.is_loaded = False
|
||||
|
||||
|
||||
# Guard against re-entrant calls when one constraint callback clears the other property.
|
||||
_face_constraint_updating = False
|
||||
|
||||
|
||||
def _update_force_perpendicular(self, context):
|
||||
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
|
||||
global _face_constraint_updating
|
||||
if _face_constraint_updating:
|
||||
return
|
||||
_face_constraint_updating = True
|
||||
try:
|
||||
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
|
||||
if new_value and self.force_parallel_to_face:
|
||||
self.force_parallel_to_face = False
|
||||
_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"])
|
||||
pset_props = {"ForcePerpendicularToFace": new_value}
|
||||
if new_value:
|
||||
pset_props["ForceParallelToFace"] = False
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties=pset_props)
|
||||
|
||||
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)
|
||||
finally:
|
||||
_face_constraint_updating = False
|
||||
|
||||
|
||||
def _update_force_parallel(self, context):
|
||||
"""Apply ForceParallelToFace to all selected dimension annotations and regenerate them."""
|
||||
global _face_constraint_updating
|
||||
if _face_constraint_updating:
|
||||
return
|
||||
_face_constraint_updating = True
|
||||
try:
|
||||
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
|
||||
from mathutils import Vector
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
new_value = self.force_parallel_to_face
|
||||
if new_value and self.force_perpendicular_to_face:
|
||||
self.force_perpendicular_to_face = False
|
||||
_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
|
||||
|
||||
cam = context.scene.camera
|
||||
cam_dir_tuple = None
|
||||
if cam:
|
||||
cd = cam.matrix_world.to_3x3() @ Vector((0, 0, -1))
|
||||
cd.normalize()
|
||||
cam_dir_tuple = (cd.x, cd.y, cd.z)
|
||||
|
||||
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"])
|
||||
pset_props = {"ForceParallelToFace": new_value}
|
||||
if new_value:
|
||||
pset_props["ForcePerpendicularToFace"] = False
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties=pset_props)
|
||||
|
||||
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)
|
||||
finally:
|
||||
_face_constraint_updating = False
|
||||
|
||||
|
||||
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 +1329,25 @@ 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,
|
||||
)
|
||||
force_parallel_to_face: bpy.props.BoolProperty(
|
||||
name="Force ∥ to Face",
|
||||
description="Constrain dimension vertices to run parallel to the face of the first anchor (along the face, perpendicular to its normal). When dimensions are selected, toggling this updates them all.",
|
||||
default=False,
|
||||
update=_update_force_parallel,
|
||||
)
|
||||
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)
|
||||
@@ -1566,12 +1588,24 @@ class SvgWriter:
|
||||
end = (offset + v1.xy * Vector((1, -1))) * self.svg_scale
|
||||
mid = ((end - start) / 2) + start
|
||||
vector = end - start
|
||||
sheet_dimension = vector.length
|
||||
if sheet_dimension < 1e-6:
|
||||
return
|
||||
perpendicular = Vector((vector.y, -vector.x)).normalized()
|
||||
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))))
|
||||
# Keep text readable regardless of draw direction: if the dimension runs
|
||||
# right-to-left the raw angle is near ±180° which renders text upside-down.
|
||||
# Flip both angle and perpendicular so text always reads left-to-right and
|
||||
# stays on the same side of the dimension line.
|
||||
if abs(angle) > 90:
|
||||
angle += 180
|
||||
perpendicular = -perpendicular
|
||||
|
||||
line = self.svg.line(start=start, end=end, class_=" ".join(classes))
|
||||
self.svg.add(line)
|
||||
@@ -1585,15 +1619,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 +1640,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 +1649,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,13 @@ 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")
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(cls.props, "force_parallel_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 +398,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)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class Data:
|
||||
ifc_file = tool.Ifc.get()
|
||||
results = []
|
||||
psetqtos = ifcopenshell.util.element.get_psets(
|
||||
element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False, verbose=True
|
||||
element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False
|
||||
)
|
||||
for name, data in sorted(psetqtos.items()):
|
||||
pset = ifc_file.by_id(data["id"])
|
||||
@@ -69,28 +69,13 @@ class Data:
|
||||
"id": data["id"],
|
||||
"Name": name,
|
||||
"is_expanded": is_expanded.get(data["id"], True),
|
||||
"Properties": [
|
||||
cls.property_display_data(ifc_file, k, v) for k, v in sorted(data.items()) if k != "id"
|
||||
],
|
||||
"Properties": [{"Name": k, "NominalValue": v} for k, v in sorted(data.items()) if k != "id"],
|
||||
"shared_pset_uses": len(pset_uses),
|
||||
"has_template": has_template,
|
||||
}
|
||||
)
|
||||
return sorted(results, key=lambda v: v["Name"])
|
||||
|
||||
@classmethod
|
||||
def property_display_data(cls, ifc_file: ifcopenshell.file, name: str, verbose_value: Any) -> dict[str, Any]:
|
||||
# Predefined property sets (e.g. IfcDoorPanelProperties) expose plain
|
||||
# attribute values even in verbose mode, since they're typed IFC
|
||||
# attributes rather than IfcProperty entities with their own id/Unit.
|
||||
if not isinstance(verbose_value, dict):
|
||||
return {"Name": name, "NominalValue": verbose_value, "UnitSymbol": ""}
|
||||
|
||||
unit_symbol = ""
|
||||
if (prop_id := verbose_value.get("id")) and (prop_entity := ifc_file.by_id(prop_id)):
|
||||
unit_symbol = tool.Pset.get_unit_symbol_for_prop(prop_entity, ifc_file)
|
||||
return {"Name": name, "NominalValue": verbose_value["value"], "UnitSymbol": unit_symbol}
|
||||
|
||||
@classmethod
|
||||
def format_pset_enum(cls, psets):
|
||||
enum_items = []
|
||||
|
||||
@@ -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"
|
||||
@@ -120,20 +158,13 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
properties = json.loads(self.properties)
|
||||
else:
|
||||
for prop in props.properties:
|
||||
metadata = prop.metadata
|
||||
if prop.value_type == "IfcPropertySingleValue":
|
||||
value = metadata.get_value()
|
||||
properties[prop.metadata.name] = prop.metadata.get_value()
|
||||
elif prop.value_type == "IfcPropertyEnumeratedValue":
|
||||
value_name = metadata.get_value_name()
|
||||
value = [e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected]
|
||||
else:
|
||||
continue
|
||||
# None (a purge/skip-creation signal, handled by edit_pset/edit_qto before any
|
||||
# unit wrapping is unpacked) must stay bare -- only wrap real values.
|
||||
if value is not None and tool.Pset.is_measurable_special_type(metadata.special_type):
|
||||
unit = self.file.by_id(metadata.unit_id) if metadata.unit_id else None
|
||||
value = {"NominalValue": value, "Unit": unit}
|
||||
properties[metadata.name] = value
|
||||
value_name = prop.metadata.get_value_name()
|
||||
properties[prop.metadata.name] = [
|
||||
e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected
|
||||
]
|
||||
|
||||
if pset.is_a() in ("IfcPropertySet", "IfcMaterialProperties", "IfcProfileProperties"):
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
@@ -147,18 +178,10 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
for key, value in properties.items():
|
||||
if value is None:
|
||||
continue
|
||||
is_wrapped = isinstance(value, dict) and "Unit" in value
|
||||
raw = value["NominalValue"] if is_wrapped else value
|
||||
if raw is None:
|
||||
continue
|
||||
if isinstance(raw, float):
|
||||
raw = round(raw, 4)
|
||||
elif not isinstance(raw, int):
|
||||
raw = 0
|
||||
if is_wrapped:
|
||||
value["NominalValue"] = raw
|
||||
else:
|
||||
properties[key] = raw
|
||||
if isinstance(value, float):
|
||||
properties[key] = round(value, 4)
|
||||
elif not isinstance(value, int):
|
||||
properties[key] = 0
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file,
|
||||
qto=pset,
|
||||
@@ -167,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()
|
||||
|
||||
|
||||
|
||||
@@ -67,10 +67,6 @@ def draw_single_property(prop: IfcProperty, layout: bpy.types.UILayout, copy_ope
|
||||
if prop.metadata.special_type == "URI":
|
||||
op = layout.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
|
||||
op.attribute_data_path = tool.Blender.get_full_data_path(prop.metadata)
|
||||
if tool.Pset.is_measurable_special_type(prop.metadata.special_type):
|
||||
unit_row = layout.row(align=True)
|
||||
unit_row.scale_x = 0.5
|
||||
prop_with_search(unit_row, prop.metadata, "unit_id_enum", text="")
|
||||
if prop.metadata.is_optional:
|
||||
layout.prop(prop.metadata, "is_null", icon="RADIOBUT_OFF" if prop.metadata.is_null else "RADIOBUT_ON", text="")
|
||||
if copy_operator:
|
||||
@@ -207,10 +203,9 @@ def draw_psetqto_ui(
|
||||
row = box.row(align=True)
|
||||
row.scale_y = 0.8
|
||||
row.label(text=prop["Name"])
|
||||
display_value = get_display_value(nominal_value)
|
||||
if unit_symbol := prop["UnitSymbol"]:
|
||||
display_value = f"{display_value} {unit_symbol}"
|
||||
op = row.operator("bim.select_similar", text=display_value, icon="NONE", emboss=False)
|
||||
op = row.operator(
|
||||
"bim.select_similar", text=get_display_value(nominal_value), icon="NONE", emboss=False
|
||||
)
|
||||
op.key = '"' + pset["Name"].replace('"', '\\"') + '"."' + prop["Name"].replace('"', '\\"') + '"'
|
||||
# calculate sum of all selected objects
|
||||
if active_operator:
|
||||
|
||||
@@ -151,7 +151,7 @@ class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator):
|
||||
ifc_file = tool.Ifc.get()
|
||||
with Profiler("Quantify function time:"):
|
||||
results = ifc5d.qto.quantify(ifc_file, elements, rules)
|
||||
ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
|
||||
ifc5d.qto.edit_qtos(ifc_file, results)
|
||||
|
||||
not_quantified_elements = elements - set(results.keys())
|
||||
not_quantified_message = tool.Qto.get_not_quantified_elements_message(not_quantified_elements)
|
||||
@@ -194,7 +194,7 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator):
|
||||
ifc_file = tool.Ifc.get()
|
||||
with Profiler("Quantify function time:"):
|
||||
results = ifc5d.qto.quantify(ifc_file, elements, rules)
|
||||
ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
|
||||
ifc5d.qto.edit_qtos(ifc_file, results)
|
||||
not_quantified_elements = elements - set(results.keys())
|
||||
return not_quantified_elements
|
||||
|
||||
|
||||
@@ -27,28 +27,10 @@ from bpy.props import (
|
||||
)
|
||||
from bpy.types import PropertyGroup
|
||||
|
||||
import bonsai.bim.prop
|
||||
import bonsai.tool as tool
|
||||
|
||||
CALCULATOR_FUNCTION_ENUM_ITEMS: list[Union[tuple[str, str, str], None]] = []
|
||||
|
||||
# Measure class (matching ifc5d.qto's Function.measure / SI2ProjectUnitConverter.project_units'
|
||||
# keys) -> (BIMQtoProperties field name, tool.Pset special_type, UI label).
|
||||
MEASURE_TO_TARGET_UNIT_FIELD: dict[str, tuple[str, str, str]] = {
|
||||
"IfcLengthMeasure": ("target_unit_length", "LENGTH", "Length"),
|
||||
"IfcAreaMeasure": ("target_unit_area", "AREA", "Area"),
|
||||
"IfcVolumeMeasure": ("target_unit_volume", "VOLUME", "Volume"),
|
||||
"IfcMassMeasure": ("target_unit_mass", "MASS", "Mass"),
|
||||
"IfcTimeMeasure": ("target_unit_time", "TIME", "Time"),
|
||||
}
|
||||
|
||||
|
||||
def _target_unit_items(special_type: str):
|
||||
def getter(self: "BIMQtoProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
return bonsai.bim.prop.get_unit_enum_items_for_special_type(special_type, tool.Ifc.get())
|
||||
|
||||
return getter
|
||||
|
||||
|
||||
def get_qto_rule(self: "BIMQtoProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
|
||||
results: list[tuple[str, str, str]] = []
|
||||
@@ -110,11 +92,6 @@ class BIMQtoProperties(PropertyGroup):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
target_unit_length: EnumProperty(items=_target_unit_items("LENGTH"), name="Length Unit")
|
||||
target_unit_area: EnumProperty(items=_target_unit_items("AREA"), name="Area Unit")
|
||||
target_unit_volume: EnumProperty(items=_target_unit_items("VOLUME"), name="Volume Unit")
|
||||
target_unit_mass: EnumProperty(items=_target_unit_items("MASS"), name="Mass Unit")
|
||||
target_unit_time: EnumProperty(items=_target_unit_items("TIME"), name="Time Unit")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
qto_rule: str
|
||||
@@ -124,8 +101,3 @@ class BIMQtoProperties(PropertyGroup):
|
||||
qto_name: str
|
||||
prop_name: str
|
||||
fallback: bool
|
||||
target_unit_length: str
|
||||
target_unit_area: str
|
||||
target_unit_volume: str
|
||||
target_unit_mass: str
|
||||
target_unit_time: str
|
||||
|
||||
@@ -17,12 +17,9 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
import ifc5d.qto
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
from bonsai.bim.module.qto.data import QtoData
|
||||
from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
|
||||
|
||||
|
||||
class BIM_PT_qto(bpy.types.Panel):
|
||||
@@ -47,14 +44,6 @@ class BIM_PT_qto(bpy.types.Panel):
|
||||
row = layout.row()
|
||||
row.prop(props, "qto_rule", text="")
|
||||
row.prop(props, "fallback", text="", icon="RADIOBUT_ON" if props.fallback else "RADIOBUT_OFF")
|
||||
|
||||
box = layout.box()
|
||||
box.label(text="Target Units (optional, otherwise project default)")
|
||||
for field_name, _special_type, label in MEASURE_TO_TARGET_UNIT_FIELD.values():
|
||||
row = box.row(align=True)
|
||||
row.label(text=label)
|
||||
prop_with_search(row, props, field_name, text="")
|
||||
|
||||
row = layout.row()
|
||||
row.operator("bim.perform_quantity_take_off")
|
||||
|
||||
@@ -77,15 +66,6 @@ class BIM_PT_qto_manual(bpy.types.Panel):
|
||||
row = layout.row()
|
||||
row.prop(props, "calculator_function", text="Function")
|
||||
|
||||
calculator = ifc5d.qto.calculators.get(props.calculator)
|
||||
function = calculator.functions.get(props.calculator_function) if calculator else None
|
||||
target_unit_field = MEASURE_TO_TARGET_UNIT_FIELD.get(function.measure) if function else None
|
||||
if target_unit_field:
|
||||
field_name, _special_type, label = target_unit_field
|
||||
row = layout.row(align=True)
|
||||
row.label(text=f"{label} Unit")
|
||||
prop_with_search(row, props, field_name, text="")
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.prop(props, "qto_name", text="")
|
||||
row.prop(props, "prop_name", text="")
|
||||
|
||||
@@ -21,6 +21,7 @@ import os
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never, get_args
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
from bpy.props import (
|
||||
BoolProperty,
|
||||
CollectionProperty,
|
||||
@@ -33,8 +34,6 @@ from bpy.props import (
|
||||
)
|
||||
from bpy.types import PropertyGroup
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
import bonsai.bim
|
||||
import bonsai.bim.handler
|
||||
import bonsai.tool as tool
|
||||
@@ -123,57 +122,6 @@ def get_attribute_enum_values(prop: "Attribute", context: bpy.types.Context) ->
|
||||
return items
|
||||
|
||||
|
||||
def get_unit_enum_items_for_special_type(
|
||||
special_type: str, ifc_file: Union[ifcopenshell.file, None]
|
||||
) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
"""Items for a unit-override picker: "Default (<symbol>)" plus every candidate unit
|
||||
matching `special_type`, filtered per-caller since candidates depend on the measure type
|
||||
in question (unlike the globally-shared lists in `bonsai.bim.ui.EnumData`).
|
||||
"""
|
||||
if not ifc_file or not tool.Pset.is_measurable_special_type(special_type):
|
||||
return [(cache_string("0"), cache_string("Default"), "")]
|
||||
|
||||
default_symbol = tool.Pset.get_unit_symbol_for_special_type(special_type, ifc_file)
|
||||
items: list[tuple[str, str, str]] = [
|
||||
(cache_string("0"), cache_string(f"Default ({default_symbol})" if default_symbol else "Default"), "")
|
||||
]
|
||||
for unit in tool.Pset.get_candidate_units_for_special_type(special_type, ifc_file):
|
||||
name = getattr(unit, "Name", None) or unit.is_a()
|
||||
symbol = ifcopenshell.util.unit.get_unit_symbol(unit)
|
||||
label = f"{name} ({symbol})" if symbol else name
|
||||
items.append((cache_string(str(unit.id())), cache_string(label), ""))
|
||||
return items
|
||||
|
||||
|
||||
def get_attribute_unit_enum_items(prop: "Attribute", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
"""Items for `Attribute.unit_id_enum`. Wraps `get_unit_enum_items_for_special_type` with a
|
||||
defensive addition: real-world files sometimes carry a Unit that doesn't cleanly match our
|
||||
candidate-matching logic (e.g. a mismatched UnitType). Always keep the attribute's own
|
||||
current override selectable/representable, however unusual, so setting unit_id_enum to
|
||||
match an already-seeded unit_id can never raise "enum not found".
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
items = get_unit_enum_items_for_special_type(prop.special_type, ifc_file)
|
||||
|
||||
if prop.unit_id and prop.unit_id not in {int(i[0]) for i in items}:
|
||||
own_unit = ifc_file.by_id(prop.unit_id)
|
||||
name = getattr(own_unit, "Name", None) or own_unit.is_a()
|
||||
symbol = ifcopenshell.util.unit.get_unit_symbol(own_unit)
|
||||
label = f"{name} ({symbol})" if symbol else name
|
||||
items.append((cache_string(str(prop.unit_id)), cache_string(label), ""))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def update_attribute_unit_id(self: "Attribute", context: bpy.types.Context) -> None:
|
||||
new_unit_id = int(tool.Blender.get_enum_safe(self, "unit_id_enum") or "0")
|
||||
if ifc_file := tool.Ifc.get():
|
||||
# Must run before self.unit_id is overwritten: convert_attribute_unit needs the OLD
|
||||
# unit_id to know what unit the current value is expressed in.
|
||||
tool.Pset.convert_attribute_unit(self, new_unit_id, ifc_file)
|
||||
self.unit_id = new_unit_id
|
||||
|
||||
|
||||
def update_schema_dir(self: "BIMProperties", context: bpy.types.Context) -> None:
|
||||
import bonsai.bim.schema
|
||||
|
||||
@@ -302,33 +250,44 @@ def set_numerical_value(self: "Attribute", value_name: str, new_value: Union[flo
|
||||
self[value_name] = new_value
|
||||
|
||||
|
||||
def get_length_value(self: "Attribute") -> float:
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
return self.float_value * si_conversion
|
||||
|
||||
|
||||
def set_length_value(self: "Attribute", value: float) -> None:
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
self.float_value = value / si_conversion
|
||||
|
||||
|
||||
def get_display_name(self: "Attribute") -> str:
|
||||
DISPLAY_UNIT_TYPES = ("AREA", "VOLUME", "FORCE")
|
||||
name = self.name
|
||||
if not self.unit_symbol:
|
||||
if not self.special_type or self.special_type not in DISPLAY_UNIT_TYPES:
|
||||
return name
|
||||
return f"{name}, {self.unit_symbol}"
|
||||
|
||||
unit_type = f"{self.special_type}UNIT"
|
||||
project_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), unit_type)
|
||||
if not project_unit:
|
||||
return name
|
||||
|
||||
def get_unit_symbol(self: "Attribute") -> str:
|
||||
"""The symbol for whatever unit the value is currently expressed in: this property's own
|
||||
override (`unit_id`) if set, else the project default for `special_type`. Computed fresh on
|
||||
every access (rather than cached at import time) so it stays correct immediately after the
|
||||
unit picker changes `unit_id`, and after the project's own default units are edited.
|
||||
"""
|
||||
if not tool.Pset.is_measurable_special_type(self.special_type):
|
||||
return ""
|
||||
if not (ifc_file := tool.Ifc.get()):
|
||||
return ""
|
||||
unit = tool.Pset.resolve_effective_unit(self.special_type, self.unit_id, ifc_file)
|
||||
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
|
||||
unit_symbol = ifcopenshell.util.unit.get_unit_symbol(project_unit)
|
||||
return f"{name}, {unit_symbol}"
|
||||
|
||||
|
||||
AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file", "list[string]"]
|
||||
# Either "", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION", or an
|
||||
# IfcUnitEnum/IfcDerivedUnitEnum value with the "UNIT" suffix stripped (e.g.
|
||||
# "LENGTH", "PRESSURE", "MODULUSOFELASTICITY") as returned by
|
||||
# tool.Pset.get_special_type_for_prop().
|
||||
AttributeSpecialType = str
|
||||
AttributeSpecialType = Literal[
|
||||
"",
|
||||
"DATE",
|
||||
"DATETIME",
|
||||
"LENGTH",
|
||||
"AREA",
|
||||
"VOLUME",
|
||||
"FORCE",
|
||||
"LOGICAL",
|
||||
"URI",
|
||||
"DURATION",
|
||||
]
|
||||
|
||||
|
||||
class Attribute(PropertyGroup):
|
||||
@@ -359,6 +318,9 @@ class Attribute(PropertyGroup):
|
||||
get=lambda self: float(self.get("float_value", 0.0)),
|
||||
set=set_float_value,
|
||||
)
|
||||
length_value: FloatProperty(
|
||||
name="Value", description=tooltip, get=get_length_value, set=set_length_value, unit="LENGTH"
|
||||
)
|
||||
enum_items: StringProperty(name="Value")
|
||||
"""Json serialized mapping of enum items:
|
||||
Typically a dictionary of string identifiers to item names.
|
||||
@@ -380,10 +342,6 @@ class Attribute(PropertyGroup):
|
||||
value_max: FloatProperty(description="This is used to validate int_value and float_value")
|
||||
value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound")
|
||||
special_type: StringProperty(name="Special Value Type", default="")
|
||||
unit_symbol: StringProperty(name="Unit Symbol", get=get_unit_symbol)
|
||||
unit_id: IntProperty(name="Unit Override", default=0)
|
||||
"""STEP id of this property/quantity's own Unit override. 0 means "use the project default"."""
|
||||
unit_id_enum: EnumProperty(items=get_attribute_unit_enum_items, name="Unit", update=update_attribute_unit_id)
|
||||
use_explorer_ui: BoolProperty()
|
||||
metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute")
|
||||
update: StringProperty(name="Update", description="Custom update function to be executed")
|
||||
@@ -399,6 +357,7 @@ class Attribute(PropertyGroup):
|
||||
bool_value: bool
|
||||
int_value: int
|
||||
float_value: float
|
||||
length_value: float
|
||||
enum_items: str
|
||||
enum_items_dynamic: str
|
||||
enum_descriptions: bpy.types.bpy_prop_collection_idprop[StrProperty]
|
||||
@@ -414,9 +373,6 @@ class Attribute(PropertyGroup):
|
||||
value_min_constraint: bool
|
||||
value_max: float
|
||||
value_max_constraint: bool
|
||||
unit_symbol: str
|
||||
unit_id: int
|
||||
unit_id_enum: str
|
||||
use_explorer_ui: bool
|
||||
metadata: str
|
||||
update: str
|
||||
@@ -474,6 +430,8 @@ class Attribute(PropertyGroup):
|
||||
elif data_type == "integer":
|
||||
return "int_value"
|
||||
elif data_type == "float":
|
||||
if display_only and self.special_type == "LENGTH":
|
||||
return "length_value"
|
||||
return "float_value"
|
||||
elif data_type == "enum":
|
||||
return "enum_value"
|
||||
|
||||
@@ -542,6 +542,10 @@ 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)
|
||||
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 +554,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,8 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties
|
||||
|
||||
|
||||
|
||||
|
||||
class Drawing(bonsai.core.tool.Drawing):
|
||||
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
|
||||
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
|
||||
@@ -209,6 +211,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 +1645,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 +1984,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 +2919,156 @@ 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:
|
||||
return v0_world, v1_world
|
||||
scale = cls.get_scale_ratio(diagram_scale["Scale"])
|
||||
model_offset = (border_offset_mm / 1000.0) / scale
|
||||
|
||||
width, height = cls.get_camera_dimensions(camera)
|
||||
half_w, half_h = width / 2, height / 2
|
||||
|
||||
cam_inv = camera.matrix_world.inverted()
|
||||
v0_local = cam_inv @ v0_world
|
||||
v1_local = cam_inv @ v1_world
|
||||
|
||||
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:
|
||||
return v0_world, v1_world
|
||||
dir_xy = dir_xy.normalized()
|
||||
z = v0_local.z
|
||||
|
||||
t_values = cls._section_ray_rect_intersections(origin, dir_xy, half_w, half_h)
|
||||
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)
|
||||
|
||||
if not pos_ts or not neg_ts:
|
||||
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."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
if not obj.data or not hasattr(obj.data, "edges") or not obj.data.edges:
|
||||
return
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {}
|
||||
border_offset = float(pset_data.get("BorderOffset", 8.0))
|
||||
if border_offset <= 0:
|
||||
return
|
||||
|
||||
auto_v0 = cls._parse_vector3(pset_data.get("AutoStartPosition") or "")
|
||||
auto_v1 = cls._parse_vector3(pset_data.get("AutoEndPosition") or "")
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
if not v0_is_auto and not v1_is_auto:
|
||||
return
|
||||
|
||||
new_v0_world, new_v1_world = cls.get_section_border_positions(camera, v0_world, v1_world, border_offset)
|
||||
|
||||
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="")
|
||||
|
||||
@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)
|
||||
|
||||
+33
-147
@@ -26,7 +26,6 @@ import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.attribute
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
import bonsai.bim.helper
|
||||
import bonsai.bim.schema
|
||||
@@ -169,144 +168,42 @@ class Pset(bonsai.core.tool.Pset):
|
||||
pset_id=0, pset_name=cls.get_pset_name(obj, obj_type), pset_type="PSET", obj=obj, obj_type=obj_type
|
||||
)
|
||||
|
||||
# Templates for quantities can specify their kind via TemplateType (e.g.
|
||||
# "Q_LENGTH") instead of PrimaryMeasureType. IfcQuantityCount has no
|
||||
# associated measure/unit, so it is intentionally absent here.
|
||||
QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE = {
|
||||
"Q_LENGTH": "LENGTH",
|
||||
"Q_AREA": "AREA",
|
||||
"Q_VOLUME": "VOLUME",
|
||||
"Q_WEIGHT": "MASS",
|
||||
"Q_TIME": "TIME",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_special_type_for_measure_class(cls, measure_class: str) -> str:
|
||||
"""Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) for an IFC measure class.
|
||||
|
||||
:param measure_class: An IFC measure class name, e.g. "IfcLengthMeasure".
|
||||
:return: E.g. "LENGTH", or "" if the class has no associated unit type.
|
||||
"""
|
||||
if not measure_class.endswith("Measure"):
|
||||
return ""
|
||||
unit_type = ifcopenshell.util.unit.get_measure_unit_type(measure_class)
|
||||
return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else ""
|
||||
|
||||
@classmethod
|
||||
def get_special_type_for_unit(cls, unit: ifcopenshell.entity_instance) -> str:
|
||||
"""Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) directly from
|
||||
a Unit entity, for properties whose NominalValue is a generic numeric type (e.g.
|
||||
IfcReal) rather than a proper measure class, but which still carry a real Unit.
|
||||
"""
|
||||
unit_type = getattr(unit, "UnitType", None)
|
||||
if unit_type and unit_type != "USERDEFINED":
|
||||
return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else ""
|
||||
dimension_type = ifcopenshell.util.unit.identify_unit_dimensions(unit)
|
||||
return dimension_type[: -len("UNIT")] if dimension_type else ""
|
||||
|
||||
@classmethod
|
||||
def get_special_type_for_prop(cls, prop_or_prop_template: ifcopenshell.entity_instance) -> str:
|
||||
"""Classify a property/quantity/template by its measure type.
|
||||
|
||||
:return: An IfcUnitEnum value with the "UNIT" suffix stripped (e.g.
|
||||
"LENGTH", "PRESSURE"), "URI" for IfcURIReference, or "" if the
|
||||
value has no associated unit type.
|
||||
"""
|
||||
def get_special_type_for_prop(
|
||||
cls, prop_or_prop_template: ifcopenshell.entity_instance
|
||||
) -> Literal["LENGTH"] | Literal["AREA"] | Literal["VOLUME"] | Literal["URI"] | Literal[""]:
|
||||
special_type = ""
|
||||
if prop_or_prop_template.is_a("IfcPropertyTemplate"):
|
||||
primary_measure_type = prop_or_prop_template.PrimaryMeasureType
|
||||
if primary_measure_type == "IfcURIReference":
|
||||
return "URI"
|
||||
if primary_measure_type:
|
||||
return cls.get_special_type_for_measure_class(primary_measure_type)
|
||||
return cls.QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE.get(prop_or_prop_template.TemplateType, "")
|
||||
elif prop_or_prop_template.is_a("IfcPropertySingleValue"):
|
||||
value = prop_or_prop_template.NominalValue
|
||||
if value is not None:
|
||||
special_type = cls.get_special_type_for_measure_class(value.is_a())
|
||||
if special_type:
|
||||
return special_type
|
||||
# Some property sets declare a generic numeric type (e.g. IfcReal) rather
|
||||
# than a proper measure class, relying on an explicit Unit attribute alone to
|
||||
# convey the dimension. Still measurable -- derive special_type from the Unit
|
||||
# itself rather than (fruitlessly) from NominalValue's declared type.
|
||||
if value.is_a() in ("IfcReal", "IfcInteger"):
|
||||
if unit := getattr(prop_or_prop_template, "Unit", None):
|
||||
return cls.get_special_type_for_unit(unit)
|
||||
elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"):
|
||||
entity = prop_or_prop_template.wrapped_data.declaration().as_entity()
|
||||
measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name()
|
||||
return cls.get_special_type_for_measure_class(measure_class)
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_unit_symbol_for_special_type(cls, special_type: str, ifc_file: ifcopenshell.file) -> str:
|
||||
"""Get the project's default unit symbol for a `special_type` (see `get_special_type_for_prop`).
|
||||
|
||||
Used where there's no property instance to check for a `Unit` override
|
||||
(e.g. a template, or a native IFC entity attribute, neither of which
|
||||
can carry one).
|
||||
"""
|
||||
if not special_type or special_type == "URI":
|
||||
return ""
|
||||
unit = ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT")
|
||||
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
|
||||
|
||||
@classmethod
|
||||
def get_unit_symbol_for_prop(cls, prop: ifcopenshell.entity_instance, ifc_file: ifcopenshell.file) -> str:
|
||||
"""Get the unit symbol for an existing property/quantity, respecting its own `Unit` override.
|
||||
|
||||
Gated on the property being classified as measurable (see `get_special_type_for_prop`,
|
||||
which already accounts for a Unit attached to a generic numeric value) -- this only
|
||||
excludes a Unit attached to a property whose value has no numeric/measure semantics at
|
||||
all (e.g. text), where a stray Unit shouldn't be surfaced as a resolved unit.
|
||||
"""
|
||||
if not cls.is_measurable_special_type(cls.get_special_type_for_prop(prop)):
|
||||
return ""
|
||||
unit = ifcopenshell.util.unit.get_property_unit(prop, ifc_file)
|
||||
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
|
||||
|
||||
# special_type values that don't denote a real unit-bearing measure (see get_special_type_for_prop).
|
||||
NON_MEASURABLE_SPECIAL_TYPES = frozenset({"", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"})
|
||||
|
||||
@classmethod
|
||||
def is_measurable_special_type(cls, special_type: str) -> bool:
|
||||
"""True if `special_type` (see `get_special_type_for_prop`) denotes a real unit-bearing measure."""
|
||||
return special_type not in cls.NON_MEASURABLE_SPECIAL_TYPES
|
||||
|
||||
@classmethod
|
||||
def get_candidate_units_for_special_type(
|
||||
cls, special_type: str, ifc_file: ifcopenshell.file
|
||||
) -> list[ifcopenshell.entity_instance]:
|
||||
"""All units in the file usable as an override for a `special_type` (see `get_special_type_for_prop`)."""
|
||||
if not cls.is_measurable_special_type(special_type):
|
||||
return []
|
||||
return ifcopenshell.util.unit.get_candidate_units(ifc_file, f"{special_type}UNIT")
|
||||
|
||||
@classmethod
|
||||
def resolve_effective_unit(
|
||||
cls, special_type: str, unit_id: int, ifc_file: ifcopenshell.file
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""The unit a value is currently expressed in: its own override (`unit_id`, a STEP id,
|
||||
0 meaning "no override"), or the project default for `special_type` otherwise."""
|
||||
if unit_id:
|
||||
return ifc_file.by_id(unit_id)
|
||||
return ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT")
|
||||
|
||||
@classmethod
|
||||
def convert_attribute_unit(cls, metadata: "Attribute", new_unit_id: int, ifc_file: ifcopenshell.file) -> None:
|
||||
"""Rescale `metadata.float_value` in place so its physical quantity is preserved when
|
||||
switching from its current effective unit to the unit named by `new_unit_id` (0 = project
|
||||
default). No-op for non-measurable attributes or when old and new resolve to the same unit.
|
||||
"""
|
||||
if not cls.is_measurable_special_type(metadata.special_type):
|
||||
return
|
||||
old_unit = cls.resolve_effective_unit(metadata.special_type, metadata.unit_id, ifc_file)
|
||||
new_unit = cls.resolve_effective_unit(metadata.special_type, new_unit_id, ifc_file)
|
||||
if old_unit is None or new_unit is None or old_unit == new_unit:
|
||||
return
|
||||
old_scale = ifcopenshell.util.unit.get_unit_scale(old_unit)
|
||||
new_scale = ifcopenshell.util.unit.get_unit_scale(new_unit)
|
||||
metadata.float_value = metadata.float_value * old_scale / new_scale
|
||||
template_type = prop_or_prop_template.TemplateType
|
||||
if primary_measure_type in ("IfcPositiveLengthMeasure", "IfcLengthMeasure") or template_type == "Q_LENGTH":
|
||||
special_type = "LENGTH"
|
||||
elif primary_measure_type == "IfcAreaMeasure" or template_type == "Q_AREA":
|
||||
special_type = "AREA"
|
||||
elif primary_measure_type == "IfcVolumeMeasure" or template_type == "Q_VOLUME":
|
||||
special_type = "VOLUME"
|
||||
elif primary_measure_type == "IfcURIReference":
|
||||
special_type = "URI"
|
||||
else:
|
||||
if prop_or_prop_template.is_a("IfcPropertySingleValue"):
|
||||
value = prop_or_prop_template.NominalValue
|
||||
if value is not None:
|
||||
value_type = value.is_a()
|
||||
if value_type in ("IfcLengthMeasure", "IfcPositiveLengthMeasure"):
|
||||
special_type = "LENGTH"
|
||||
elif value_type == "IfcAreaMeasure":
|
||||
special_type = "AREA"
|
||||
elif value_type == "IfcVolumeMeasure":
|
||||
special_type = "VOLUME"
|
||||
elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"):
|
||||
prop_class = prop_or_prop_template.is_a()
|
||||
if prop_class == "IfcQuantityArea":
|
||||
special_type = "AREA"
|
||||
elif prop_class == "IfcQuantityVolume":
|
||||
special_type = "VOLUME"
|
||||
elif prop_class == "IfcQuantityLength":
|
||||
special_type = "LENGTH"
|
||||
return special_type
|
||||
|
||||
@classmethod
|
||||
def import_pset_from_existing(
|
||||
@@ -386,15 +283,6 @@ class Pset(bonsai.core.tool.Pset):
|
||||
metadata.is_null = value is None
|
||||
metadata.is_optional = True
|
||||
metadata.special_type = cls.get_special_type_for_prop(prop)
|
||||
# The prop's OWN Unit override only -- metadata.unit_symbol is computed fresh from
|
||||
# special_type/unit_id on every access (see Attribute.get_unit_symbol), so it
|
||||
# already accounts for the project-default fallback once unit_id is set below.
|
||||
# Some real-world files (e.g. certain exporters) set Unit on properties that
|
||||
# aren't actually measures -- ignore it there, since we only ever treat Unit as
|
||||
# meaningful for measurable special_types (matching the UI picker's own gating).
|
||||
own_unit = getattr(prop, "Unit", None) if cls.is_measurable_special_type(metadata.special_type) else None
|
||||
metadata.unit_id = own_unit.id() if own_unit else 0
|
||||
metadata.unit_id_enum = str(metadata.unit_id)
|
||||
metadata.set_value(metadata.get_value_default() if metadata.is_null else value)
|
||||
process_prop_description(metadata)
|
||||
|
||||
@@ -521,8 +409,6 @@ class Pset(bonsai.core.tool.Pset):
|
||||
cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props)
|
||||
|
||||
elif prop_template.TemplateType.startswith("Q_"):
|
||||
if prop_data:
|
||||
continue # Existing quantity will be added later by import_pset_from_existing.
|
||||
cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props)
|
||||
|
||||
elif prop_template.TemplateType == "P_ENUMERATEDVALUE":
|
||||
|
||||
@@ -176,19 +176,6 @@ class Qto(bonsai.core.tool.Qto):
|
||||
is_ifc4x3 = ifc_file.schema == "IFC4X3"
|
||||
return {rule_id: rule for rule_id, rule in ifc5d.qto.rules.items() if rule_id.startswith("IFC4X3") == is_ifc4x3}
|
||||
|
||||
@classmethod
|
||||
def get_target_units(cls) -> dict[str, ifcopenshell.entity_instance]:
|
||||
from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
|
||||
|
||||
props = cls.get_qto_props()
|
||||
ifc_file = tool.Ifc.get()
|
||||
target_units: dict[str, ifcopenshell.entity_instance] = {}
|
||||
for measure_class, (field_name, _special_type, _label) in MEASURE_TO_TARGET_UNIT_FIELD.items():
|
||||
unit_id = int(tool.Blender.get_enum_safe(props, field_name) or "0")
|
||||
if unit_id:
|
||||
target_units[measure_class] = ifc_file.by_id(unit_id)
|
||||
return target_units
|
||||
|
||||
@classmethod
|
||||
def get_not_quantified_elements_message(cls, not_quantified_elements: set[ifcopenshell.entity_instance]) -> str:
|
||||
not_quantified_message = ""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -361,22 +361,31 @@ Scenario: Edit pset length property
|
||||
Given an empty IFC project
|
||||
And I press "mesh.add_stair"
|
||||
And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Pset_StairFlightCommon').id()"
|
||||
And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())"
|
||||
And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')"
|
||||
|
||||
# Testing IfcPositiveLengthMeasure type of prop
|
||||
Then "active_object.PsetProperties.properties['TreadLength'].metadata.special_type" is "LENGTH"
|
||||
And "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is "250"
|
||||
And "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" is roughly "0.25"
|
||||
|
||||
When I set "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" to "350"
|
||||
Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "350"
|
||||
|
||||
When I set "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" to "0.45"
|
||||
Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "450"
|
||||
|
||||
# Testing IfcLengthMeasure type of prop
|
||||
Then "active_object.PsetProperties.properties['NosingLength'].metadata.special_type" is "LENGTH"
|
||||
And "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is "0.0"
|
||||
And "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" is roughly "0.0"
|
||||
|
||||
When I set "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" to "350"
|
||||
Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "350"
|
||||
|
||||
When I set "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" to "0.45"
|
||||
Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "450"
|
||||
|
||||
When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')"
|
||||
Then nothing happens
|
||||
|
||||
@@ -385,14 +394,19 @@ Scenario: Edit qset length property
|
||||
And I press "mesh.add_stair"
|
||||
And I press "bim.perform_quantity_take_off"
|
||||
And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Qto_StairFlightBaseQuantities').id()"
|
||||
And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())"
|
||||
And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')"
|
||||
|
||||
# Testing Q_LENGTH type of prop
|
||||
Then "active_object.PsetProperties.properties['Length'].metadata.special_type" is "LENGTH"
|
||||
And "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "2156.485"
|
||||
And "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "2.156"
|
||||
|
||||
When I set "active_object.PsetProperties.properties['Length'].metadata.float_value" to "350"
|
||||
Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "350"
|
||||
Then "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "0.35"
|
||||
|
||||
When I set "active_object.PsetProperties.properties['Length'].metadata.length_value" to "0.45"
|
||||
Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "450"
|
||||
|
||||
When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')"
|
||||
Then nothing happens
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
import bonsai.bim.prop
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
|
||||
def import_single_property(ifc, element, prop):
|
||||
"""Import a single existing IfcProperty into a real, addon-registered
|
||||
PsetProperties collection, exactly as the property editor does, and
|
||||
return its `metadata` (an `Attribute`)."""
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
|
||||
pset.HasProperties = [prop]
|
||||
obj = bpy.data.objects.new(prop.Name, None)
|
||||
tool.Ifc.link(element, obj)
|
||||
props = obj.PsetProperties
|
||||
tool.Pset.import_pset_from_existing(pset, props, None)
|
||||
return props.properties[prop.Name].metadata
|
||||
|
||||
|
||||
class TestGetDisplayName(NewFile):
|
||||
def test_appends_the_resolved_unit_symbol(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
pressure = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="PRESSUREUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[pressure])
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
assert metadata.display_name == "Foo, Pa"
|
||||
|
||||
def test_falls_back_to_the_plain_name_when_no_unit_is_resolvable(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
# No units assigned to the project at all -- nothing to resolve.
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
assert metadata.unit_symbol == ""
|
||||
assert metadata.display_name == "Foo"
|
||||
|
||||
def test_falls_back_to_the_plain_name_for_a_non_measure_property(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcText("Bar"))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
assert metadata.unit_symbol == ""
|
||||
assert metadata.display_name == "Foo"
|
||||
|
||||
def test_resolves_a_unit_explicitly_attached_to_a_generic_numeric_value(self):
|
||||
# A generic IfcReal has no unit semantics per its own declared type, but a property
|
||||
# set may still explicitly attach a real Unit to a specific instance to convey the
|
||||
# dimension the spec's generic typing doesn't. That explicit Unit is real, deliberate
|
||||
# data (not incidental/stray), so it should resolve normally.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm)
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
assert metadata.unit_symbol == "mm"
|
||||
assert metadata.display_name == "Foo, mm"
|
||||
|
||||
|
||||
class TestGetAttributeUnitEnumItems(NewFile):
|
||||
def test_returns_default_plus_one_per_candidate_for_a_measurable_type(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
|
||||
identifiers = [i[0] for i in items]
|
||||
assert identifiers[0] == "0"
|
||||
assert str(length_mm.id()) in identifiers
|
||||
assert str(length_m.id()) in identifiers
|
||||
assert len(items) == 3 # Default + mm + m
|
||||
|
||||
def test_returns_just_default_for_non_measurable_types(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcText("Bar"))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
|
||||
assert [i[0] for i in items] == ["0"]
|
||||
|
||||
|
||||
class TestGetUnitEnumItemsForSpecialType(NewFile):
|
||||
def test_matches_the_attribute_wrapper_output(self):
|
||||
# Regression test for extracting get_unit_enum_items_for_special_type out of
|
||||
# get_attribute_unit_enum_items: the wrapper must still produce identical items for
|
||||
# the plain (no own-unit-fallback-needed) case.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
direct_items = bonsai.bim.prop.get_unit_enum_items_for_special_type(metadata.special_type, ifc)
|
||||
wrapper_items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
|
||||
assert direct_items == wrapper_items
|
||||
|
||||
def test_returns_just_default_when_ifc_file_is_none(self):
|
||||
assert bonsai.bim.prop.get_unit_enum_items_for_special_type("LENGTH", None) == [("0", "Default", "")]
|
||||
|
||||
|
||||
class TestUnitSymbolWithAreaVolumeDerivedFromLength(NewFile):
|
||||
"""Regression test: AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project
|
||||
whose area/volume default is an IfcDerivedUnit rather than a literal-UnitType-matching
|
||||
IfcSIUnit/IfcConversionBasedUnit has no literal UnitType to match on.
|
||||
ifcopenshell.util.unit.get_project_unit() used to only match by literal UnitType, so the
|
||||
read-only unit symbol and the edit-mode "Default (<symbol>)" picker entry both silently
|
||||
fell back to no symbol at all in that case.
|
||||
"""
|
||||
|
||||
def setup_project_with_derived_area_and_volume(self, ifc):
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
area = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "area-ish", {length: 2})
|
||||
volume = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "volume-ish", {length: 3})
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length, area, volume])
|
||||
|
||||
def test_default_picker_entry_shows_the_resolved_symbol(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
self.setup_project_with_derived_area_and_volume(ifc)
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
|
||||
assert items[0][1] == "Default (m2)"
|
||||
|
||||
def test_read_only_display_resolves_the_symbol(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
self.setup_project_with_derived_area_and_volume(ifc)
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcVolumeMeasure(5.0))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
assert metadata.unit_symbol == "m3"
|
||||
assert metadata.display_name == "Foo, m3"
|
||||
|
||||
|
||||
class TestUpdateAttributeUnitId(NewFile):
|
||||
def test_syncs_unit_id_and_converts_float_value_when_unit_id_enum_changes(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
assert metadata.unit_id == 0
|
||||
assert metadata.float_value == 2500.0
|
||||
assert metadata.unit_symbol == "mm"
|
||||
assert metadata.display_name == "Foo, mm"
|
||||
|
||||
metadata.unit_id_enum = str(length_m.id())
|
||||
|
||||
assert metadata.unit_id == length_m.id()
|
||||
assert metadata.float_value == pytest.approx(2.5) # converted, not just relabeled
|
||||
# Regression test: unit_symbol/display_name used to be a snapshot taken once at import
|
||||
# time, so picking a different unit converted the value but left the label showing the
|
||||
# old unit's symbol.
|
||||
assert metadata.unit_symbol == "m"
|
||||
assert metadata.display_name == "Foo, m"
|
||||
|
||||
|
||||
class TestUnitSymbolReflectsLiveProjectState(NewFile):
|
||||
def test_symbol_updates_after_a_project_default_unit_is_assigned_later(self):
|
||||
# Regression test: unit_symbol used to be a snapshot computed once at import time, so a
|
||||
# property/quantity imported before its measure type had a project default unit assigned
|
||||
# kept showing no symbol even after one was added, unless the panel was closed and
|
||||
# reopened (re-triggering import). It's now computed fresh on every access.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
# No AREAUNIT assigned yet.
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
assert metadata.unit_symbol == ""
|
||||
assert metadata.display_name == "Foo"
|
||||
|
||||
area = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[area])
|
||||
|
||||
assert metadata.unit_symbol == "m2"
|
||||
assert metadata.display_name == "Foo, m2"
|
||||
|
||||
|
||||
class TestImportPsetFromExistingWithAGenericNumericValueAndAnExplicitUnit(NewFile):
|
||||
def test_run(self):
|
||||
# Regression test: some property set specifications declare a property as a generic
|
||||
# IfcReal rather than a proper measure class, relying on an explicit Unit attribute
|
||||
# alone to convey the dimension. get_property_unit() already handled this fine for
|
||||
# display (it checks prop.Unit before looking at NominalValue's type at all), but
|
||||
# get_special_type_for_prop() only looked at NominalValue's class ending in "Measure"
|
||||
# -- so special_type came back "", the picker never appeared, and the real Unit
|
||||
# override never got seeded into unit_id even though it was legitimately set.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(
|
||||
Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm
|
||||
)
|
||||
metadata = import_single_property(ifc, element, prop)
|
||||
|
||||
assert metadata.special_type == "LENGTH"
|
||||
assert tool.Pset.is_measurable_special_type(metadata.special_type)
|
||||
assert metadata.unit_symbol == "mm"
|
||||
assert metadata.unit_id == length_mm.id()
|
||||
assert metadata.unit_id_enum == str(length_mm.id())
|
||||
|
||||
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
|
||||
assert str(length_mm.id()) in [i[0] for i in items]
|
||||
|
||||
|
||||
class TestImportPsetFromExistingWithAStrayUnitOnANonMeasureProperty(NewFile):
|
||||
def test_run(self):
|
||||
# Regression test: some real-world exporters set a Unit on a property whose
|
||||
# NominalValue isn't actually a measure (e.g. a text classification), which used
|
||||
# to crash import_pset_from_existing with "enum '<id>' not found in ('0')" -- unit_id
|
||||
# was seeded from prop.Unit unconditionally, before the special_type gate that decides
|
||||
# whether Unit is even meaningful for this property.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(
|
||||
Name="Foo", NominalValue=ifc.createIfcLabel("Bar"), Unit=length_m
|
||||
)
|
||||
metadata = import_single_property(ifc, element, prop) # must not raise
|
||||
|
||||
assert metadata.special_type == ""
|
||||
assert metadata.unit_id == 0
|
||||
assert metadata.unit_id_enum == "0"
|
||||
|
||||
|
||||
class TestGetAttributeUnitEnumItemsWithAMismatchedUnit(NewFile):
|
||||
def test_own_unit_is_always_representable_even_if_not_a_normal_candidate(self):
|
||||
# Regression test: a property's own Unit might not satisfy
|
||||
# get_candidate_units_for_special_type's matching (e.g. mismatched UnitType in messy
|
||||
# real-world data). Seeding must never crash trying to select it, and it should still
|
||||
# show up in the picker so the user can see/change it.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
# A LENGTHUNIT attached to a PRESSURE-typed property -- a real mismatch, not a candidate
|
||||
# get_candidate_units_for_special_type("PRESSURE", ...) would ever return.
|
||||
mismatched_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
prop = ifc.createIfcPropertySingleValue(
|
||||
Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0), Unit=mismatched_unit
|
||||
)
|
||||
metadata = import_single_property(ifc, element, prop) # must not raise
|
||||
|
||||
assert metadata.special_type == "PRESSURE"
|
||||
assert metadata.unit_id == mismatched_unit.id()
|
||||
assert metadata.unit_id_enum == str(mismatched_unit.id())
|
||||
|
||||
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
|
||||
assert str(mismatched_unit.id()) in [i[0] for i in items]
|
||||
@@ -20,9 +20,6 @@ import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
@@ -55,340 +52,3 @@ class TestIsPsetEmpty(NewFile):
|
||||
assert subject.is_pset_empty(pset) is False
|
||||
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties={"Foo": None})
|
||||
assert subject.is_pset_empty(pset) is True
|
||||
|
||||
|
||||
class TestEditingAnOverriddenUnitPropertyRoundTrips(NewFile):
|
||||
def test_run(self):
|
||||
# Project default is mm, but this property is authored directly in m.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
|
||||
prop = ifc.createIfcPropertySingleValue(
|
||||
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5), Unit=length_m
|
||||
)
|
||||
pset.HasProperties = [prop]
|
||||
|
||||
obj = bpy.data.objects.new("Wall", None)
|
||||
tool.Ifc.link(element, obj)
|
||||
blender_props = obj.PsetProperties
|
||||
subject.import_pset_from_existing(pset, blender_props, None)
|
||||
|
||||
metadata = blender_props.properties["Foo"].metadata
|
||||
assert metadata.unit_symbol == "m"
|
||||
assert metadata.float_value == 2.5 # raw stored value, not rescaled to the project's mm
|
||||
|
||||
# Simulate a user edit in the property editor.
|
||||
metadata.float_value = 3.5
|
||||
|
||||
# Simulate what EditPset.execute() does: collect the raw value straight
|
||||
# off the metadata and write it back, with no rescaling step.
|
||||
properties = {"Foo": metadata.get_value()}
|
||||
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
|
||||
|
||||
assert prop.NominalValue.wrappedValue == 3.5 # not rescaled to 3500mm
|
||||
assert prop.Unit == length_m # override preserved
|
||||
|
||||
|
||||
class TestImportingATemplatedQuantityRespectsItsOwnUnitOverride(NewFile):
|
||||
def test_run(self):
|
||||
# Regression test: import_pset_from_template's Q_ branch used to
|
||||
# unconditionally re-template existing quantities, which shadowed
|
||||
# their own Unit override with the project default -- edit mode
|
||||
# showed "m" while the read-only panel correctly showed "mm".
|
||||
# Project default is m, but this quantity is authored directly in mm.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
|
||||
element = ifc.createIfcBeam()
|
||||
qto = ifcopenshell.api.pset.add_qto(ifc, product=element, name="Qto_Test")
|
||||
quantity = ifc.createIfcQuantityLength(Name="Foo", Unit=length_mm, LengthValue=2500.0)
|
||||
qto.Quantities = [quantity]
|
||||
|
||||
pset_template = ifc.createIfcPropertySetTemplate(
|
||||
Name="Qto_Test",
|
||||
TemplateType="PSET_TYPEDRIVENOVERRIDE",
|
||||
ApplicableEntity="IfcBeam",
|
||||
HasPropertyTemplates=[ifc.createIfcSimplePropertyTemplate(Name="Foo", TemplateType="Q_LENGTH")],
|
||||
)
|
||||
|
||||
obj = bpy.data.objects.new("Beam", None)
|
||||
tool.Ifc.link(element, obj)
|
||||
blender_props = obj.PsetProperties
|
||||
# Mirrors core/pset.py's enable_pset_editing: template pass, then existing-data pass.
|
||||
subject.import_pset_from_template(pset_template, qto, blender_props)
|
||||
subject.import_pset_from_existing(qto, blender_props, pset_template)
|
||||
|
||||
assert len(blender_props.properties) == 1 # not duplicated by the template pass
|
||||
metadata = blender_props.properties["Foo"].metadata
|
||||
assert metadata.unit_symbol == "mm" # the quantity's own override, not the project default "m"
|
||||
assert metadata.float_value == 2500.0 # raw stored value, not rescaled
|
||||
|
||||
|
||||
class TestIsMeasurableSpecialType(NewFile):
|
||||
def test_run(self):
|
||||
for special_type in ("", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"):
|
||||
assert subject.is_measurable_special_type(special_type) is False
|
||||
assert subject.is_measurable_special_type("LENGTH") is True
|
||||
assert subject.is_measurable_special_type("PRESSURE") is True
|
||||
|
||||
|
||||
class TestGetCandidateUnitsForSpecialType(NewFile):
|
||||
def test_returns_candidates_matching_the_special_type(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT")
|
||||
assert set(subject.get_candidate_units_for_special_type("LENGTH", ifc)) == {length_mm, length_m}
|
||||
|
||||
def test_gating_returns_empty_for_non_measurable_special_types(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
assert subject.get_candidate_units_for_special_type("", ifc) == []
|
||||
assert subject.get_candidate_units_for_special_type("URI", ifc) == []
|
||||
|
||||
|
||||
class TestResolveEffectiveUnit(NewFile):
|
||||
def test_own_override_takes_precedence_over_project_default(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
assert subject.resolve_effective_unit("LENGTH", length_m.id(), ifc) == length_m
|
||||
|
||||
def test_falls_back_to_project_default_when_unit_id_is_zero(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
assert subject.resolve_effective_unit("LENGTH", 0, ifc) == length_mm
|
||||
|
||||
|
||||
class TestConvertAttributeUnit(NewFile):
|
||||
def _new_metadata(self, ifc: ifcopenshell.file):
|
||||
element = ifc.createIfcWall()
|
||||
obj = bpy.data.objects.new("Wall", None)
|
||||
tool.Ifc.link(element, obj)
|
||||
props = obj.PsetProperties
|
||||
new_prop = props.properties.add()
|
||||
new_prop.name = "Foo"
|
||||
return new_prop.metadata
|
||||
|
||||
def test_converts_value_between_two_explicit_units(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
metadata = self._new_metadata(ifc)
|
||||
metadata.special_type = "LENGTH"
|
||||
metadata.unit_id = length_mm.id()
|
||||
metadata.float_value = 2500.0
|
||||
|
||||
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
|
||||
assert metadata.float_value == pytest.approx(2.5)
|
||||
|
||||
def test_converts_value_when_switching_to_and_from_the_project_default(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
|
||||
length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot")
|
||||
|
||||
metadata = self._new_metadata(ifc)
|
||||
metadata.special_type = "LENGTH"
|
||||
metadata.unit_id = length_ft.id()
|
||||
metadata.float_value = 10.0 # 10 ft
|
||||
|
||||
subject.convert_attribute_unit(metadata, 0, ifc) # 0 = switch to project default (m)
|
||||
assert metadata.float_value == pytest.approx(3.048)
|
||||
|
||||
def test_noop_when_old_and_new_resolve_to_the_same_unit(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
|
||||
|
||||
metadata = self._new_metadata(ifc)
|
||||
metadata.special_type = "LENGTH"
|
||||
metadata.unit_id = 0 # already resolves to length_m (the project default)
|
||||
metadata.float_value = 5.0
|
||||
|
||||
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
|
||||
assert metadata.float_value == 5.0
|
||||
|
||||
def test_noop_for_a_non_measurable_special_type(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
metadata = self._new_metadata(ifc)
|
||||
metadata.special_type = ""
|
||||
metadata.unit_id = 0
|
||||
metadata.float_value = 5.0
|
||||
|
||||
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
|
||||
assert metadata.float_value == 5.0
|
||||
|
||||
|
||||
def _build_wrapped_properties_from_ui(blender_props) -> dict:
|
||||
"""Mirrors EditPset._execute()'s properties-building loop (operator.py)."""
|
||||
properties = {}
|
||||
for entry in blender_props.properties:
|
||||
metadata = entry.metadata
|
||||
value = metadata.get_value()
|
||||
if value is not None and subject.is_measurable_special_type(metadata.special_type):
|
||||
unit = tool.Ifc.get().by_id(metadata.unit_id) if metadata.unit_id else None
|
||||
value = {"NominalValue": value, "Unit": unit}
|
||||
properties[metadata.name] = value
|
||||
return properties
|
||||
|
||||
|
||||
class TestEditPsetWithUnitOverridePicker(NewFile):
|
||||
def test_picking_a_different_unit_converts_the_displayed_value_and_writes_it_back(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
|
||||
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0))
|
||||
pset.HasProperties = [prop]
|
||||
|
||||
obj = bpy.data.objects.new("Wall", None)
|
||||
tool.Ifc.link(element, obj)
|
||||
blender_props = obj.PsetProperties
|
||||
subject.import_pset_from_existing(pset, blender_props, None)
|
||||
|
||||
metadata = blender_props.properties["Foo"].metadata
|
||||
assert metadata.unit_id == 0
|
||||
assert metadata.float_value == 2500.0
|
||||
|
||||
# Simulate the user picking "m" in the unit picker dropdown.
|
||||
metadata.unit_id_enum = str(length_m.id())
|
||||
assert metadata.float_value == pytest.approx(2.5) # converted live, not just relabeled
|
||||
assert metadata.unit_id == length_m.id()
|
||||
|
||||
properties = _build_wrapped_properties_from_ui(blender_props)
|
||||
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
|
||||
|
||||
assert prop.NominalValue.wrappedValue == pytest.approx(2.5)
|
||||
assert prop.Unit == length_m
|
||||
|
||||
def test_picking_default_after_an_override_converts_back_and_clears_the_unit(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
|
||||
prop = ifc.createIfcPropertySingleValue(
|
||||
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5), Unit=length_m
|
||||
)
|
||||
pset.HasProperties = [prop]
|
||||
|
||||
obj = bpy.data.objects.new("Wall", None)
|
||||
tool.Ifc.link(element, obj)
|
||||
blender_props = obj.PsetProperties
|
||||
subject.import_pset_from_existing(pset, blender_props, None)
|
||||
|
||||
metadata = blender_props.properties["Foo"].metadata
|
||||
assert metadata.unit_id == length_m.id()
|
||||
assert metadata.float_value == 2.5
|
||||
|
||||
# Simulate picking "Default" (mm).
|
||||
metadata.unit_id_enum = "0"
|
||||
assert metadata.float_value == pytest.approx(2500.0)
|
||||
assert metadata.unit_id == 0
|
||||
|
||||
properties = _build_wrapped_properties_from_ui(blender_props)
|
||||
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
|
||||
|
||||
assert prop.Unit is None
|
||||
assert prop.NominalValue.wrappedValue == pytest.approx(2500.0)
|
||||
|
||||
def test_editing_an_unrelated_sibling_property_does_not_disturb_this_ones_override(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
|
||||
length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot")
|
||||
|
||||
element = ifc.createIfcWall()
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
|
||||
overridden_prop = ifc.createIfcPropertySingleValue(
|
||||
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(10.0), Unit=length_ft
|
||||
)
|
||||
untouched_prop = ifc.createIfcPropertySingleValue(Name="Bar", NominalValue=ifc.createIfcLengthMeasure(3.0))
|
||||
pset.HasProperties = [overridden_prop, untouched_prop]
|
||||
|
||||
obj = bpy.data.objects.new("Wall", None)
|
||||
tool.Ifc.link(element, obj)
|
||||
blender_props = obj.PsetProperties
|
||||
subject.import_pset_from_existing(pset, blender_props, None)
|
||||
|
||||
# Edit only "Bar", never touching "Foo"'s unit dropdown.
|
||||
blender_props.properties["Bar"].metadata.float_value = 4.0
|
||||
|
||||
properties = _build_wrapped_properties_from_ui(blender_props)
|
||||
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
|
||||
|
||||
assert overridden_prop.Unit == length_ft # untouched override survives
|
||||
assert overridden_prop.NominalValue.wrappedValue == 10.0
|
||||
assert untouched_prop.NominalValue.wrappedValue == 4.0
|
||||
|
||||
|
||||
class TestEditQtoRoundingLoopPreservesUnitWrappedValues(NewFile):
|
||||
def test_run(self):
|
||||
# Regression test for EditPset._execute()'s qto post-processing loop: it must reach
|
||||
# into {"Unit": ..., "NominalValue": ...}-wrapped values to round them, rather than
|
||||
# treating the whole dict as a bare float/int (which would zero it out).
|
||||
properties = {
|
||||
"Foo": {"NominalValue": 2.123456, "Unit": None},
|
||||
"Bar": 3,
|
||||
}
|
||||
for key, value in properties.items():
|
||||
if value is None:
|
||||
continue
|
||||
is_wrapped = isinstance(value, dict) and "Unit" in value
|
||||
raw = value["NominalValue"] if is_wrapped else value
|
||||
if raw is None:
|
||||
continue
|
||||
if isinstance(raw, float):
|
||||
raw = round(raw, 4)
|
||||
elif not isinstance(raw, int):
|
||||
raw = 0
|
||||
if is_wrapped:
|
||||
value["NominalValue"] = raw
|
||||
else:
|
||||
properties[key] = raw
|
||||
assert properties["Foo"]["NominalValue"] == 2.1235
|
||||
assert properties["Foo"]["Unit"] is None
|
||||
assert properties["Bar"] == 3
|
||||
|
||||
@@ -181,42 +181,6 @@ class TestGetCalculatedObjectQuantities(test.bim.bootstrap.NewFile):
|
||||
assert quantities["NetVolume"] == 282.517
|
||||
|
||||
|
||||
class TestGetTargetUnits(test.bim.bootstrap.NewFile):
|
||||
def setup_file(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Test")
|
||||
return ifc
|
||||
|
||||
def test_default_scene_state_returns_nothing(self):
|
||||
self.setup_file()
|
||||
assert subject.get_target_units() == {}
|
||||
|
||||
def test_setting_a_field_maps_it_to_its_measure_class(self):
|
||||
ifc = self.setup_file()
|
||||
metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
|
||||
millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[metre])
|
||||
|
||||
props = tool.Qto.get_qto_props()
|
||||
props.target_unit_length = str(millimetre.id())
|
||||
|
||||
assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
|
||||
|
||||
def test_untouched_fields_are_excluded(self):
|
||||
ifc = self.setup_file()
|
||||
metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
|
||||
millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
|
||||
gram = ifc.createIfcSIUnit(None, "MASSUNIT", None, "GRAM")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, units=[metre, gram])
|
||||
|
||||
props = tool.Qto.get_qto_props()
|
||||
props.target_unit_length = str(millimetre.id())
|
||||
props.target_unit_mass = "0" # explicitly left at "Default"
|
||||
|
||||
assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
|
||||
|
||||
|
||||
class TestGetBaseQto(test.bim.bootstrap.NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
|
||||
+4
-86
@@ -24,7 +24,7 @@ import os
|
||||
import types
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Literal, NamedTuple, Optional, Union, get_args
|
||||
from typing import Any, Literal, NamedTuple, Union, get_args
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
@@ -127,61 +127,8 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst
|
||||
return results
|
||||
|
||||
|
||||
def get_quantity_measures(rules: dict) -> dict[str, dict[str, str]]:
|
||||
"""Statically derive each quantity's measure class from the rule set that defines it,
|
||||
reading it straight from the calculator's own Function table (the same source the
|
||||
calculator itself used to compute the value) -- not guessed from the quantity name.
|
||||
|
||||
:param rules: A rule set as accepted by :func:`quantify`, e.g. from `ifc5d.qto.rules`.
|
||||
:return: `qto_name -> quantity_name -> measure class` (e.g. "IfcLengthMeasure"), matching
|
||||
the keys used by `SI2ProjectUnitConverter.project_units`.
|
||||
"""
|
||||
measures: dict[str, dict[str, str]] = {}
|
||||
for calculator_name, queries in rules.get("calculators", {}).items():
|
||||
calculator = calculators[calculator_name]
|
||||
for _entity_or_query, qtos in queries.items():
|
||||
for qto_name, quantities in qtos.items():
|
||||
for quantity_name, formula in quantities.items():
|
||||
if not formula:
|
||||
continue
|
||||
function = calculator.functions.get(formula)
|
||||
if function is None:
|
||||
continue
|
||||
measures.setdefault(qto_name, {})[quantity_name] = function.measure
|
||||
return measures
|
||||
|
||||
|
||||
def _reconvert(ifc_file: ifcopenshell.file, value: float, to_unit: ifcopenshell.entity_instance) -> float:
|
||||
"""Re-express `value` (as computed by `SI2ProjectUnitConverter` -- the project's default
|
||||
unit for its dimension, or, if the project has none, raw SI, mirroring `convert()`'s own
|
||||
fallback below) in `to_unit`, which shares `to_unit`'s dimension (`UnitType`).
|
||||
"""
|
||||
unit_type = getattr(to_unit, "UnitType", None)
|
||||
from_unit = ifcopenshell.util.unit.get_project_unit(ifc_file, unit_type) if unit_type else None
|
||||
from_scale = ifcopenshell.util.unit.get_unit_scale(from_unit) if from_unit else 1.0 # already SI
|
||||
return value * from_scale / ifcopenshell.util.unit.get_unit_scale(to_unit)
|
||||
|
||||
|
||||
def edit_qtos(
|
||||
ifc_file: ifcopenshell.file,
|
||||
results: ResultsDict,
|
||||
target_units: Optional[dict[str, ifcopenshell.entity_instance]] = None,
|
||||
rules: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""Apply quantification results as quantity sets.
|
||||
|
||||
:param target_units: Optional map of measure class (e.g. "IfcLengthMeasure", matching
|
||||
`SI2ProjectUnitConverter.project_units`'s keys) to a unit to express *newly created*
|
||||
quantities of that measure in, instead of the project default. Ignored unless `rules`
|
||||
is also given (needed to resolve each quantity's measure class -- see
|
||||
`get_quantity_measures`). Has no effect on quantities that already exist -- those are
|
||||
always re-expressed in whatever Unit they already carry (see below), regardless of
|
||||
`target_units`.
|
||||
:param rules: The rule set used to produce `results` (the same object passed to
|
||||
`quantify()`), used only to resolve `target_units` via `get_quantity_measures()`.
|
||||
"""
|
||||
quantity_measures = get_quantity_measures(rules) if (target_units and rules) else {}
|
||||
|
||||
def edit_qtos(ifc_file: ifcopenshell.file, results: ResultsDict) -> None:
|
||||
"""Apply quantification results as quantity sets."""
|
||||
for element, qtos in results.items():
|
||||
for name, quantities in qtos.items():
|
||||
qto = ifcopenshell.util.element.get_pset(element, name, should_inherit=False)
|
||||
@@ -189,36 +136,7 @@ def edit_qtos(
|
||||
qto = ifc_file.by_id(qto["id"])
|
||||
else:
|
||||
qto = ifcopenshell.api.pset.add_qto(ifc_file, element, name)
|
||||
|
||||
existing_by_name = {q.Name: q for q in (qto.Quantities or ())}
|
||||
wrapped_quantities: dict[str, Any] = {}
|
||||
|
||||
for quantity_name, value in quantities.items():
|
||||
existing_unit = getattr(existing_by_name.get(quantity_name), "Unit", None)
|
||||
|
||||
if existing_unit is not None:
|
||||
# A quantity that already carries its own Unit override must be
|
||||
# re-expressed in that unit, not overwritten with a value computed in
|
||||
# the project default while the stale Unit label stays put.
|
||||
wrapped_quantities[quantity_name] = {
|
||||
"NominalValue": _reconvert(ifc_file, value, existing_unit),
|
||||
"Unit": existing_unit,
|
||||
}
|
||||
continue
|
||||
|
||||
measure = quantity_measures.get(name, {}).get(quantity_name)
|
||||
target_unit = target_units.get(measure) if (target_units and measure) else None
|
||||
if target_unit is not None:
|
||||
# Brand new quantity, proactively expressed in the chosen target unit.
|
||||
wrapped_quantities[quantity_name] = {
|
||||
"NominalValue": _reconvert(ifc_file, value, target_unit),
|
||||
"Unit": target_unit,
|
||||
}
|
||||
continue
|
||||
|
||||
wrapped_quantities[quantity_name] = value # unchanged bare-float path
|
||||
|
||||
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=wrapped_quantities)
|
||||
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=quantities)
|
||||
|
||||
|
||||
class SI2ProjectUnitConverter:
|
||||
|
||||
@@ -22,7 +22,6 @@ import ifcopenshell
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
import pytest
|
||||
|
||||
import ifc5d.qto
|
||||
@@ -91,141 +90,3 @@ class TestOpeningQuantities:
|
||||
assert quantities["Depth"] == pytest.approx(0.3)
|
||||
assert quantities["Area"] == pytest.approx(0.5)
|
||||
assert quantities["Volume"] == pytest.approx(0.15)
|
||||
|
||||
|
||||
class TestGetQuantityMeasures:
|
||||
def test_resolves_measures_from_the_calculator_function_table(self):
|
||||
measures = ifc5d.qto.get_quantity_measures(ifc5d.qto.rules["IFC4X3QtoBaseQuantities"])
|
||||
assert measures["Qto_WallBaseQuantities"]["Length"] == "IfcLengthMeasure"
|
||||
assert measures["Qto_WallBaseQuantities"]["NetWeight"] == "IfcMassMeasure"
|
||||
|
||||
|
||||
class TestEditQtos:
|
||||
def setup_method(self):
|
||||
self.file = ifcopenshell.file(schema="IFC4X3")
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject", name="Test")
|
||||
self.wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
|
||||
def get_quantity(self, name: str) -> ifcopenshell.entity_instance:
|
||||
pset = ifcopenshell.util.element.get_pset(self.wall, "Qto_WallBaseQuantities", should_inherit=False)
|
||||
qto = self.file.by_id(pset["id"])
|
||||
return next(q for q in qto.Quantities if q.Name == name)
|
||||
|
||||
def test_new_quantity_with_no_target_unit_is_a_bare_value(self):
|
||||
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
|
||||
|
||||
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
|
||||
|
||||
quantity = self.get_quantity("Length")
|
||||
assert quantity.LengthValue == pytest.approx(5.0)
|
||||
assert quantity.Unit is None
|
||||
|
||||
def test_existing_manual_unit_override_is_reconverted_not_left_stale(self):
|
||||
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
|
||||
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
|
||||
|
||||
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
|
||||
quantity = self.get_quantity("Length")
|
||||
# Simulate a user picking a millimetre override via the per-property picker.
|
||||
quantity.Unit = millimetre
|
||||
quantity.LengthValue = 5000.0
|
||||
|
||||
# Re-running take-off recomputes the value in the project default (metres) again --
|
||||
# this must not leave the recomputed metres value mislabeled as millimetres.
|
||||
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 6.0}}})
|
||||
|
||||
quantity = self.get_quantity("Length")
|
||||
assert quantity.Unit == millimetre
|
||||
assert quantity.LengthValue == pytest.approx(6000.0)
|
||||
|
||||
def test_target_unit_applies_only_to_brand_new_quantities(self):
|
||||
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
|
||||
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
|
||||
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
|
||||
|
||||
ifc5d.qto.edit_qtos(
|
||||
self.file,
|
||||
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
|
||||
target_units={"IfcLengthMeasure": millimetre},
|
||||
rules=rules,
|
||||
)
|
||||
|
||||
quantity = self.get_quantity("Length")
|
||||
assert quantity.Unit == millimetre
|
||||
assert quantity.LengthValue == pytest.approx(5000.0)
|
||||
|
||||
def test_target_units_are_ignored_without_rules(self):
|
||||
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
|
||||
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
|
||||
|
||||
# `rules` is required to resolve a quantity's measure class -- without it, target_units
|
||||
# has nothing to key off, so brand new quantities fall back to today's bare-float path.
|
||||
ifc5d.qto.edit_qtos(
|
||||
self.file,
|
||||
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
|
||||
target_units={"IfcLengthMeasure": millimetre},
|
||||
)
|
||||
|
||||
quantity = self.get_quantity("Length")
|
||||
assert quantity.Unit is None
|
||||
assert quantity.LengthValue == pytest.approx(5.0)
|
||||
|
||||
def test_reconvert_treats_a_missing_project_default_as_raw_si(self):
|
||||
# No LENGTHUNIT is assigned to the project at all, so SI2ProjectUnitConverter.convert()
|
||||
# would have left the calculated value as raw SI (metres) -- _reconvert must match.
|
||||
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
|
||||
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
|
||||
|
||||
ifc5d.qto.edit_qtos(
|
||||
self.file,
|
||||
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
|
||||
target_units={"IfcLengthMeasure": millimetre},
|
||||
rules=rules,
|
||||
)
|
||||
|
||||
quantity = self.get_quantity("Length")
|
||||
assert quantity.LengthValue == pytest.approx(5000.0)
|
||||
|
||||
|
||||
class TestEditQtosIntegration:
|
||||
"""A real quantify() + edit_qtos() round trip, guarding against edit_qto's own
|
||||
class-inference disagreeing with get_quantity_measures()'s notion of measure.
|
||||
"""
|
||||
|
||||
def test_target_unit_produces_the_correct_quantity_class(self):
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
ifcopenshell.api.root.create_entity(file, ifc_class="IfcProject", name="Test")
|
||||
metre = file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
|
||||
sqm = file.createIfcSIUnit(None, "AREAUNIT", None, "SQUARE_METRE")
|
||||
cum = file.createIfcSIUnit(None, "VOLUMEUNIT", None, "CUBIC_METRE")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[metre, sqm, cum])
|
||||
model = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
body = ifcopenshell.api.context.add_context(
|
||||
file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model
|
||||
)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(file, ifc_class="IfcWall")
|
||||
wall.ObjectPlacement = file.createIfcLocalPlacement(
|
||||
None, file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
|
||||
)
|
||||
profile = file.createIfcRectangleProfileDef("AREA", None, None, 5.0, 0.2)
|
||||
position = file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
|
||||
solid = file.createIfcExtrudedAreaSolid(profile, position, file.createIfcDirection((0.0, 0.0, 1.0)), 3.0)
|
||||
rep = file.createIfcShapeRepresentation(body, "Body", "SweptSolid", [solid])
|
||||
wall.Representation = file.createIfcProductDefinitionShape(None, None, [rep])
|
||||
|
||||
millimetre = file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
|
||||
rules = ifc5d.qto.rules["IFC4X3QtoBaseQuantities"]
|
||||
results = ifc5d.qto.quantify(file, {wall}, rules)
|
||||
ifc5d.qto.edit_qtos(file, results, target_units={"IfcLengthMeasure": millimetre}, rules=rules)
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(wall, "Qto_WallBaseQuantities", should_inherit=False)
|
||||
qto = file.by_id(pset["id"])
|
||||
length = next(q for q in qto.Quantities if q.Name == "Length")
|
||||
assert length.is_a("IfcQuantityLength")
|
||||
assert length.Unit == millimetre
|
||||
assert length.LengthValue == pytest.approx(5000.0)
|
||||
|
||||
@@ -25,7 +25,7 @@ are automatically created and maintained.
|
||||
|
||||
Alignments are created with stationing referents. Each layout segment is assigned a position referent that informs about
|
||||
the start point of the segment. An example is the point of curvature of a horizontal circular curve. The referent is
|
||||
nested to the segment representing the circular arc and is named with the alignment name and an indicator of the position and the station, e.g. "MyAlignment 145+98.32 (P.C.)"
|
||||
nested to the segment representing the circular arc and is named with a indicator of the position and the station, e.g. "P.C. (145+98.32)"
|
||||
|
||||
This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance.
|
||||
|
||||
@@ -89,7 +89,6 @@ from .layout_vertical_alignment_by_pi_method import (
|
||||
layout_vertical_alignment_by_pi_method,
|
||||
)
|
||||
from .name_segments import name_segments
|
||||
from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags
|
||||
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
|
||||
@@ -133,7 +132,6 @@ __all__ = [
|
||||
"layout_vertical_alignment_by_pi_method",
|
||||
"name_segments",
|
||||
"register_referent_name_callback",
|
||||
"update_alignment_parameter_segment_tags",
|
||||
"update_end_point",
|
||||
"update_fallback_position",
|
||||
"update_key_point_referents",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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/>.
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.alignment
|
||||
|
||||
|
||||
def _get_key_point_tag(file: ifcopenshell.file, label: str, station: float) -> str:
|
||||
"""
|
||||
Builds the station-and-label text shared by update_alignment_parameter_segment_tags (used
|
||||
directly as IfcAlignmentParameterSegment.StartTag/EndTag) and update_key_point_referents (used,
|
||||
prefixed with the alignment name, as IfcReferent.Name): "<station> (<label>)", e.g.
|
||||
"145+98.32 (P.O.B.)".
|
||||
"""
|
||||
return f"{ifcopenshell.util.alignment.station_as_string(file, station)} ({label})"
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
# 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/>.
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
|
||||
|
||||
def update_alignment_parameter_segment_tags(
|
||||
file: ifcopenshell.file, layout: entity_instance, label_end_tag: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Sets IfcAlignmentParameterSegment.StartTag (and, optionally, EndTag) for every segment
|
||||
transition in an alignment layout. Unlike update_key_point_referents, this does not create any
|
||||
IfcReferent or IfcRelNests -- it only mutates the StartTag/EndTag string attributes already
|
||||
present on each segment's DesignParameters.
|
||||
|
||||
Every real segment's StartTag is set to a computed tag describing the point where it begins,
|
||||
using the same label-and-station format as update_key_point_referents' Name minus the alignment
|
||||
name (via _get_key_point_tag), e.g. "145+98.32 (P.C.)". The first segment's StartTag comes from
|
||||
the "Beginning of Alignment" boundary label.
|
||||
|
||||
EndTag is left untouched unless `label_end_tag` is True. When enabled, for each transition
|
||||
between two consecutive segments, the outgoing segment's EndTag is set to the same tag as the
|
||||
incoming segment's StartTag (they describe the same physical point), and the last segment's
|
||||
EndTag is set from the "End of Alignment" boundary label.
|
||||
|
||||
Labels come from _get_segment_start_point_label -- if a callback has been registered via
|
||||
register_referent_name_callback(), its output is used instead of the built-in labels, exactly as
|
||||
in update_key_point_referents.
|
||||
|
||||
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
|
||||
:param label_end_tag: if True, also sets EndTag on every real segment. If False (default),
|
||||
EndTag is left untouched.
|
||||
:return: None -- this function mutates segment.DesignParameters.StartTag/EndTag in place
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(model, horizontal)
|
||||
"""
|
||||
|
||||
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()}"
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
if alignment is None:
|
||||
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
|
||||
|
||||
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
|
||||
tag = _get_key_point_tag(file, label, station)
|
||||
|
||||
dp.StartTag = tag
|
||||
if prev_segment is not None and label_end_tag:
|
||||
prev_segment.DesignParameters.EndTag = tag
|
||||
|
||||
if is_horizontal:
|
||||
distance_along += dp.SegmentLength
|
||||
else:
|
||||
distance_along = dp.StartDistAlong + dp.HorizontalLength
|
||||
|
||||
prev_segment = segment
|
||||
|
||||
if label_end_tag:
|
||||
label = _get_segment_start_point_label(prev_segment, None)
|
||||
station = start_station + distance_along
|
||||
prev_segment.DesignParameters.EndTag = _get_key_point_tag(file, label, station)
|
||||
@@ -22,9 +22,9 @@ 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_key_point_tag import _get_key_point_tag
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
@@ -32,6 +32,21 @@ 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."""
|
||||
@@ -76,7 +91,7 @@ def _create_key_point_referent(
|
||||
),
|
||||
)
|
||||
|
||||
name = f"{alignment.Name} {_get_key_point_tag(file, label, station)}"
|
||||
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
|
||||
|
||||
referent = file.createIfcReferent(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
@@ -105,8 +120,7 @@ def update_key_point_referents(
|
||||
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.", ...), and combined with the alignment name and station to build the Name, e.g.
|
||||
"MyAlignment 145+98.32 (P.C.)". Different jurisdictions use
|
||||
"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
|
||||
@@ -115,11 +129,9 @@ def update_key_point_referents(
|
||||
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; its RelatingObject must be the
|
||||
IfcAlignment that nests `layout` (TypeError is raised otherwise). If omitted, a new
|
||||
IfcRelNests is always created and related to that IfcAlignment -- there is no implicit
|
||||
search for or reuse of a previously created nest. Callers who want to regenerate into an
|
||||
existing nest must pass it back in explicitly via `rel_nests`.
|
||||
: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.
|
||||
@@ -146,7 +158,7 @@ def update_key_point_referents(
|
||||
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 ends with "(Start)" instead of the default "(P.O.B.)"
|
||||
# nest.RelatedObjects[0].Name starts with "Start (" instead of the default "P.O.B. ("
|
||||
"""
|
||||
|
||||
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
|
||||
@@ -155,20 +167,12 @@ def update_key_point_referents(
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
if alignment is None:
|
||||
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
|
||||
|
||||
if rel_nests is not None:
|
||||
if not rel_nests.RelatingObject.is_a("IfcAlignment"):
|
||||
raise TypeError(
|
||||
f"Expected rel_nests.RelatingObject to be IfcAlignment, instead received "
|
||||
f"{rel_nests.RelatingObject.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=()
|
||||
)
|
||||
else:
|
||||
rel_nests = file.createIfcRelNests(
|
||||
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=()
|
||||
)
|
||||
|
||||
if clear:
|
||||
for referent in list(rel_nests.RelatedObjects):
|
||||
@@ -185,6 +189,7 @@ def update_key_point_referents(
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -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,422 @@
|
||||
# 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])
|
||||
|
||||
# ForceParallelToFace: project vertices 1…n onto the line through
|
||||
# pt[0] in the direction cross(face_normal, camera_dir), so the polyline
|
||||
# runs parallel to the face (perpendicular to the face normal).
|
||||
if pset_data.get("ForceParallelToFace") and len(resolved) >= 2 and resolved[0] is not None:
|
||||
face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
||||
if face_normal and camera_dir:
|
||||
fn, cd = face_normal, camera_dir
|
||||
tang = (
|
||||
fn[1] * cd[2] - fn[2] * cd[1],
|
||||
fn[2] * cd[0] - fn[0] * cd[2],
|
||||
fn[0] * cd[1] - fn[1] * cd[0],
|
||||
)
|
||||
mag = math.sqrt(tang[0] ** 2 + tang[1] ** 2 + tang[2] ** 2)
|
||||
if mag > 1e-12:
|
||||
tang = (tang[0] / mag, tang[1] / mag, tang[2] / mag)
|
||||
base = resolved[0]
|
||||
for i in range(1, len(resolved)):
|
||||
if resolved[i] is None:
|
||||
continue
|
||||
pt = resolved[i]
|
||||
t = ((pt[0] - base[0]) * tang[0]
|
||||
+ (pt[1] - base[1]) * tang[1]
|
||||
+ (pt[2] - base[2]) * tang[2])
|
||||
resolved[i] = (base[0] + t * tang[0],
|
||||
base[1] + t * tang[1],
|
||||
base[2] + t * tang[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
@@ -23,10 +23,6 @@ import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.pset
|
||||
|
||||
# Sentinel distinguishing "no Unit dict was passed at all" from "a Unit dict was passed
|
||||
# with Unit explicitly set to None" (i.e. explicitly clear an existing override).
|
||||
_NO_UNIT = object()
|
||||
|
||||
|
||||
def edit_pset(
|
||||
file: ifcopenshell.file,
|
||||
@@ -289,7 +285,7 @@ class Usecase:
|
||||
f'Value "{self.settings["properties"][prop.Name]}" is not a valid value for enum property {prop.Name}.'
|
||||
)
|
||||
|
||||
if unit is not _NO_UNIT:
|
||||
if unit:
|
||||
prop.Unit = unit
|
||||
del self.settings["properties"][prop.Name]
|
||||
return prop
|
||||
@@ -314,7 +310,7 @@ class Usecase:
|
||||
)
|
||||
value = self.cast_value_to_primary_measure_type(value, primary_measure_type)
|
||||
prop.NominalValue = self.file.create_entity(primary_measure_type, value)
|
||||
if unit is not _NO_UNIT:
|
||||
if unit:
|
||||
prop.Unit = unit
|
||||
del self.settings["properties"][prop.Name]
|
||||
return prop
|
||||
@@ -333,7 +329,7 @@ class Usecase:
|
||||
# If it's not an entity, then it's a primitive data type
|
||||
elif not value.is_entity():
|
||||
kwargs = {"Name": name, "NominalValue": value}
|
||||
if unit is not None and unit is not _NO_UNIT:
|
||||
if unit:
|
||||
kwargs["Unit"] = unit
|
||||
properties.append(self.file.create_entity("IfcPropertySingleValue", **kwargs))
|
||||
|
||||
@@ -357,7 +353,7 @@ class Usecase:
|
||||
"IfcPropertyListValue",
|
||||
Name=name,
|
||||
ListValues=[self.file.create_entity(ifc_class, v) for v in value],
|
||||
Unit=unit if (unit is not None and unit is not _NO_UNIT) else None,
|
||||
Unit=unit,
|
||||
)
|
||||
)
|
||||
break
|
||||
@@ -367,7 +363,7 @@ class Usecase:
|
||||
"IFCPROPERTYENUMERATION",
|
||||
Name=name,
|
||||
EnumerationValues=pset_template.Enumerators.EnumerationValues,
|
||||
**({"Unit": unit} if (unit is not None and unit is not _NO_UNIT) else {}),
|
||||
**({"Unit": unit} if unit else {}),
|
||||
)
|
||||
prop_enum_value = self.file.create_entity(
|
||||
"IFCPROPERTYENUMERATEDVALUE",
|
||||
@@ -393,7 +389,7 @@ class Usecase:
|
||||
value = self.cast_value_to_primary_measure_type(value, primary_measure_type)
|
||||
nominal_value = self.file.create_entity(primary_measure_type, value)
|
||||
args = {"Name": name, "NominalValue": nominal_value}
|
||||
if unit is not None and unit is not _NO_UNIT:
|
||||
if unit:
|
||||
args["Unit"] = unit
|
||||
|
||||
properties.append(self.file.create_entity("IfcPropertySingleValue", **args))
|
||||
@@ -483,16 +479,12 @@ class Usecase:
|
||||
def unpack_unit_value(value_candidate):
|
||||
"""
|
||||
Returns tuple of the format: (Unit, NominalValue)
|
||||
|
||||
NOTE: Unit is the module-level _NO_UNIT sentinel when no Unit was specified at all
|
||||
(bare value, or a dict without a "Unit" key), so that callers can distinguish "leave
|
||||
the existing Unit untouched" from an explicit `{"Unit": None, ...}` (clear the
|
||||
existing Unit override, falling back to the project default).
|
||||
NOTE: Unit fallbacks to None
|
||||
"""
|
||||
if value_candidate is None:
|
||||
return (None, None)
|
||||
|
||||
if isinstance(value_candidate, dict): # Custom IfcUnits can be passed in a dict along with the pset value
|
||||
return (value_candidate.get("Unit", _NO_UNIT), value_candidate["NominalValue"])
|
||||
return (value_candidate["Unit"], value_candidate["NominalValue"])
|
||||
|
||||
return (_NO_UNIT, value_candidate)
|
||||
return (None, value_candidate)
|
||||
|
||||
@@ -136,32 +136,10 @@ def edit_qto(
|
||||
return usecase.execute()
|
||||
|
||||
|
||||
# Sentinel distinguishing "no Unit dict was passed at all" from "a Unit dict was passed
|
||||
# with Unit explicitly set to None" (i.e. explicitly clear an existing override).
|
||||
_NO_UNIT = object()
|
||||
|
||||
|
||||
class Usecase:
|
||||
file: ifcopenshell.file
|
||||
settings: dict[str, Any]
|
||||
|
||||
@staticmethod
|
||||
def unpack_unit_value(value_candidate):
|
||||
"""
|
||||
Returns tuple of the format: (Unit, NominalValue)
|
||||
|
||||
NOTE: a dict value_candidate is ambiguous with the IfcPhysicalComplexQuantity spec
|
||||
convention ({"Discrimination": ..., "HasQuantities": ...}) used elsewhere in this
|
||||
module -- callers must check for that case (absence of a "Unit" key) before calling
|
||||
this. Unit is the module-level _NO_UNIT sentinel when no Unit was specified at all
|
||||
(bare value, or a dict without a "Unit" key), so that callers can distinguish "leave
|
||||
the existing Unit untouched" from an explicit `{"Unit": None, ...}` (clear the
|
||||
existing Unit override, falling back to the project default).
|
||||
"""
|
||||
if isinstance(value_candidate, dict) and "Unit" in value_candidate:
|
||||
return (value_candidate["Unit"], value_candidate["NominalValue"])
|
||||
return (_NO_UNIT, value_candidate)
|
||||
|
||||
def execute(self):
|
||||
self.qto_idx = 5
|
||||
if self.settings["qto"].is_a("IfcPhysicalComplexQuantity"):
|
||||
@@ -195,19 +173,16 @@ class Usecase:
|
||||
name = prop.Name
|
||||
if value is None:
|
||||
self.file.remove(prop)
|
||||
elif prop.is_a("IfcPhysicalComplexQuantity") and isinstance(value, dict) and "Unit" not in value:
|
||||
elif prop.is_a("IfcPhysicalComplexQuantity") and isinstance(value, dict):
|
||||
prop.Discrimination = value.get("Discrimination", prop.Discrimination)
|
||||
ifcopenshell.api.pset.edit_qto(self.file, qto=prop, properties=value["HasQuantities"])
|
||||
elif prop.is_a("IfcPhysicalSimpleQuantity"):
|
||||
unit, value = self.unpack_unit_value(value)
|
||||
value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value
|
||||
# 3 IfcPhysicalSimpleQuantity.XXXValue
|
||||
if self.file.schema == "IFC4X3" and prop.is_a("IfcQuantityCount"):
|
||||
prop[3] = int(value)
|
||||
else:
|
||||
prop[3] = float(value)
|
||||
if unit is not _NO_UNIT:
|
||||
prop.Unit = unit
|
||||
del self.settings["properties"][name]
|
||||
|
||||
def add_new_properties(self) -> list[ifcopenshell.entity_instance]:
|
||||
@@ -215,20 +190,21 @@ class Usecase:
|
||||
for name, value in self.settings["properties"].items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, dict) and "Unit" not in value:
|
||||
if isinstance(value, dict):
|
||||
complex_qto = self.file.create_entity(
|
||||
"IfcPhysicalComplexQuantity", Name=name, Discrimination=value["Discrimination"]
|
||||
)
|
||||
properties.append(complex_qto)
|
||||
ifcopenshell.api.pset.edit_qto(self.file, qto=complex_qto, properties=value["HasQuantities"])
|
||||
else:
|
||||
unit, value = self.unpack_unit_value(value)
|
||||
property_type = self.get_canonical_property_type(name, value)
|
||||
value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value
|
||||
kwargs = {"Name": name, "{}Value".format(property_type): value}
|
||||
if unit is not None and unit is not _NO_UNIT:
|
||||
kwargs["Unit"] = unit
|
||||
properties.append(self.file.create_entity("IfcQuantity{}".format(property_type), **kwargs))
|
||||
properties.append(
|
||||
self.file.create_entity(
|
||||
"IfcQuantity{}".format(property_type),
|
||||
**{"Name": name, "{}Value".format(property_type): value},
|
||||
)
|
||||
)
|
||||
return properties
|
||||
|
||||
def extend_qto_with_new_properties(self, new_properties: list[ifcopenshell.entity_instance]) -> None:
|
||||
|
||||
@@ -406,51 +406,6 @@ def get_named_dimensions(name):
|
||||
return named_dimensions.get(name, (0, 0, 0, 0, 0, 0, 0))
|
||||
|
||||
|
||||
def get_unit_dimensions(unit: ifcopenshell.entity_instance) -> tuple[int, int, int, int, int, int, int]:
|
||||
"""Get the dimensional exponents of a unit, per IfcDimensionalExponents.
|
||||
|
||||
Supports IfcSIUnit, IfcConversionBasedUnit, IfcContextDependentUnit, and
|
||||
IfcDerivedUnit (composed recursively from its elements).
|
||||
|
||||
:param unit: The unit to inspect.
|
||||
:return: A 7-tuple of (Length, Mass, Time, ElectricCurrent,
|
||||
ThermodynamicTemperature, AmountOfSubstance, LuminousIntensity).
|
||||
"""
|
||||
if unit.is_a("IfcDerivedUnit"):
|
||||
dimensions = [0, 0, 0, 0, 0, 0, 0]
|
||||
for element in unit.Elements:
|
||||
element_dimensions = get_unit_dimensions(element.Unit)
|
||||
for i in range(7):
|
||||
dimensions[i] += element_dimensions[i] * element.Exponent
|
||||
return tuple(dimensions)
|
||||
if unit.is_a("IfcSIUnit"):
|
||||
return get_si_dimensions(unit.Name.replace("METER", "METRE"))
|
||||
return get_named_dimensions(getattr(unit, "UnitType", None))
|
||||
|
||||
|
||||
def identify_unit_dimensions(unit: ifcopenshell.entity_instance) -> Union[str, None]:
|
||||
"""Identify which named IfcUnitEnum type a unit's dimensions correspond to.
|
||||
|
||||
This is mainly useful for an IfcDerivedUnit that has no named
|
||||
IfcDerivedUnitEnum match for its measure type, allowing it to still be
|
||||
recognised as, e.g., a pressure unit by dimensional analysis alone.
|
||||
|
||||
Note that dimensionless quantities (e.g. plane angle, solid angle, or a
|
||||
genuinely unitless value) are dimensionally indistinguishable, so this
|
||||
heuristically returns the first zero-dimension match rather than
|
||||
disambiguating them.
|
||||
|
||||
:param unit: The unit to identify.
|
||||
:return: An uppercase IfcUnitEnum value, or None if no named type shares
|
||||
the same dimensions.
|
||||
"""
|
||||
dimensions = get_unit_dimensions(unit)
|
||||
for name, named in named_dimensions.items():
|
||||
if named == dimensions:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def get_unit_assignment(ifc_file: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None]:
|
||||
return ifc_file.by_type("IfcProject")[0].UnitsInContext
|
||||
|
||||
@@ -466,16 +421,7 @@ def cache_units(ifc_file: ifcopenshell.file) -> None:
|
||||
"""
|
||||
ifc_file.units = {}
|
||||
if assignment := get_unit_assignment(ifc_file):
|
||||
all_units = assignment.Units or []
|
||||
units = {u.UnitType: u for u in all_units if getattr(u, "UnitType", None)}
|
||||
# As in get_project_unit(): a literal match always wins; an IfcDerivedUnit with no
|
||||
# literal UnitType match is matched dimensionally instead, only to fill a gap.
|
||||
for unit in all_units:
|
||||
if unit.is_a("IfcDerivedUnit"):
|
||||
dimension = identify_unit_dimensions(unit)
|
||||
if dimension and dimension not in units:
|
||||
units[dimension] = unit
|
||||
ifc_file.units = units
|
||||
ifc_file.units = {u.UnitType: u for u in assignment.Units if getattr(u, "UnitType", None)}
|
||||
|
||||
|
||||
def clear_unit_cache(ifc_file: ifcopenshell.file) -> None:
|
||||
@@ -491,10 +437,6 @@ def get_project_unit(
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Get the default project unit of a particular unit type
|
||||
|
||||
IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a fallback, by
|
||||
dimensional analysis (:func:`identify_unit_dimensions`), mirroring
|
||||
:func:`get_candidate_units`.
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param unit_type: The type of unit, taken from the list of IFC unit types,
|
||||
such as "LENGTHUNIT".
|
||||
@@ -506,39 +448,9 @@ def get_project_unit(
|
||||
if units := ifc_file.units:
|
||||
return units.get(unit_type, None)
|
||||
if unit_assignment := get_unit_assignment(ifc_file):
|
||||
dimensional_match = None
|
||||
for unit in unit_assignment.Units or []:
|
||||
if getattr(unit, "UnitType", None) == unit_type:
|
||||
return unit
|
||||
if dimensional_match is None and unit.is_a("IfcDerivedUnit") and identify_unit_dimensions(unit) == unit_type:
|
||||
dimensional_match = unit
|
||||
return dimensional_match
|
||||
|
||||
|
||||
def get_candidate_units(ifc_file: ifcopenshell.file, unit_type: str) -> list[ifcopenshell.entity_instance]:
|
||||
"""Get all units in the file usable as an override for `unit_type`.
|
||||
|
||||
Unlike :func:`get_project_unit`, this returns every matching unit defined
|
||||
in the file (e.g. both an mm and an m IfcSIUnit might be present), not
|
||||
just the one currently assigned as the project default.
|
||||
|
||||
IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a
|
||||
fallback, by dimensional analysis (:func:`identify_unit_dimensions`) --
|
||||
that fallback only helps for the dimension families covered by
|
||||
`named_dimensions` (the core `IfcUnitEnum` types); it won't match e.g.
|
||||
`"MODULUSOFELASTICITYUNIT"` by dimension alone, only by literal `UnitType`.
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param unit_type: The type of unit, taken from the list of IFC unit
|
||||
types, such as "LENGTHUNIT", or an IfcDerivedUnitEnum value such as
|
||||
"MODULUSOFELASTICITYUNIT".
|
||||
:return: All matching IfcNamedUnit / IfcDerivedUnit entities in the file.
|
||||
"""
|
||||
candidates = [u for u in ifc_file.by_type("IfcNamedUnit") if getattr(u, "UnitType", None) == unit_type]
|
||||
for unit in ifc_file.by_type("IfcDerivedUnit"):
|
||||
if getattr(unit, "UnitType", None) == unit_type or identify_unit_dimensions(unit) == unit_type:
|
||||
candidates.append(unit)
|
||||
return candidates
|
||||
|
||||
|
||||
def get_property_unit(
|
||||
@@ -568,8 +480,7 @@ def get_property_unit(
|
||||
entity = prop.wrapped_data.declaration().as_entity()
|
||||
measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name()
|
||||
elif prop.is_a("IfcPropertySingleValue"):
|
||||
if value := prop.NominalValue:
|
||||
measure_class = value.is_a()
|
||||
measure_class = prop.NominalValue.is_a()
|
||||
elif prop.is_a("IfcPropertyEnumeratedValue"):
|
||||
if prop.EnumerationReference:
|
||||
if unit := prop.EnumerationReference.Unit:
|
||||
@@ -711,8 +622,6 @@ def get_symbol_quantity_class(symbol: Optional[str] = None) -> QUANTITY_CLASS:
|
||||
|
||||
|
||||
def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
|
||||
if unit.is_a("IfcDerivedUnit"):
|
||||
return get_derived_unit_symbol(unit)
|
||||
symbol: str = ""
|
||||
if unit.is_a("IfcSIUnit"):
|
||||
symbol += prefix_symbols.get(unit.Prefix, "")
|
||||
@@ -722,28 +631,6 @@ def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
|
||||
return symbol
|
||||
|
||||
|
||||
def get_derived_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
|
||||
"""Compose a unit symbol for an IfcDerivedUnit from its elements.
|
||||
|
||||
E.g. a derived unit of NEWTON / SQUARE_METRE composes to "N/m2".
|
||||
|
||||
:param unit: The IfcDerivedUnit.
|
||||
:return: The composed symbol.
|
||||
"""
|
||||
numerator = []
|
||||
denominator = []
|
||||
for element in unit.Elements:
|
||||
symbol = get_unit_symbol(element.Unit)
|
||||
exponent = abs(element.Exponent)
|
||||
if exponent != 1:
|
||||
symbol += str(exponent)
|
||||
(numerator if element.Exponent > 0 else denominator).append(symbol)
|
||||
result = ".".join(numerator) or "1"
|
||||
if denominator:
|
||||
result += "/" + ".".join(denominator)
|
||||
return result
|
||||
|
||||
|
||||
def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit: ifcopenshell.entity_instance) -> float:
|
||||
"""Convert from one unit to another unit
|
||||
|
||||
@@ -797,70 +684,6 @@ def convert(value: float, from_prefix: Optional[str], from_unit: str, to_prefix:
|
||||
return value
|
||||
|
||||
|
||||
def get_named_unit_scale(unit: ifcopenshell.entity_instance) -> float:
|
||||
"""Get the scale factor to convert a value in a named unit to SI units.
|
||||
|
||||
Supports IfcSIUnit and IfcConversionBasedUnit (including chains of
|
||||
conversion-based units). Does not support IfcDerivedUnit -- see
|
||||
:func:`get_derived_unit_scale` for that.
|
||||
|
||||
:param unit: The IfcNamedUnit.
|
||||
:returns: The scale factor.
|
||||
"""
|
||||
scale = 1.0
|
||||
while unit.is_a("IfcConversionBasedUnit"):
|
||||
conversion_factor = unit.ConversionFactor
|
||||
scale *= conversion_factor.ValueComponent.wrappedValue
|
||||
unit = conversion_factor.UnitComponent
|
||||
if unit.is_a("IfcSIUnit"):
|
||||
prefix_multiplier = get_prefix_multiplier(unit.Prefix)
|
||||
# An SI prefix attaches to the base unit symbol, and the prefixed
|
||||
# symbol is raised to the power as a whole: dm3 = (dm)3 = 1e-3 m3,
|
||||
# not 0.1 m3. For units whose dimensions are a pure power of length
|
||||
# (METRE, SQUARE_METRE, CUBIC_METRE) the prefix multiplier must
|
||||
# therefore be raised to the length exponent. Units with mixed or
|
||||
# non-length dimensions (PASCAL, NEWTON, GRAM, ...) keep the linear
|
||||
# multiplier, as there the prefix scales the derived unit itself.
|
||||
# https://github.com/IfcOpenShell/IfcOpenShell/issues/9278
|
||||
#
|
||||
# Dimensions is looked up from si_dimensions by name rather than via
|
||||
# unit.Dimensions (the schema-derived IfcDimensionalExponents), since
|
||||
# the derived attribute isn't computed for SQLite-linked files and
|
||||
# would return None there.
|
||||
length_exponent, *other_exponents = get_si_dimensions(unit.Name.replace("METER", "METRE"))
|
||||
if length_exponent > 0 and not any(other_exponents):
|
||||
prefix_multiplier **= length_exponent
|
||||
scale *= prefix_multiplier
|
||||
return scale
|
||||
|
||||
|
||||
def get_derived_unit_scale(unit: ifcopenshell.entity_instance) -> float:
|
||||
"""Get the scale factor to convert a value in an IfcDerivedUnit to SI units.
|
||||
|
||||
:param unit: The IfcDerivedUnit.
|
||||
:returns: The scale factor.
|
||||
"""
|
||||
scale = 1.0
|
||||
for element in unit.Elements:
|
||||
scale *= get_named_unit_scale(element.Unit) ** element.Exponent
|
||||
return scale
|
||||
|
||||
|
||||
def get_unit_scale(unit: ifcopenshell.entity_instance) -> float:
|
||||
"""Get the scale factor to convert a value in `unit` to SI units.
|
||||
|
||||
Dispatches to :func:`get_derived_unit_scale` for IfcDerivedUnit, or
|
||||
:func:`get_named_unit_scale` otherwise (IfcSIUnit / IfcConversionBasedUnit,
|
||||
including chains).
|
||||
|
||||
:param unit: The unit to get the scale factor for.
|
||||
:returns: The scale factor.
|
||||
"""
|
||||
if unit.is_a("IfcDerivedUnit"):
|
||||
return get_derived_unit_scale(unit)
|
||||
return get_named_unit_scale(unit)
|
||||
|
||||
|
||||
def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUNIT") -> float:
|
||||
"""Returns a unit scale factor to convert to and from IFC project units and SI units.
|
||||
|
||||
@@ -872,17 +695,17 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
|
||||
si_meters / unit_scale = ifc_project_length
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param unit_type: The type of SI unit, defaults to "LENGTHUNIT". This may
|
||||
also be an IfcDerivedUnitEnum value (e.g. "MASSDENSITYUNIT") to
|
||||
support project units defined as an IfcDerivedUnit.
|
||||
:param unit_type: The type of SI unit, defaults to "LENGTHUNIT"
|
||||
:returns: The scale factor
|
||||
"""
|
||||
if type(ifc_file) is ifcopenshell.file and unit_type:
|
||||
schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
|
||||
valid_types = set(schema.declaration_by_name("IfcUnitEnum").enumeration_items())
|
||||
valid_types |= set(schema.declaration_by_name("IfcDerivedUnitEnum").enumeration_items())
|
||||
if unit_type not in valid_types:
|
||||
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
|
||||
if (
|
||||
type(ifc_file) is ifcopenshell.file
|
||||
and unit_type
|
||||
not in ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
|
||||
.declaration_by_name("IfcUnitEnum")
|
||||
.enumeration_items()
|
||||
):
|
||||
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
|
||||
|
||||
# Currently we assume that all ifc projects must have IfcProject.
|
||||
if not (projects := ifc_file.by_type("IfcProject")) or not (units := projects[0].UnitsInContext):
|
||||
@@ -892,7 +715,12 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
|
||||
for unit in units.Units:
|
||||
if getattr(unit, "UnitType", ...) != unit_type:
|
||||
continue
|
||||
unit_scale *= get_unit_scale(unit)
|
||||
while unit.is_a("IfcConversionBasedUnit"):
|
||||
conversion_factor = unit.ConversionFactor
|
||||
unit_scale *= conversion_factor.ValueComponent.wrappedValue
|
||||
unit = conversion_factor.UnitComponent
|
||||
if unit.is_a("IfcSIUnit"):
|
||||
unit_scale *= get_prefix_multiplier(unit.Prefix)
|
||||
return unit_scale
|
||||
|
||||
|
||||
|
||||
@@ -96,10 +96,6 @@ def callback_alignment():
|
||||
yield alignment
|
||||
|
||||
|
||||
def _label(name):
|
||||
return name.rsplit("(", 1)[1].rstrip(")")
|
||||
|
||||
|
||||
def test_with_default_names(default_names_alignment):
|
||||
file = default_names_alignment.file
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
|
||||
@@ -111,8 +107,8 @@ def test_with_default_names(default_names_alignment):
|
||||
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 [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
|
||||
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
|
||||
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):
|
||||
@@ -126,7 +122,7 @@ def test_with_callbacks(callback_alignment):
|
||||
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
|
||||
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
|
||||
|
||||
assert [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
|
||||
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
|
||||
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
|
||||
|
||||
-307
@@ -1,307 +0,0 @@
|
||||
# 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/>.
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.alignment
|
||||
|
||||
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 _real_segments(layout):
|
||||
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
|
||||
return segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(layout) else segments
|
||||
|
||||
|
||||
def _label(tag):
|
||||
return tag.rsplit("(", 1)[1].rstrip(")")
|
||||
|
||||
|
||||
def test_wrong_layout_type_raises_type_error():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
with pytest.raises(TypeError):
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, alignment)
|
||||
|
||||
|
||||
def test_not_nested_under_alignment_raises_value_error():
|
||||
file = _new_file_no_context()
|
||||
horizontal = file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new())
|
||||
with pytest.raises(ValueError):
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
|
||||
|
||||
|
||||
def test_returns_none():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_no_referents_or_rel_nests_created():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
referents_before = len(file.by_type("IfcReferent"))
|
||||
rel_nests_before = len(file.by_type("IfcRelNests"))
|
||||
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
|
||||
|
||||
assert len(file.by_type("IfcReferent")) == referents_before
|
||||
assert len(file.by_type("IfcRelNests")) == rel_nests_before
|
||||
|
||||
|
||||
def test_no_real_segments_leaves_tags_none():
|
||||
file = _new_file_no_context()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
|
||||
|
||||
assert result is None
|
||||
segments = ifcopenshell.api.alignment.get_layout_segments(horizontal)
|
||||
assert len(segments) == 1 # only the auto zero-length segment
|
||||
assert segments[0].DesignParameters.StartTag is None
|
||||
assert segments[0].DesignParameters.EndTag is None
|
||||
|
||||
|
||||
def test_single_real_segment_produces_only_boundary_tags():
|
||||
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)
|
||||
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal, label_end_tag=True)
|
||||
|
||||
segments = _real_segments(horizontal)
|
||||
assert len(segments) == 1
|
||||
dp = segments[0].DesignParameters
|
||||
assert _label(dp.StartTag) == "P.O.B."
|
||||
assert _label(dp.EndTag) == "P.O.E."
|
||||
|
||||
|
||||
def test_end_tag_not_labelled_by_default():
|
||||
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)
|
||||
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
|
||||
|
||||
segments = _real_segments(horizontal)
|
||||
assert len(segments) == 1
|
||||
dp = segments[0].DesignParameters
|
||||
assert _label(dp.StartTag) == "P.O.B."
|
||||
assert dp.EndTag is None
|
||||
|
||||
|
||||
def test_horizontal_tag_labels_and_adjacency():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal, label_end_tag=True)
|
||||
|
||||
segments = _real_segments(horizontal)
|
||||
assert len(segments) == 7
|
||||
|
||||
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
|
||||
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
|
||||
|
||||
assert start_labels == ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T."]
|
||||
assert end_labels == ["P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
|
||||
|
||||
# every real segment has both tags set
|
||||
assert all(s.DesignParameters.StartTag is not None for s in segments)
|
||||
assert all(s.DesignParameters.EndTag is not None for s in segments)
|
||||
|
||||
# adjacent segments agree on the tag describing their shared transition point
|
||||
for i in range(len(segments) - 1):
|
||||
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
|
||||
|
||||
|
||||
def test_vertical_tag_labels_and_adjacency():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
|
||||
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, vertical, label_end_tag=True)
|
||||
|
||||
segments = _real_segments(vertical)
|
||||
assert len(segments) == 9
|
||||
|
||||
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
|
||||
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
|
||||
|
||||
assert start_labels == [
|
||||
"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.",
|
||||
]
|
||||
assert end_labels == [
|
||||
"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 all(s.DesignParameters.StartTag is not None for s in segments)
|
||||
assert all(s.DesignParameters.EndTag is not None for s in segments)
|
||||
|
||||
for i in range(len(segments) - 1):
|
||||
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
|
||||
|
||||
|
||||
def test_cant_layout_boundary_tags():
|
||||
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)
|
||||
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, cant, label_end_tag=True)
|
||||
|
||||
segments = _real_segments(cant)
|
||||
assert _label(segments[0].DesignParameters.StartTag) == "C.P.O.B."
|
||||
assert _label(segments[-1].DesignParameters.EndTag) == "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 _label(segments[0].DesignParameters.EndTag) == "xx"
|
||||
assert _label(segments[-1].DesignParameters.StartTag) == "xx"
|
||||
|
||||
|
||||
def test_exact_tag_format():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
|
||||
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
segments = _real_segments(horizontal)
|
||||
assert segments[0].DesignParameters.StartTag == (
|
||||
f"{ifcopenshell.util.alignment.station_as_string(file, start_station)} (P.O.B.)"
|
||||
)
|
||||
|
||||
|
||||
test_wrong_layout_type_raises_type_error()
|
||||
test_not_nested_under_alignment_raises_value_error()
|
||||
test_returns_none()
|
||||
test_no_referents_or_rel_nests_created()
|
||||
test_no_real_segments_leaves_tags_none()
|
||||
test_single_real_segment_produces_only_boundary_tags()
|
||||
test_end_tag_not_labelled_by_default()
|
||||
test_horizontal_tag_labels_and_adjacency()
|
||||
test_vertical_tag_labels_and_adjacency()
|
||||
test_cant_layout_boundary_tags()
|
||||
test_exact_tag_format()
|
||||
@@ -66,10 +66,6 @@ def _pset_station(referent):
|
||||
return ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station")
|
||||
|
||||
|
||||
def _label(name):
|
||||
return name.rsplit("(", 1)[1].rstrip(")")
|
||||
|
||||
|
||||
def test_wrong_layout_type_raises_type_error():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
@@ -86,13 +82,13 @@ def test_default_rel_nests_created_when_none_provided():
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
assert nest.is_a("IfcRelNests")
|
||||
assert nest.RelatingObject == alignment
|
||||
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_creates_separate_nest():
|
||||
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)
|
||||
@@ -101,23 +97,10 @@ def test_second_call_without_rel_nests_creates_separate_nest():
|
||||
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(nest1.RelatedObjects) == 8
|
||||
assert len(nest2.RelatedObjects) == 8
|
||||
segment_count_after = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
|
||||
assert segment_count_after == segment_count_before
|
||||
|
||||
|
||||
def test_passing_previous_nest_back_in_accumulates():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest1)
|
||||
|
||||
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():
|
||||
@@ -125,7 +108,7 @@ def test_provided_rel_nests_is_used_as_is():
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
# rel_nests.RelatingObject must be the IfcAlignment that nests `layout`
|
||||
# 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)
|
||||
@@ -135,17 +118,6 @@ def test_provided_rel_nests_is_used_as_is():
|
||||
assert len(result.RelatedObjects) == 8
|
||||
|
||||
|
||||
def test_provided_rel_nests_with_wrong_relating_object_raises_type_error():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=horizontal, RelatedObjects=())
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests)
|
||||
|
||||
|
||||
def test_clear_true_removes_old_referents_and_psets():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
@@ -185,7 +157,7 @@ def test_default_horizontal_labels_and_order():
|
||||
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 [_label(r.Name) for r in nest.RelatedObjects] == expected
|
||||
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
|
||||
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == sorted(stations)
|
||||
@@ -211,7 +183,7 @@ def test_default_vertical_labels_and_order():
|
||||
"P.V.T.",
|
||||
"V.P.O.E.",
|
||||
]
|
||||
assert [_label(r.Name) for r in nest.RelatedObjects] == expected
|
||||
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
|
||||
@@ -228,7 +200,7 @@ def test_name_format():
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
referent = nest.RelatedObjects[0]
|
||||
station = _pset_station(referent)
|
||||
assert referent.Name == f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, station)} (P.O.B.)"
|
||||
assert referent.Name == f"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
|
||||
|
||||
|
||||
def test_geometric_placement_when_layout_has_representation():
|
||||
@@ -296,7 +268,7 @@ def test_cant_layout_boundary_labels():
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
|
||||
|
||||
labels = [_label(r.Name) for r in nest.RelatedObjects]
|
||||
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
|
||||
@@ -335,7 +307,7 @@ def test_single_real_segment_produces_only_boundary_labels():
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
labels = [_label(r.Name) for r in nest.RelatedObjects]
|
||||
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
|
||||
assert labels == ["P.O.B.", "P.O.E."]
|
||||
|
||||
|
||||
@@ -384,10 +356,8 @@ def test_returns_ifc_rel_nests():
|
||||
|
||||
test_wrong_layout_type_raises_type_error()
|
||||
test_default_rel_nests_created_when_none_provided()
|
||||
test_second_call_without_rel_nests_creates_separate_nest()
|
||||
test_passing_previous_nest_back_in_accumulates()
|
||||
test_second_call_without_rel_nests_reuses_existing_nest()
|
||||
test_provided_rel_nests_is_used_as_is()
|
||||
test_provided_rel_nests_with_wrong_relating_object_raises_type_error()
|
||||
test_clear_true_removes_old_referents_and_psets()
|
||||
test_clear_false_appends_without_dedup()
|
||||
test_default_horizontal_labels_and_order()
|
||||
|
||||
@@ -188,36 +188,6 @@ class TestEditPsetIFC2X3(test.bootstrap.IFC2X3):
|
||||
assert unit.Prefix == "GIGA"
|
||||
assert unit.Name == "PASCAL"
|
||||
|
||||
def test_explicitly_clearing_a_propertys_unit_override(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
|
||||
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
|
||||
)
|
||||
prop = pset.HasProperties[0]
|
||||
assert prop.Unit == custom_unit
|
||||
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 40.0, "Unit": None}}
|
||||
)
|
||||
assert prop.Unit is None
|
||||
assert prop.NominalValue.wrappedValue == 40.0
|
||||
|
||||
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
|
||||
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
|
||||
)
|
||||
prop = pset.HasProperties[0]
|
||||
assert prop.Unit == custom_unit
|
||||
|
||||
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"MyCustom": 42.0})
|
||||
assert prop.Unit == custom_unit
|
||||
assert prop.NominalValue.wrappedValue == 42.0
|
||||
|
||||
def test_editing_properties_of_non_rooted_elements(self):
|
||||
element = self.file.createIfcMaterial()
|
||||
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.unit
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
@@ -151,87 +150,3 @@ class TestEditQto(test.bootstrap.IFC4):
|
||||
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
|
||||
assert qto.Quantities[0].Name == "MyLength"
|
||||
assert qto.Quantities[0].LengthValue == 34
|
||||
|
||||
def test_adding_a_new_quantity_with_a_custom_unit(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
|
||||
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
|
||||
)
|
||||
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
|
||||
assert qto.Quantities[0].Name == "MyLength"
|
||||
assert qto.Quantities[0].LengthValue == 30.0
|
||||
assert qto.Quantities[0].Unit == custom_unit
|
||||
|
||||
def test_editing_an_existing_quantitys_unit(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
|
||||
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
|
||||
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 12.0})
|
||||
quantity = qto.Quantities[0]
|
||||
assert quantity.Unit is None
|
||||
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
|
||||
)
|
||||
assert quantity.Unit == custom_unit
|
||||
assert quantity.LengthValue == 30.0
|
||||
|
||||
def test_explicitly_clearing_a_quantitys_unit_override(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
|
||||
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
|
||||
)
|
||||
quantity = qto.Quantities[0]
|
||||
assert quantity.Unit == custom_unit
|
||||
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file, qto=qto, properties={"MyLength": {"NominalValue": 40.0, "Unit": None}}
|
||||
)
|
||||
assert quantity.Unit is None
|
||||
assert quantity.LengthValue == 40.0
|
||||
|
||||
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
|
||||
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
|
||||
)
|
||||
quantity = qto.Quantities[0]
|
||||
assert quantity.Unit == custom_unit
|
||||
|
||||
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 42.0})
|
||||
assert quantity.Unit == custom_unit
|
||||
assert quantity.LengthValue == 42.0
|
||||
|
||||
def test_complex_quantity_editing_is_unaffected_by_the_unit_wrapper_convention(self):
|
||||
# Regression guard: dict values are already overloaded to mean "this is an
|
||||
# IfcPhysicalComplexQuantity spec" ({"Discrimination": ..., "HasQuantities": ...}).
|
||||
# The new {"Unit": ..., "NominalValue": ...} convention must not be confused with it.
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file,
|
||||
qto=qto,
|
||||
properties={"Layers": {"Discrimination": "layer", "HasQuantities": {"Width": 5.0}}},
|
||||
)
|
||||
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
|
||||
complex_qty = qto.Quantities[0]
|
||||
assert complex_qty.is_a("IfcPhysicalComplexQuantity")
|
||||
assert complex_qty.Name == "Layers"
|
||||
assert complex_qty.Discrimination == "layer"
|
||||
assert complex_qty.HasQuantities[0].Name == "Width"
|
||||
assert complex_qty.HasQuantities[0].LengthValue == 5.0
|
||||
|
||||
# Editing it again (update_existing_property's complex-quantity branch) still works too.
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
self.file,
|
||||
qto=qto,
|
||||
properties={"Layers": {"Discrimination": "layer2", "HasQuantities": {"Width": 6.0}}},
|
||||
)
|
||||
assert complex_qty.Discrimination == "layer2"
|
||||
assert complex_qty.HasQuantities[0].LengthValue == 6.0
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import tempfile
|
||||
from math import pi
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -31,10 +29,8 @@ import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.geolocation
|
||||
import ifcopenshell.util.unit as subject
|
||||
import ifcpatch
|
||||
import test.bootstrap
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
from ifcpatch.recipes import Ifc2Sql
|
||||
|
||||
|
||||
class TestMmToM:
|
||||
@@ -94,80 +90,6 @@ class TestGetProjectUnit(test.bootstrap.IFC4):
|
||||
assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length2
|
||||
assert self.file.units == {"LENGTHUNIT": length2, "AREAUNIT": area}
|
||||
|
||||
def test_area_and_volume_derived_from_length_are_matched_dimensionally(self):
|
||||
# AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project whose area/volume
|
||||
# default is an IfcDerivedUnit has no literal UnitType match for either -- get_project_unit
|
||||
# must still resolve them by dimensional analysis, like get_candidate_units already does.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
|
||||
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
|
||||
volume = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "volume-ish", {length: 3})
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area, volume])
|
||||
|
||||
assert subject.get_project_unit(self.file, "AREAUNIT") == area
|
||||
assert subject.get_project_unit(self.file, "VOLUMEUNIT") == volume
|
||||
|
||||
def test_literal_unit_type_match_takes_priority_over_dimensional(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
|
||||
literal_area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
|
||||
derived_area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[length, literal_area, derived_area])
|
||||
|
||||
assert subject.get_project_unit(self.file, "AREAUNIT") == literal_area
|
||||
|
||||
def test_dimensional_fallback_also_applies_when_using_a_cache(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
|
||||
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
|
||||
|
||||
assert subject.get_project_unit(self.file, "AREAUNIT", use_cache=True) == area
|
||||
assert self.file.units["AREAUNIT"] == area
|
||||
|
||||
|
||||
class TestGetCandidateUnits(test.bootstrap.IFC4):
|
||||
def test_returns_only_units_matching_the_unit_type(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
|
||||
candidates = subject.get_candidate_units(self.file, "LENGTHUNIT")
|
||||
assert set(candidates) == {mm, m}
|
||||
assert area not in candidates
|
||||
|
||||
def test_returns_all_matching_units_not_just_the_assigned_default(self):
|
||||
# Unlike get_project_unit, which only returns the one assigned default.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[mm])
|
||||
assert subject.get_project_unit(self.file, "LENGTHUNIT") == mm
|
||||
assert set(subject.get_candidate_units(self.file, "LENGTHUNIT")) == {mm, m}
|
||||
|
||||
def test_derived_unit_matched_by_literal_unit_type(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
|
||||
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
|
||||
assert subject.get_candidate_units(self.file, "MODULUSOFELASTICITYUNIT") == [modulus]
|
||||
|
||||
def test_userdefined_derived_unit_matched_by_dimensional_fallback(self):
|
||||
# No literal UnitType match (USERDEFINED), but dimensionally it's a pressure unit,
|
||||
# and PRESSUREUNIT is one of the core families covered by named_dimensions.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
|
||||
weird_pressure = ifcopenshell.api.unit.add_derived_unit(
|
||||
self.file, "USERDEFINED", "pressure-ish", {force: 1, area: -1}
|
||||
)
|
||||
assert subject.get_candidate_units(self.file, "PRESSUREUNIT") == [weird_pressure]
|
||||
|
||||
def test_empty_when_nothing_matches(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
|
||||
assert subject.get_candidate_units(self.file, "MASSUNIT") == []
|
||||
|
||||
|
||||
class TestGetPropertyUnit(test.bootstrap.IFC4):
|
||||
def test_no_unit(self):
|
||||
@@ -197,12 +119,6 @@ class TestGetPropertyUnit(test.bootstrap.IFC4):
|
||||
prop.Unit = length2
|
||||
assert subject.get_property_unit(prop, self.file) == length2
|
||||
|
||||
def test_single_value_with_no_nominal_value(self):
|
||||
# NominalValue is optional -- a property may be null. Must not crash.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
prop = self.file.createIfcPropertySingleValue(Name="Foo", NominalValue=None)
|
||||
assert subject.get_property_unit(prop, self.file) is None
|
||||
|
||||
def test_enumerated_value(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
@@ -284,152 +200,6 @@ class TestCalculateUnitScale(test.bootstrap.IFC4):
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[angle])
|
||||
assert subject.calculate_unit_scale(self.file, "PLANEANGLEUNIT") == pi / 180 * 0.001
|
||||
|
||||
def test_derived_units_are_considered(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="MILLI")
|
||||
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[modulus])
|
||||
# AREAUNIT is a pure power of length, so its MILLI prefix is raised to
|
||||
# the length exponent (2) per #9278: (1e-3)**2 = 1e-6, inverted by the
|
||||
# derived unit's -1 exponent to give 1e6.
|
||||
assert subject.calculate_unit_scale(self.file, "MODULUSOFELASTICITYUNIT") == pytest.approx(1_000_000.0)
|
||||
|
||||
def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self):
|
||||
# A prefixed square/cubic metre is (prefix-metre) squared/cubed:
|
||||
# DECI SQUARE_METRE = dm2 = 1e-2 m2, DECI CUBIC_METRE = dm3 (litre) = 1e-3 m3.
|
||||
# https://github.com/IfcOpenShell/IfcOpenShell/issues/9278
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
|
||||
area.Prefix = "DECI"
|
||||
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
|
||||
volume.Prefix = "DECI"
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[area, volume])
|
||||
assert subject.calculate_unit_scale(self.file, "AREAUNIT") == pytest.approx(0.1**2)
|
||||
assert subject.calculate_unit_scale(self.file, "VOLUMEUNIT") == pytest.approx(0.1**3)
|
||||
|
||||
def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self):
|
||||
# For derived and non-length SI units the prefix scales the unit itself:
|
||||
# KILO PASCAL = 1e3 Pa, KILO GRAM = 1e3 g.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT")
|
||||
pressure.Prefix = "KILO"
|
||||
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT")
|
||||
mass.Prefix = "KILO"
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[pressure, mass])
|
||||
assert subject.calculate_unit_scale(self.file, "PRESSUREUNIT") == pytest.approx(1000)
|
||||
assert subject.calculate_unit_scale(self.file, "MASSUNIT") == pytest.approx(1000)
|
||||
|
||||
|
||||
class TestCalculateUnitScaleOnLinkedFile(test.bootstrap.IFC4):
|
||||
def test_run(self):
|
||||
# Regression test: IfcSIUnit.Dimensions is a schema-*derived*
|
||||
# attribute that isn't computed for SQLite-linked files (used for
|
||||
# Bonsai's "linked project" large-model workflow), so it returns None
|
||||
# there instead of an IfcDimensionalExponents entity. calculate_unit_scale()
|
||||
# used to access unit.Dimensions.LengthExponent unconditionally for
|
||||
# every IfcSIUnit, which crashed project loading for any linked file.
|
||||
# See the PR discussion for a standalone reproduction script.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(self.file, units=[length])
|
||||
|
||||
patcher = Ifc2Sql.Patcher(self.file, sql_type="SQLite")
|
||||
patcher.patch()
|
||||
tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1])
|
||||
ifcpatch.write(patcher.get_output(), tmp_file)
|
||||
|
||||
try:
|
||||
linked_file = ifcopenshell.open(str(tmp_file))
|
||||
assert linked_file.by_type("IfcSIUnit")[0].Dimensions is None
|
||||
assert subject.calculate_unit_scale(linked_file, "LENGTHUNIT") == 1.0
|
||||
finally:
|
||||
if isinstance(linked_file, ifcopenshell.sqlite):
|
||||
linked_file.db.close()
|
||||
tmp_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestGetNamedUnitScale(test.bootstrap.IFC4):
|
||||
def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self):
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="DECI")
|
||||
assert subject.get_named_unit_scale(area) == pytest.approx(0.1**2)
|
||||
|
||||
def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self):
|
||||
pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT", prefix="KILO")
|
||||
assert subject.get_named_unit_scale(pressure) == pytest.approx(1000)
|
||||
|
||||
|
||||
class TestGetDerivedUnitScale(test.bootstrap.IFC4):
|
||||
def test_composes_scale_from_elements(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO")
|
||||
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
|
||||
density = ifcopenshell.api.unit.add_derived_unit(self.file, "MASSDENSITYUNIT", None, {mass: 1, volume: -1})
|
||||
assert subject.get_derived_unit_scale(density) == 1000.0
|
||||
|
||||
def test_unnamed_derived_unit_still_composes(self):
|
||||
# A made-up "force per unit time" derived unit with no IfcDerivedUnitEnum match.
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT", prefix="MILLI")
|
||||
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
|
||||
assert subject.get_derived_unit_scale(weird) == pytest.approx(1 / 0.001)
|
||||
|
||||
|
||||
class TestGetUnitScale(test.bootstrap.IFC4):
|
||||
def test_dispatches_to_named_unit_scale_for_si_and_conversion_based_units(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
|
||||
ft = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="foot")
|
||||
assert subject.get_unit_scale(mm) == subject.get_named_unit_scale(mm)
|
||||
assert subject.get_unit_scale(ft) == subject.get_named_unit_scale(ft)
|
||||
|
||||
def test_dispatches_to_derived_unit_scale_for_derived_units(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO")
|
||||
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
|
||||
density = ifcopenshell.api.unit.add_derived_unit(self.file, "MASSDENSITYUNIT", None, {mass: 1, volume: -1})
|
||||
assert subject.get_unit_scale(density) == subject.get_derived_unit_scale(density)
|
||||
|
||||
|
||||
class TestGetUnitSymbol(test.bootstrap.IFC4):
|
||||
def test_derived_unit_composes_a_symbol(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
|
||||
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
|
||||
assert subject.get_unit_symbol(modulus) == "N/m2"
|
||||
|
||||
def test_unnamed_derived_unit_still_composes_a_symbol_without_crashing(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT")
|
||||
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
|
||||
assert subject.get_unit_symbol(weird) == "N/s"
|
||||
|
||||
def test_context_dependent_userdefined_unit_is_not_shadowed_by_the_derived_unit_check(self):
|
||||
# IfcContextDependentUnit (e.g. "each", "boxes") is a distinct entity
|
||||
# from IfcDerivedUnit, so the is_a("IfcDerivedUnit") check added for
|
||||
# derived-unit symbol composition must not shadow this fallback.
|
||||
each = ifcopenshell.api.unit.add_context_dependent_unit(self.file, name="EACH")
|
||||
assert subject.get_unit_symbol(each) == "EACH"
|
||||
|
||||
|
||||
class TestIdentifyUnitDimensions(test.bootstrap.IFC4):
|
||||
def test_matches_a_named_unit_type_by_dimension(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
|
||||
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
|
||||
assert subject.identify_unit_dimensions(modulus) == "PRESSUREUNIT"
|
||||
|
||||
def test_returns_none_for_no_match(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
|
||||
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT")
|
||||
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
|
||||
assert subject.identify_unit_dimensions(weird) is None
|
||||
|
||||
|
||||
class TestFormatLength(test.bootstrap.IFC4):
|
||||
def test_run(self):
|
||||
|
||||
Reference in New Issue
Block a user