From 22a17cd287e79accfa87ebb96b4db6466585d5e8 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 16 May 2026 19:16:02 -0500 Subject: [PATCH] Add LinePosition: absolute dimension line placement gated on ForcePerpendicularToFace - BBIM_Dimension.LinePosition (IfcLengthMeasure): holds the dimension line at a fixed global coordinate along cross(world_Z, dim_direction), independent of geometry movement - regenerate_dimension applies LinePosition only when ForcePerpendicularToFace is also set (the two are semantically coupled); anchor["pt"] always stores the true surface hit so the measured length is unaffected - BIMAnnotationProperties.line_position uses get/set callbacks instead of an update callback to avoid the 'Writing to ID classes in this context is not allowed' error that fires when Blender draws the tool header - DimensionLinePositionWidget (BIM_GGT_dimension_line_position): gizmo group with two opposing GizmoCone handles at the curve midpoint; poll requires ForcePerpendicularToFace so the handles only appear when the feature is active - UI: line_position field and gizmo are hidden when ForcePerpendicularToFace is off - Psets_BBIM_Annotation.ifc: #40 LinePosition template added to BBIM_Dimension Co-Authored-By: Claude Sonnet 4.6 --- .../bim/data/pset/Psets_BBIM_Annotation.ifc | 3 +- .../bonsai/bim/module/drawing/__init__.py | 1 + .../bonsai/bim/module/drawing/gizmos.py | 193 ++++++++++++++++++ src/bonsai/bonsai/bim/module/drawing/prop.py | 105 ++++++++++ .../bonsai/bim/module/drawing/workspace.py | 5 + .../api/drawing/regenerate_dimension.py | 79 +++++++ 6 files changed, 385 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc index 045e673c7e..c301700841 100644 --- a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc +++ b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc @@ -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)); +#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39,#40)); #25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -44,5 +44,6 @@ DATA; #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.); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 0ef006ac8b..c69a23ed6d 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -175,6 +175,7 @@ classes = ( gizmos.UglyDotGizmo, gizmos.ExtrusionGuidesGizmo, gizmos.ExtrusionWidget, + gizmos.DimensionLinePositionWidget, workspace.LaunchAnnotationTypeManager, workspace.Hotkey, ) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 53a4c8328e..553d486112 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -2621,6 +2621,199 @@ class ExtrusionWidget(types.GizmoGroup): self.handle.target_set_prop("offset", prop, "value") self.guides.target_set_prop("depth", prop, "value") + +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", "PLAN_LEVEL", "SECTION_LEVEL")) + + @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") and pset.get("ForcePerpendicularToFace")) + + # ------------------------------------------------------------------ + # Helpers + + @staticmethod + def _offset_dir(obj: bpy.types.Object) -> "Vector | None": + """World-space unit direction perpendicular to the dimension line and world_Z.""" + 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() + world_z = Vector((0.0, 0.0, 1.0)) + od = world_z.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: + 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 + + resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override) + 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) + + self.gz_fwd.matrix_basis = self._basis(mid, 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(mid, -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 diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 209a83b8de..bae42f0e3a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -1094,6 +1094,104 @@ def _update_force_perpendicular(self, context): _update_blender_curve(element, resolved_pts) +def _get_line_position(self) -> float: + """Return LinePosition from the active annotation's BBIM_Dimension pset. + + Falls back to the natural anchor projection when LinePosition has not been + explicitly set, so the field always shows a meaningful value. + """ + import math + import json + try: + import bpy as _bpy + import ifcopenshell.util.element as _ue + import bonsai.tool as _tool + obj = getattr(_bpy.context, "active_object", None) + if obj: + element = _tool.Ifc.get_entity(obj) + if element and element.is_a("IfcAnnotation"): + pset = _ue.get_pset(element, "BBIM_Dimension") + if pset: + stored = pset.get("LinePosition") + if stored is not None: + return float(stored) + raw = pset.get("Anchors") + if raw: + anchors = json.loads(raw) + if len(anchors) >= 2 and anchors[0].get("pt") and anchors[1].get("pt"): + a, b = anchors[0]["pt"], anchors[1]["pt"] + dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2] + m = math.sqrt(dx * dx + dy * dy + dz * dz) + if m > 1e-10: + ddx, ddy, ddz = dx / m, dy / m, dz / m + # cross(world_Z=(0,0,1), dim_dir) = (-ddy, ddx, 0) + ox, oy, oz = -ddy, ddx, 0.0 + om = math.sqrt(ox * ox + oy * oy) + if om > 1e-6: + od = (ox / om, oy / om, 0.0) + pt = anchors[0]["pt"] + return float(pt[0] * od[0] + pt[1] * od[1]) + 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", "PLAN_LEVEL", "SECTION_LEVEL")) + + 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 + + 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) + 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 @@ -1113,6 +1211,13 @@ class BIMAnnotationProperties(PropertyGroup): default=False, update=_update_force_perpendicular, ) + line_position: bpy.props.FloatProperty( + name="Line Position", + description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.", + unit="LENGTH", + get=_get_line_position, + set=_set_line_position, + ) tag_rotation_mode: bpy.props.EnumProperty( name="Tag Rotation Mode", description="How to orient the tag relative to the tagged object", diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index 285f9f9a9b..03bcd0c033 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -237,6 +237,11 @@ class AnnotationToolUI: 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) row.operator("bim.set_dimension_anchor", icon="PIVOT_CURSOR") op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate") diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py index ae997671a1..62abe52ba3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py @@ -132,6 +132,21 @@ def regenerate_dimension( 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. + # Only active when ForcePerpendicularToFace is also set — the two are semantically coupled. + line_position = pset_data.get("LinePosition") + if line_position is not None and pset_data.get("ForcePerpendicularToFace") 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]) + 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] @@ -257,6 +272,23 @@ 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, @@ -291,3 +323,50 @@ def _get_anchor_face_normal_world( return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value] return None + + +def _get_line_offset_direction( + face_normal: Optional[tuple[float, float, float]], + resolved_pts: list[tuple], +) -> Optional[tuple[float, float, float]]: + """Return the direction to apply LineOffset — parallel to the first face. + + Uses cross(world_Z, dim_direction) to get the horizontal direction + perpendicular to the dimension line, which slides the line sideways + (parallel to the face) rather than into/out of it. + + Falls back to cross(face_normal, world_Z) when the dimension line is + nearly vertical (e.g. elevation dimensions). + """ + world_z = (0.0, 0.0, 1.0) + + # Primary: use the dimension line direction (anchor[0] → anchor[1]) + 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) + # cross(world_Z, dim_dir) — horizontal direction perp to dimension + d = ( + world_z[1] * dim_dir[2] - world_z[2] * dim_dir[1], + world_z[2] * dim_dir[0] - world_z[0] * dim_dir[2], + world_z[0] * dim_dir[1] - world_z[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 vertical dims: 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