Compare commits

..

8 Commits

Author SHA1 Message Date
Richard Brice cffb933efb Gets the pick list for bSDD properties with Allowed Values working 2026-08-11 14:47:44 -07:00
myoualid 223f358f28 fixes to sequence.create_baseline:
- assert isinstance(res, list) was wrong because duplicate_task returns a tuple not a list
- removed overkill assertion anyway as the usecase is already typed.
- setting optional name or reuse planned schedule name
- usecase now returns created baseline work schedule
2026-08-11 16:30:41 +01:00
Thomas Krijnen 8dc1ee55b5 Apply suggestion from @aothms 2026-08-11 12:14:33 +02:00
BelGraDev 33f61ef344 Fixed error when accessing the UnitType attribute in convert_file_length_units 2026-08-11 12:14:33 +02:00
Robert Sigmundsson f05dd4aea5 Fix #9278. calculate_unit_scale raises the SI prefix to the length exponent for prefixed SQUARE_METRE/CUBIC_METRE units.
An SI prefix attaches to the base unit symbol and the prefixed symbol is
raised to the power as a whole: DECI CUBIC_METRE is dm3 = a litre = 1e-3 m3,
not 0.1 m3. The scale factor previously applied the prefix multiplier
linearly for all IfcSIUnits, inflating volumes x100 and areas x10 for such
declarations (produced e.g. by MagiCAD for Revit MEP exports).

Following the reviewer note in #9278, the exponent is taken from the
derived attribute IfcSIUnit.Dimensions rather than from substring matching
on the unit name: the multiplier is raised to LengthExponent only when the
unit's dimensions are a pure power of length, so prefixed derived units
(KILO PASCAL, MEGA NEWTON) and non-length units (KILO GRAM) correctly keep
the linear multiplier. This matches the exponent handling already present
in convert() and named_dimensions in the same module.

Adds regression tests for prefixed AREAUNIT/VOLUMEUNIT and for the
linear-prefix behaviour of PRESSUREUNIT/MASSUNIT.
2026-08-09 08:41:15 +02:00
Richard Brice 7ed8584edc Revised update_alignment_parameter_segment_tags to make EndTag optional 2026-08-08 10:35:20 -07:00
Richard Brice c5ba22451f Adds update_alignment_parameter_segment_tags function 2026-08-07 14:33:04 -07:00
Richard Brice 048242783e Updates update_key_point_referents to confirm to CT 4.1.4.4.3 2026-08-05 07:26:27 -07:00
37 changed files with 853 additions and 7089 deletions
@@ -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,#41,#42));
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2));
#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,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39,#40));
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30));
#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,14 +38,5 @@ DATA;
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#40=IFCSIMPLEPROPERTYTEMPLATE('1LoNpKqR3sTuVwXyZaBcDe',$,'LinePosition','Absolute world-space coordinate (metres) of the dimension line along the horizontal offset axis (perpendicular to the dimension direction). When set, the dimension line is held at this fixed global position even if the measured geometry moves. When absent the line sits at the anchor points.',.P_SINGLEVALUE.,'IfcLengthMeasure',$,$,$,$,$,.READWRITE.);
#41=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsAnnotFaux0001aB',$,'IsManualDrawingReference','Marks this annotation as a manually placed drawing reference, exempt from automatic drawing regeneration.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#42=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsDocRefFaux001aB',$,'IsDocumentReference','Marks this annotation as pointing to an external document reference (not a Bonsai drawing camera).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
@@ -32,7 +32,6 @@ classes = (
operator.ActivateModel,
operator.AddAnnotation,
operator.AddAnnotationType,
operator.AddElevationAnnotation,
operator.AddDrawing,
operator.AddDrawingStyle,
operator.AddDrawingToSheet,
@@ -112,16 +111,6 @@ 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,
@@ -183,19 +172,11 @@ 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:
@@ -215,21 +196,9 @@ 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:
@@ -242,10 +211,5 @@ 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)
+3 -22
View File
@@ -55,14 +55,6 @@ 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"
@@ -320,9 +312,6 @@ 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)
@@ -342,9 +331,6 @@ 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
@@ -813,24 +799,19 @@ 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_units = list(pset_data.get("CustomUnit", None) or [])
separator = pset_data.get("Separator", None) or " / "
custom_unit_list = pset_data.get("CustomUnit", None) or ""
custom_unit = custom_unit_list[0] if custom_unit_list else ""
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_units": custom_units,
"separator": separator,
"custom_unit": custom_unit,
}
@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, suppress_zero_feet=False, custom_unit=None, in_unit_length=False):
def format_value(self, context, value, suppress_zero_inches=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,7 +506,6 @@ 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,
)
@@ -723,13 +722,11 @@ 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])
@@ -740,10 +737,6 @@ 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
@@ -752,25 +745,16 @@ class DimensionDecorator(BaseDecorator):
"multiline": True,
"text_dir": text_dir,
}
base_pos = p1 if is_ordinate else (p0 + p1) / 2
base_pos = p0 + text_dir * 0.5
if not show_description_only:
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)
length = (v1 - v0).length
text = self.format_value(
context,
length,
suppress_zero_inches=dimension_data["suppress_zero_inches"],
custom_unit=dimension_data["custom_unit"],
)
if isinstance(self, DiameterDecorator):
text = "D" + text
text = text_prefix + text + text_suffix
@@ -781,18 +765,15 @@ class DimensionDecorator(BaseDecorator):
self.draw_label(
text=text,
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",
pos=base_pos + text_offset,
box_alignment="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 + (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,
text=description, pos=base_pos - text_offset, box_alignment="top-middle", **common_label_attrs
)
@@ -988,9 +969,7 @@ class RadiusDecorator(BaseDecorator):
def get_text():
length = (spline_points[-1] - spline_points[-2]).length
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)
return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"])
self.draw_dimension_text(
context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center"
@@ -1526,20 +1505,6 @@ 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]]
@@ -1548,7 +1513,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 = edge_dir_2d.to_3d()
edge_dir = (v1 - v0).normalized()
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)
@@ -2164,8 +2129,4 @@ class DecorationsHandler:
object_decorators = DecoratorData.data.get("object_decorators", [])
for obj, decorator in object_decorators:
try:
decorator.decorate(context, obj)
except ReferenceError:
DecoratorData.is_loaded = False
break
decorator.decorate(context, obj)
@@ -2297,19 +2297,6 @@ 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),
@@ -2634,370 +2621,6 @@ 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
@@ -3022,76 +2645,6 @@ class DimensionLinePositionWidget(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
# ============================================================================
@@ -4058,24 +3611,6 @@ 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,148 +16,15 @@
# 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)
@@ -194,193 +61,3 @@ 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,7 +170,6 @@ def format_distance(
precision=None,
decimal_places=None,
suppress_zero_inches=False,
suppress_zero_feet=False,
in_unit_length=False,
custom_unit=None,
):
@@ -320,10 +319,10 @@ def format_distance(
tx_dist = ""
if feet:
tx_dist += str(feet) + "'"
if not feet and not add_inches and not suppress_zero_feet:
if not feet and not add_inches:
tx_dist += str(feet) + "'"
if not feet and add_inches and unit_length != "INCHES" and not suppress_zero_feet:
if not feet and add_inches and unit_length != "INCHES":
if value < 0:
tx_dist += "-0' - "
else:
File diff suppressed because it is too large Load Diff
@@ -95,14 +95,6 @@ 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:
@@ -1046,276 +1038,6 @@ 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
@@ -1329,25 +1051,6 @@ 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,11 +872,7 @@ 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)))
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
angle = -math.degrees((v2 - v1).xy.angle_signed(Vector((0, 1))))
transform = "rotate({}, {}, {})".format(angle, *symbol_position_svg.xy)
@@ -899,8 +895,6 @@ 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"):
@@ -1373,18 +1367,14 @@ class SvgWriter:
def get_text():
radius = (points[-1].co - points[-2].co).length
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)
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
self.draw_dimension_text(
get_text, tag, dimension_data, text_position=text_position, class_str="RADIUS", box_alignment="center"
@@ -1511,12 +1501,10 @@ 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_units=dimension_data["custom_units"],
separator=dimension_data["separator"],
custom_unit=dimension_data["custom_unit"],
)
def draw_dimension_annotations(self, obj: bpy.types.Object) -> None:
@@ -1527,15 +1515,11 @@ 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,
@@ -1543,13 +1527,10 @@ 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_units=dimension_data["custom_units"],
separator=dimension_data["separator"],
distance_override=ordinate_total if is_ordinate else None,
custom_unit=dimension_data["custom_unit"],
)
def draw_measureit_arch_dimension_annotations(self) -> None:
@@ -1573,13 +1554,10 @@ 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_units=None,
separator=" / ",
distance_override=None,
custom_unit=None,
) -> None:
offset = Vector([self.raw_width, self.raw_height]) / 2
v0 = self.project_point_onto_camera(v0_global)
@@ -1588,24 +1566,12 @@ 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
if distance_override is not None:
text_position = end
else:
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
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)
@@ -1619,20 +1585,15 @@ class SvgWriter:
}
if not show_description_only:
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
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
else:
if not dimension_text:
return
@@ -1640,8 +1601,8 @@ class SvgWriter:
text_tags += self.create_text_tag(
text,
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",
text_position + perpendicular,
box_alignment="bottom-middle",
multiline_to_bottom=False,
**text_tag_kwargs,
)
@@ -1649,8 +1610,8 @@ class SvgWriter:
if not show_description_only and dimension_text:
text_tags += self.create_text_tag(
dimension_text,
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",
text_position - perpendicular,
box_alignment="top-middle",
multiline_to_bottom=True,
**text_tag_kwargs,
)
@@ -555,17 +555,6 @@ 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:
@@ -583,8 +572,6 @@ 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,11 +114,7 @@ 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 = (
# 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() + (
bl_keymap = 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")]}),
@@ -225,68 +221,14 @@ 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):
obj = bpy.context.active_object
if tool.Ifc.get_entity(obj) and DecoratorData.get_text_data(obj):
if DecoratorData.get_text_data(bpy.context.active_object):
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
@@ -309,13 +251,6 @@ 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")
@@ -398,20 +333,8 @@ 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):
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():
if bpy.ops.bim.add_annotation.poll():
bpy.ops.bim.add_annotation()
def hotkey_S_E(self):
@@ -1325,10 +1325,6 @@ 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,8 +834,6 @@ 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,8 +796,6 @@ 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))
@@ -865,8 +863,6 @@ 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))]
+3 -15
View File
@@ -462,26 +462,14 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
# 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.
tool.Raycast.clear_snap_objs()
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)
self._init_snapping_points(context, event)
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)
tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
context.window_manager.modal_handler_add(self)
def _init_snapping_points(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
"""Populate self.snapping_points at operator start.
Override in subclasses to skip the full BVH snap detection when a cheap
placeholder is sufficient. The default runs the full detection pass.
"""
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
@@ -88,44 +88,6 @@ 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"
@@ -190,12 +152,7 @@ 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()
-24
View File
@@ -542,10 +542,6 @@ 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)
@@ -554,26 +550,6 @@ 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"))
+40 -3
View File
@@ -170,7 +170,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
psets.setdefault(pset, {})
predefined_value = prop.get("predefinedValue")
if predefined_value:
if predefined_value and predefined_value != "None":
possible_values = [predefined_value]
else:
possible_values = prop.get("allowedValues", []) or []
@@ -369,7 +369,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
data = cls.bsdd_properties[bsdd_prop.uri]
predefined_value = data.get("predefinedValue")
if predefined_value:
if predefined_value and predefined_value != "None":
possible_values = [predefined_value]
else:
possible_values = data.get("allowedValues", []) or []
@@ -401,7 +401,13 @@ class Bsdd(bonsai.core.tool.Bsdd):
uris.add(uri)
psets = set()
for uri in uris:
if not (bsdd_class := cls.bsdd_classes.get(uri, None)):
try:
# Cache may not be populated yet (e.g. a fresh session that never
# browsed this class), so fetch on a cache miss instead of skipping.
bsdd_class = cls.get_bsdd_class(uri)
except Exception:
continue
if not bsdd_class:
continue
for class_pset in bsdd_class.get("classProperties", []):
if not (pset_name := class_pset.get("propertySet", None)):
@@ -409,6 +415,37 @@ class Bsdd(bonsai.core.tool.Bsdd):
psets.add((uri, bsdd_class["name"], pset_name))
return psets
@classmethod
def get_bsdd_pset_property_values(
cls, element: ifcopenshell.entity_instance, pset_name: str
) -> dict[str, list[str]]:
"""Map bSDD property code -> allowed/predefined values for properties bSDD says
are applicable to `element` (via its classification references) under `pset_name`.
Used to recognise a Pset property as bSDD-sourced even outside the dedicated
bSDD add-property flow (e.g. a Pset created in a previous session), so it can
still be edited as a picklist.
"""
result: dict[str, list[str]] = {}
for uri, class_name, applicable_pset_name in cls.get_applicable_psets(element):
if applicable_pset_name != pset_name:
continue
bsdd_class = cls.get_bsdd_class(uri)
for class_prop in bsdd_class.get("classProperties", []):
if class_prop.get("propertySet") != pset_name:
continue
code = class_prop.get("propertyCode")
if not code or code in result:
continue
predefined_value = class_prop.get("predefinedValue")
if predefined_value and predefined_value != "None":
result[code] = [predefined_value]
continue
allowed_values = class_prop.get("allowedValues", []) or []
if allowed_values:
result[code] = [v["value"] for v in allowed_values]
return result
@classmethod
def is_applicable(cls, pset_uri: str, element: ifcopenshell.entity_instance) -> bool:
uris = set()
+1 -260
View File
@@ -77,8 +77,6 @@ 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")
@@ -211,17 +209,6 @@ 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)
@@ -1645,14 +1632,7 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def is_auto_annotation(cls, element: ifcopenshell.entity_instance):
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
return element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL")
@classmethod
def get_drawing_reference_annotation(
@@ -1984,95 +1964,6 @@ 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,
@@ -2919,156 +2810,6 @@ 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)
+23
View File
@@ -223,6 +223,18 @@ class Pset(bonsai.core.tool.Pset):
elif pset.is_a("IfcMaterialProperties") or pset.is_a("IfcProfileProperties"):
pset_props = pset.Properties
# If this Pset's owning element is classified against a bSDD class that defines
# this Pset, recognise properties matching bSDD property codes as picklists,
# even though the Pset wasn't necessarily created through the bSDD add-property UI.
bsdd_allowed_values: dict[str, list[str]] = {}
if pset.is_a("IfcPropertySet"):
elements = ifcopenshell.util.element.get_elements_by_pset(pset)
if elements:
try:
bsdd_allowed_values = tool.Bsdd.get_bsdd_pset_property_values(next(iter(elements)), pset.Name)
except Exception:
pass
prop_templates: dict[str, ifcopenshell.entity_instance] = {}
if pset_template:
prop_templates = {prop.Name: prop for prop in pset_template.HasPropertyTemplates}
@@ -286,6 +298,17 @@ class Pset(bonsai.core.tool.Pset):
metadata.set_value(metadata.get_value_default() if metadata.is_null else value)
process_prop_description(metadata)
if prop.is_a("IfcPropertySingleValue") and (possible_values := bsdd_allowed_values.get(prop.Name)):
str_value = None if value is None else str(value)
if str_value is not None and str_value not in possible_values:
# Preserve a legacy/imported value that doesn't match the current
# bSDD enumeration instead of silently dropping it.
possible_values = [*possible_values, str_value]
metadata.enum_items = json.dumps(possible_values)
metadata.data_type = "enum"
if str_value is not None:
metadata.enum_value = str_value
@classmethod
def get_prop_template_primitive_type(cls, prop_template: ifcopenshell.entity_instance) -> str:
if prop_template.TemplateType in ["Q_LENGTH", "Q_AREA", "Q_VOLUME", "Q_WEIGHT", "Q_TIME"]:
+5 -19
View File
@@ -970,31 +970,18 @@ 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):
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 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.
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)
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]:
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
if (obj.matrix_world @ v1.co) != v2:
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)
@@ -1033,7 +1020,6 @@ 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):
+1 -1
View File
@@ -1026,7 +1026,7 @@ def apply_ifc_classification_properties(
psets = ifcopenshell.util.element.get_psets(element)
for prop in classificationProperties:
predefinedValue = prop.get("predefinedValue")
if not predefinedValue or prop.get("propertyDomainName") != "IFC":
if not predefinedValue or predefinedValue == "None" or prop.get("propertyDomainName") != "IFC":
continue
pset = psets.get(prop["propertySet"])
if pset:
@@ -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 a indicator of the position and the station, e.g. "P.C. (145+98.32)"
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.)"
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,6 +89,7 @@ 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
@@ -132,6 +133,7 @@ __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",
@@ -0,0 +1,30 @@
# 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})"
@@ -0,0 +1,108 @@
# 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,21 +32,6 @@ 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."""
@@ -91,7 +76,7 @@ def _create_key_point_referent(
),
)
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
name = f"{alignment.Name} {_get_key_point_tag(file, label, station)}"
referent = file.createIfcReferent(
GlobalId=ifcopenshell.guid.new(),
@@ -120,7 +105,8 @@ 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.", ...), with the station appended, e.g. "P.C. (145+98.32)". Different jurisdictions use
"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
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
@@ -129,9 +115,11 @@ 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. 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 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 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.
@@ -158,7 +146,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 starts with "Start (" instead of the default "P.O.B. ("
# nest.RelatedObjects[0].Name ends with "(Start)" instead of the default "(P.O.B.)"
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
@@ -167,12 +155,20 @@ def update_key_point_referents(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
if rel_nests is None:
rel_nests = _get_key_point_referent_nest(layout)
if rel_nests is None:
rel_nests = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=layout, RelatedObjects=()
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()}"
)
else:
rel_nests = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=()
)
if clear:
for referent in list(rel_nests.RelatedObjects):
@@ -189,7 +185,6 @@ 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,25 +25,12 @@ 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",
]
@@ -1,422 +0,0 @@
# 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
@@ -28,7 +28,7 @@ import ifcopenshell.util.sequence
def create_baseline(
file: ifcopenshell.file, work_schedule: ifcopenshell.entity_instance, name: Optional[str] = None
) -> None:
) -> ifcopenshell.entity_instance:
"""Creates a baseline for your Work Schedule
Using a IfcWorkSchdule having PredefinedType=PLANNED,
@@ -42,7 +42,7 @@ def create_baseline(
* Same Construction Resources
* Same Resource Relationships
:param work_schedule: The planned work_schedule to baseline
:param work_schedule: The planned work schedule to baseline
:param name: baseline work schedule name
:return: The baseline work_schedule
@@ -51,7 +51,7 @@ def create_baseline(
.. code:: python
# We have a Work Schedule
planned_work_schedule = WorkSchedule(name="Design new feature",predefinedType="PLANNED", deadline="2023-03-01")
planned_work_schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Planned Construction Schedule")
# And now we have a baseline for our Work Schedule
baseline_work_schedule = ifcopenshell.api.sequence.create_baseline(file, work_schedule=planned_work_schedule, name="Baseline 1")
@@ -64,24 +64,23 @@ def create_baseline(
class Usecase:
file: ifcopenshell.file
def execute(self, work_schedule: ifcopenshell.entity_instance, name: Union[str, None]) -> None:
# create work schedule
if not work_schedule.PredefinedType == "PLANNED":
return
def execute(
self, work_schedule: ifcopenshell.entity_instance, name: Union[str, None]
) -> ifcopenshell.entity_instance:
if work_schedule.PredefinedType != "PLANNED":
raise ValueError("Only a PLANNED work schedule can be baselined.")
baseline_work_schedule = ifcopenshell.api.sequence.add_work_schedule(
self.file, name=work_schedule.Name, predefined_type="BASELINE"
self.file, name=name or work_schedule.Name, predefined_type="BASELINE"
)
baseline_work_schedule.Name = name
self.create_baseline_reference(work_schedule, baseline_work_schedule)
for summary_task in ifcopenshell.util.sequence.get_root_tasks(work_schedule):
res = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task)
assert isinstance(res, list)
current, duplicate = res
current, duplicate = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task)
ifcopenshell.api.control.assign_control(
self.file, relating_control=baseline_work_schedule, related_objects=[duplicate[0]]
)
for i, task in enumerate(current):
self.create_baseline_reference(task, duplicate[i])
return baseline_work_schedule
def create_baseline_reference(
self, relating_object: ifcopenshell.entity_instance, related_object: ifcopenshell.entity_instance
@@ -720,7 +720,29 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
unit_scale *= conversion_factor.ValueComponent.wrappedValue
unit = conversion_factor.UnitComponent
if unit.is_a("IfcSIUnit"):
unit_scale *= get_prefix_multiplier(unit.Prefix)
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 = unit.Dimensions
length_exponent = dimensions.LengthExponent
if length_exponent > 0 and not any(
(
dimensions.MassExponent,
dimensions.TimeExponent,
dimensions.ElectricCurrentExponent,
dimensions.ThermodynamicTemperatureExponent,
dimensions.AmountOfSubstanceExponent,
dimensions.LuminousIntensityExponent,
)
):
prefix_multiplier **= length_exponent
unit_scale *= prefix_multiplier
return unit_scale
@@ -940,7 +962,8 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = "
)
unit_assignment = get_unit_assignment(file_patched)
unit_assignment.Units = [new_length, *(u for u in unit_assignment.Units if u.UnitType != new_length.UnitType)]
# UnitType not available on IfcMonetaryUnit
unit_assignment.Units = [new_length, *(u for u in unit_assignment.Units if getattr(u, 'UnitType', None) != new_length.UnitType)]
if not file_patched.get_total_inverses(old_length):
ifcopenshell.util.element.remove_deep2(file_patched, old_length)
@@ -96,6 +96,10 @@ 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)
@@ -107,8 +111,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 [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
assert [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
def test_with_callbacks(callback_alignment):
@@ -122,7 +126,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 [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
assert [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state
@@ -0,0 +1,307 @@
# 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,6 +66,10 @@ 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)
@@ -82,13 +86,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 == horizontal
assert nest.RelatingObject == alignment
assert nest.id() != segment_nest.id()
assert len(nest.RelatedObjects) == 8
assert all(r.is_a("IfcReferent") for r in nest.RelatedObjects)
def test_second_call_without_rel_nests_reuses_existing_nest():
def test_second_call_without_rel_nests_creates_separate_nest():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
@@ -97,18 +101,31 @@ def test_second_call_without_rel_nests_reuses_existing_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(nest2.RelatedObjects) == 16
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
def test_provided_rel_nests_is_used_as_is():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# the nest may live anywhere the caller chooses, e.g. hung off the parent IfcAlignment
# rel_nests.RelatingObject must be the IfcAlignment that nests `layout`
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)
@@ -118,6 +135,17 @@ 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)
@@ -157,7 +185,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 [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
assert [_label(r.Name) for r in nest.RelatedObjects] == expected
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == sorted(stations)
@@ -183,7 +211,7 @@ def test_default_vertical_labels_and_order():
"P.V.T.",
"V.P.O.E.",
]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
assert [_label(r.Name) 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
@@ -200,7 +228,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"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
assert referent.Name == f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, station)} (P.O.B.)"
def test_geometric_placement_when_layout_has_representation():
@@ -268,7 +296,7 @@ def test_cant_layout_boundary_labels():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
labels = [_label(r.Name) 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
@@ -307,7 +335,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 = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
labels = [_label(r.Name) for r in nest.RelatedObjects]
assert labels == ["P.O.B.", "P.O.E."]
@@ -356,8 +384,10 @@ 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_reuses_existing_nest()
test_second_call_without_rel_nests_creates_separate_nest()
test_passing_previous_nest_back_in_accumulates()
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()
@@ -0,0 +1,146 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 IfcOpenShell contributors
#
# 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.
import pytest
import ifcopenshell.api.root
import ifcopenshell.api.sequence
import ifcopenshell.util.sequence
import test.bootstrap
class TestCreateBaseline(test.bootstrap.IFC4):
def create_planned_schedule(self, name="Design & Build"):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
return ifcopenshell.api.sequence.add_work_schedule(self.file, name=name, predefined_type="PLANNED")
def test_returns_the_created_baseline_schedule(self):
planned = self.create_planned_schedule()
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Design")
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
assert baseline.is_a("IfcWorkSchedule")
assert baseline.Name == "Baseline 1"
assert baseline.PredefinedType == "BASELINE"
baseline_roots = ifcopenshell.util.sequence.get_root_tasks(baseline)
assert [task.Name for task in baseline_roots] == [root_task.Name]
assert baseline_roots != [root_task]
def test_falls_back_to_the_planned_schedule_name(self):
planned = self.create_planned_schedule()
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned)
assert baseline.Name == "Design & Build"
def test_leaves_the_name_null_when_both_names_are_omitted(self):
planned = self.create_planned_schedule()
planned.Name = None
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned)
assert baseline.Name is None
def test_rejects_a_non_planned_schedule(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
actual = ifcopenshell.api.sequence.add_work_schedule(self.file, predefined_type="ACTUAL")
with pytest.raises(ValueError):
ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=actual)
def test_baselines_a_schedule_without_tasks(self):
planned = self.create_planned_schedule()
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
assert ifcopenshell.util.sequence.get_root_tasks(baseline) == []
def test_baselines_every_root_task(self):
planned = self.create_planned_schedule()
ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Design")
ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
baseline_roots = ifcopenshell.util.sequence.get_root_tasks(baseline)
assert sorted(task.Name for task in baseline_roots) == ["Construction", "Design"]
def test_baselines_nested_tasks(self):
planned = self.create_planned_schedule()
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Superstructure")
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
nested = ifcopenshell.util.sequence.get_nested_tasks(baseline_root)
assert sorted(task.Name for task in nested) == ["Foundations", "Superstructure"]
assert len(self.file.by_type("IfcTask")) == 6
def test_baselines_task_attributes_and_times(self):
planned = self.create_planned_schedule()
task = ifcopenshell.api.sequence.add_task(
self.file, work_schedule=planned, name="Foundations", identification="A1", description="Pour concrete"
)
ifcopenshell.api.sequence.add_task_time(self.file, task=task)
ifcopenshell.api.sequence.edit_task_time(
self.file, task_time=task.TaskTime, attributes={"ScheduleDuration": "P5D"}
)
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
baseline_task = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
assert baseline_task.Identification == "A1"
assert baseline_task.Description == "Pour concrete"
assert baseline_task.TaskTime != task.TaskTime
assert baseline_task.TaskTime.ScheduleDuration == "P5D"
def test_baselines_sequence_relationships_between_tasks(self):
planned = self.create_planned_schedule()
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
predecessor = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
successor = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Superstructure")
ifcopenshell.api.sequence.assign_sequence(self.file, relating_process=predecessor, related_process=successor)
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
nested = {task.Name: task for task in ifcopenshell.util.sequence.get_nested_tasks(baseline_root)}
rels = nested["Foundations"].IsPredecessorTo
assert len(rels) == 1
assert rels[0].RelatedProcess == nested["Superstructure"]
def test_references_the_planned_schedule_and_tasks(self):
planned = self.create_planned_schedule()
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
subtask = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
baseline_subtask = ifcopenshell.util.sequence.get_nested_tasks(baseline_root)[0]
references = {
rel.RelatingObject: list(rel.RelatedObjects) for rel in self.file.by_type("IfcRelDefinesByObject")
}
assert references[planned] == [baseline]
assert references[root_task] == [baseline_root]
assert references[subtask] == [baseline_subtask]
def test_reuses_the_existing_reference_for_further_baselines(self):
planned = self.create_planned_schedule()
first = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
second = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 2")
assert len(planned.Declares) == 1
assert list(planned.Declares[0].RelatedObjects) == [first, second]
@@ -200,6 +200,31 @@ 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_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 TestFormatLength(test.bootstrap.IFC4):
def test_run(self):