From b4c6e8a2ec1eff59b1e91498400161e1513a1468 Mon Sep 17 00:00:00 2001
From: Swichllc <42838734+Swichllc@users.noreply.github.com>
Date: Sat, 18 Jul 2026 11:41:14 -0400
Subject: [PATCH 1/2] Add SwichDesign automatic dimensions extension
Add SwichDesign.extension with automatic dimensioning for Revit. Includes three main library modules (geometry, revit_io, standards) providing pure geometry algorithms, Revit API integration, and US architectural dimensioning standards. The main button implements exterior (multi-tier wall/opening dimensions) and interior (room-driven) dimensioning modes with support for angled buildings via frame transformations, core/finish face selection, and opening dimensioning modes.
Update .gitignore to allow this extension in the repository.
---
.../lib/autodimswichdesign/__init__.py | 0
.../lib/autodimswichdesign/geometry.py | 811 +++++++++
.../lib/autodimswichdesign/revit_io.py | 886 +++++++++
.../lib/autodimswichdesign/standards.py | 144 ++
.../AutoDimWindow.xaml | 139 ++
.../bundle.yaml | 163 ++
.../icon.dark.png | Bin 0 -> 841 bytes
.../Automatic Dimensions.pushbutton/icon.png | Bin 0 -> 840 bytes
.../Automatic Dimensions.pushbutton/script.py | 1607 +++++++++++++++++
9 files changed, 3750 insertions(+)
create mode 100644 extensions/SwichDesign.extension/lib/autodimswichdesign/__init__.py
create mode 100644 extensions/SwichDesign.extension/lib/autodimswichdesign/geometry.py
create mode 100644 extensions/SwichDesign.extension/lib/autodimswichdesign/revit_io.py
create mode 100644 extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py
create mode 100644 extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/AutoDimWindow.xaml
create mode 100644 extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/bundle.yaml
create mode 100644 extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/icon.dark.png
create mode 100644 extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/icon.png
create mode 100644 extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/script.py
diff --git a/extensions/SwichDesign.extension/lib/autodimswichdesign/__init__.py b/extensions/SwichDesign.extension/lib/autodimswichdesign/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/extensions/SwichDesign.extension/lib/autodimswichdesign/geometry.py b/extensions/SwichDesign.extension/lib/autodimswichdesign/geometry.py
new file mode 100644
index 0000000000..b76f9e6da2
--- /dev/null
+++ b/extensions/SwichDesign.extension/lib/autodimswichdesign/geometry.py
@@ -0,0 +1,811 @@
+# -*- coding: utf-8 -*-
+"""Pure geometry logic for the Automatic Dimensions rules.
+
+Zero Revit dependency - unit-tested anywhere (tests/test_geometry.py).
+Dual-engine: IronPython 2.7 (pyRevit) and CPython 3.x (tests). No
+f-strings, no annotations, explicit float division only.
+
+Points are (x, y) tuples in decimal feet. Segments are (point, point).
+
+Pipeline:
+ order_segments() unordered wall segments -> connected chains.
+ Two phases: exact endpoint chaining, then gap
+ bridging - real models have split walls, offset
+ bumps and unjoined corners whose location lines
+ do not touch (live-observed: one house perimeter
+ fragmented into 25 "sides" without bridging).
+ split_runs() one chain -> maximal SINGLE-AXIS runs. Since the
+ script groups runs per facing direction afterwards,
+ splitting no longer tries to preserve whole "sides"
+ - every axis change is a boundary. Micro-segments
+ (< RECT_MIN_SEG_FT, e.g. offset-bump connectors)
+ inherit their neighbors' axis when those agree, so
+ an offset bump stays inside one run as a jog;
+ micro-runs made only of bridged connectors are
+ dropped. (The old long-stretch "side" grouping
+ failed live on a 3.7 ft x 7 ft bump-out - real
+ walls under the 4 ft threshold became clutter and
+ the whole west side was skipped.)
+ build_tiers() one run -> the 3-tier exterior dimension values.
+
+Gap-bridged connector segments carry seg-index None - callers mapping
+seg indices back to walls must skip None.
+"""
+from __future__ import division
+
+import math
+
+TOLERANCE_FT = 0.01 # exact endpoint-matching tolerance
+GAP_TOL_FT = 2.0 # bridge splits/bumps/unjoined ends up to this
+COLLINEAR_OFFSET_FT = 0.2 # same-line tolerance for unlimited-length
+ # collinear bridging (string runs across a
+ # storefront/opening break like a drafter's)
+ANGLE_TOL_DEG = 1.0 # direction-change threshold
+RECT_MIN_SEG_FT = 1.0 # segments shorter than this are exempt from
+ # the rectilinearity check and get their axis
+ # from their neighbors (micro-connectors)
+TIER_MERGE_TOL_FT = 0.15 # tier values closer than ~2" merge into one
+ # witness line (bridged connectors can create
+ # near-coincident jog points)
+FRAME_TOL_DEG = 2.0 # walls within this of a frame's angle belong
+ # to it (models are never perfectly on-angle)
+FRAME_MIN_LEN_FT = 1.0 # stubs are too short to vote on a direction
+WITNESS_END_TOL_FT = 1.0 # a wall this close to either end of a witness
+ # line does not count as "crossed" (absorbs the
+ # location-line vs finish-face offset)
+
+QUARTER_TURN = math.pi / 2.0
+
+
+class GeometryError(Exception):
+ """Base for geometry failures. Message is user-facing."""
+
+
+# ------------------------------------------------------- rotated frames
+#
+# Everything below this module's frame layer works in a LOCAL coordinate
+# system whose x/y axes run along the building. For an orthogonal building
+# that is the world system and Frame(0) is the exact identity, so nothing
+# changes. For an angled building - or an angled WING of an otherwise
+# orthogonal one - each direction gets its own Frame, and the same tier /
+# room / span math runs inside it unchanged.
+#
+# This is the only honest way to fix angled buildings: a dimension is
+# always created ALONG a line, so measuring a rotated wall against a world
+# axis returns the cosine-shortened projection - the wrong number, not
+# merely a badly placed one.
+
+
+class Frame(object):
+ """A building direction. Rotates between world and frame-local (x, y).
+
+ angle is measured from world +X, and only matters modulo a quarter
+ turn: a frame's x and y axes are interchangeable for our purposes, so
+ 0 deg and 90 deg are the same frame."""
+
+ def __init__(self, angle=0.0):
+ self.angle = angle
+ # exact identity at 0 so orthogonal models are bit-for-bit
+ # unchanged and cannot regress
+ if angle == 0.0:
+ self.cos = 1.0
+ self.sin = 0.0
+ else:
+ self.cos = math.cos(angle)
+ self.sin = math.sin(angle)
+
+ def to_local(self, pt):
+ x, y = pt
+ return (x * self.cos + y * self.sin, -x * self.sin + y * self.cos)
+
+ def to_world(self, pt):
+ u, v = pt
+ return (u * self.cos - v * self.sin, u * self.sin + v * self.cos)
+
+ def degrees(self):
+ return math.degrees(self.angle)
+
+ def __repr__(self):
+ return "Frame({0:.2f} deg)".format(self.degrees())
+
+
+def _circular_distance(a, b, period=QUARTER_TURN):
+ d = abs(a - b) % period
+ return min(d, period - d)
+
+
+def segment_angle(p, q):
+ """Direction of a segment, folded into [0, 90) - a wall and the wall
+ perpendicular to it define the same frame."""
+ return math.atan2(q[1] - p[1], q[0] - p[0]) % QUARTER_TURN
+
+
+def direction_frames(segments, tol_deg=FRAME_TOL_DEG,
+ min_len=FRAME_MIN_LEN_FT):
+ """The building's directions, as Frames, most important first.
+
+ Wall directions are folded modulo 90 deg and clustered, each wall
+ voting with its LENGTH - so a long facade sets the frame and a short
+ closet stub cannot. An orthogonal building yields exactly one frame at
+ ~0 deg; a rotated one yields one frame at its angle; an angled wing
+ yields a second frame.
+
+ The cluster's angle is a length-weighted CIRCULAR mean (taken on the
+ 4x-angle unit circle, so 89 deg and 1 deg average to 90/0, not 45)."""
+ votes = []
+ for p, q in segments:
+ length = _dist(p, q)
+ if length < min_len:
+ continue
+ votes.append((segment_angle(p, q), length))
+ if not votes:
+ return [Frame(0.0)]
+
+ tol = math.radians(tol_deg)
+ clusters = [] # [[(angle, weight)], ...] seeded by the longest walls
+ for angle, weight in sorted(votes, key=lambda v: -v[1]):
+ for cluster in clusters:
+ if _circular_distance(angle, cluster[0][0]) <= tol:
+ cluster.append((angle, weight))
+ break
+ else:
+ clusters.append([(angle, weight)])
+
+ frames = []
+ for cluster in clusters:
+ # circular mean at 4x so the [0, 90) wrap averages correctly
+ sx = sy = 0.0
+ total = 0.0
+ for angle, weight in cluster:
+ sx += weight * math.cos(4.0 * angle)
+ sy += weight * math.sin(4.0 * angle)
+ total += weight
+ mean = (math.atan2(sy, sx) / 4.0) % QUARTER_TURN
+ frames.append((mean, total))
+
+ frames.sort(key=lambda f: -f[1])
+ return [Frame(angle) for angle, _ in frames]
+
+
+def frame_of(segment, frames, tol_deg=FRAME_TOL_DEG):
+ """Index of the frame a wall belongs to, or None when it is on no
+ frame at all (a genuinely skewed wall - reported, never dimensioned
+ against the wrong axis)."""
+ angle = segment_angle(segment[0], segment[1])
+ tol = math.radians(tol_deg)
+ best = None
+ best_d = None
+ for i in range(len(frames)):
+ d = _circular_distance(angle, frames[i].angle % QUARTER_TURN)
+ if d > tol:
+ continue
+ if best_d is None or d < best_d:
+ best_d = d
+ best = i
+ return best
+
+
+class NotRectilinearError(GeometryError):
+ pass
+
+
+class MultiDirectionalChainError(GeometryError):
+ """No longer raised (kept for import compatibility): single-axis
+ splitting makes multi-directional runs structurally impossible."""
+
+
+def _dist(p, q):
+ return ((p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2) ** 0.5
+
+
+def _heading(p, q):
+ return math.atan2(q[1] - p[1], q[0] - p[0])
+
+
+def _is_turn(p_prev, p, p_next, angle_tol_deg):
+ diff = abs(math.degrees(_heading(p, p_next) - _heading(p_prev, p)))
+ diff = min(diff, 360.0 - diff)
+ return diff > angle_tol_deg
+
+
+# ------------------------------------------------------------- chaining
+
+def _exact_chains(segments, tol):
+ """Phase 1: chain segments whose endpoints coincide within tol."""
+ chains = []
+ unused = set(range(len(segments)))
+
+ while unused:
+ start = min(unused)
+ unused.discard(start)
+ pts = [segments[start][0], segments[start][1]]
+ idxs = [start]
+
+ def walk(get_end, add_pt, add_idx):
+ changed = True
+ while changed:
+ changed = False
+ end = get_end()
+ for i in sorted(unused):
+ a, b = segments[i]
+ if _dist(a, end) <= tol:
+ add_pt(b)
+ add_idx(i)
+ unused.discard(i)
+ changed = True
+ break
+ if _dist(b, end) <= tol:
+ add_pt(a)
+ add_idx(i)
+ unused.discard(i)
+ changed = True
+ break
+
+ walk(lambda: pts[-1], pts.append, idxs.append)
+ walk(lambda: pts[0],
+ lambda p: pts.insert(0, p),
+ lambda i: idxs.insert(0, i))
+
+ closed = len(pts) > 3 and _dist(pts[0], pts[-1]) <= tol
+ if closed:
+ pts = pts[:-1]
+ chains.append((pts, idxs, closed))
+
+ return chains
+
+
+def _end_point(chain_pts, end):
+ return chain_pts[0] if end == 0 else chain_pts[-1]
+
+
+def _end_axis(chain_pts, end):
+ """Axis of the segment at a chain end; None if degenerate."""
+ if len(chain_pts) < 2:
+ return None
+ if end == 0:
+ p, q = chain_pts[1], chain_pts[0]
+ else:
+ p, q = chain_pts[-2], chain_pts[-1]
+ dx = abs(q[0] - p[0])
+ dy = abs(q[1] - p[1])
+ if dx < 1e-9 and dy < 1e-9:
+ return None
+ return "x" if dx >= dy else "y"
+
+
+def _collinear_bridge_ok(pts_i, end_i, pts_j, end_j, collinear_offset):
+ """True when two chain ends lie on the same axis-aligned line, so
+ they may be bridged regardless of gap length."""
+ axis_i = _end_axis(pts_i, end_i)
+ axis_j = _end_axis(pts_j, end_j)
+ if axis_i is None or axis_j is None or axis_i != axis_j:
+ return False
+ pi = _end_point(pts_i, end_i)
+ pj = _end_point(pts_j, end_j)
+ perp = abs(pj[1] - pi[1]) if axis_i == "x" else abs(pj[0] - pi[0])
+ return perp <= collinear_offset
+
+
+def _merge_chains(chains, gap_tol, collinear_offset):
+ """Phase 2: bridge open chains across small gaps (any direction) or
+ collinear gaps (any length). Connector segments get index None."""
+ closed = [c for c in chains if c[2]]
+ open_chains = [[c[0], c[1]] for c in chains if not c[2]]
+
+ merged = True
+ while merged and len(open_chains) > 1:
+ merged = False
+ best = None # (gap, i, j, end_i, end_j)
+ for i in range(len(open_chains)):
+ for j in range(i + 1, len(open_chains)):
+ for end_i in (0, 1):
+ for end_j in (0, 1):
+ pi = _end_point(open_chains[i][0], end_i)
+ pj = _end_point(open_chains[j][0], end_j)
+ gap = _dist(pi, pj)
+ ok = (gap <= gap_tol
+ or _collinear_bridge_ok(
+ open_chains[i][0], end_i,
+ open_chains[j][0], end_j,
+ collinear_offset))
+ if ok and (best is None or gap < best[0]):
+ best = (gap, i, j, end_i, end_j)
+ if best is not None:
+ _, i, j, end_i, end_j = best
+ pts_i, idxs_i = open_chains[i]
+ pts_j, idxs_j = open_chains[j]
+ if end_i == 0: # connect at i's head -> flip i tail-first
+ pts_i = pts_i[::-1]
+ idxs_i = idxs_i[::-1]
+ if end_j == 1: # connect at j's tail -> flip j head-first
+ pts_j = pts_j[::-1]
+ idxs_j = idxs_j[::-1]
+ open_chains[i] = [pts_i + pts_j, idxs_i + [None] + idxs_j]
+ open_chains.pop(j)
+ merged = True
+
+ result = list(closed)
+ for pts, idxs in open_chains:
+ if len(pts) > 3 and _dist(pts[0], pts[-1]) <= gap_tol:
+ result.append((pts, idxs + [None], True))
+ else:
+ result.append((pts, idxs, False))
+ return result
+
+
+def order_segments(segments, tol=TOLERANCE_FT,
+ gap_tol=GAP_TOL_FT,
+ collinear_offset=COLLINEAR_OFFSET_FT):
+ """Unordered segments -> connected chains, bridging real-model gaps.
+
+ Returns list of (points, seg_indices, closed); seg_indices[k] is the
+ input index of the segment between points[k] and points[k+1]
+ (wrapping for closed chains) - None for bridged connectors.
+ """
+ if not segments:
+ return []
+ return _merge_chains(_exact_chains(segments, tol),
+ gap_tol, collinear_offset)
+
+
+# ------------------------------------------------------------ splitting
+
+def _segment_axes(pts, idxs, closed):
+ """Axis per segment: 'x', 'y', or 'micro' for short segments whose
+ neighbors disagree. Short segments (< RECT_MIN_SEG_FT) inherit the
+ shared axis of their neighbors - an offset-bump connector between
+ two collinear-ish pieces stays inside their run as a jog."""
+ n_seg = len(idxs)
+ axes = []
+ for k in range(n_seg):
+ a = pts[k]
+ b = pts[0] if (closed and k == n_seg - 1) else pts[k + 1]
+ dx = abs(b[0] - a[0])
+ dy = abs(b[1] - a[1])
+ if (dx * dx + dy * dy) ** 0.5 < RECT_MIN_SEG_FT:
+ axes.append(None)
+ else:
+ axes.append("x" if dx >= dy else "y")
+
+ for k in range(n_seg):
+ if axes[k] is None:
+ if closed:
+ prev_axis = axes[(k - 1) % n_seg]
+ next_axis = axes[(k + 1) % n_seg]
+ else:
+ prev_axis = axes[k - 1] if k > 0 else None
+ next_axis = axes[k + 1] if k < n_seg - 1 else None
+ if prev_axis is not None and prev_axis == next_axis:
+ axes[k] = prev_axis
+ else:
+ axes[k] = "micro"
+ return axes
+
+
+def split_runs(pts, idxs, closed):
+ """Split a chain into maximal single-axis runs.
+
+ Returns list of (run_points, run_seg_indices); indices may contain
+ None for bridged connectors. Runs made ONLY of bridged connectors
+ (no real wall) are dropped - their endpoints already bound the
+ neighboring runs. Direction grouping downstream reassembles runs
+ into per-facing catch-all strings, so fragmenting here is fine and
+ keeps every run strictly one-directional (the old "side" grouping
+ failed live on a 3.7 ft x 7 ft bump-out)."""
+ if len(pts) < 2:
+ raise GeometryError("Wall run needs at least 2 points")
+ if len(idxs) == 0:
+ raise GeometryError("Wall run has no segments")
+
+ axes = _segment_axes(pts, idxs, closed)
+ n_seg = len(idxs)
+
+ if closed:
+ start = None
+ for k in range(n_seg):
+ if axes[k] != axes[k - 1]:
+ start = k
+ break
+ if start is None:
+ raise GeometryError(
+ "Exterior loop runs in a single direction - invalid loop")
+ pts_open = pts[start:] + pts[:start] + [pts[start]]
+ idxs_open = idxs[start:] + idxs[:start]
+ return split_runs(pts_open, idxs_open, False)
+
+ runs = []
+ k0 = 0
+ for k in range(1, n_seg + 1):
+ if k == n_seg or axes[k] != axes[k0]:
+ run_idxs = idxs[k0:k]
+ has_real_wall = False
+ for i in run_idxs:
+ if i is not None:
+ has_real_wall = True
+ break
+ if has_real_wall:
+ runs.append((pts[k0:k + 1], run_idxs))
+ k0 = k
+ return runs
+
+
+# ----------------------------------------------------------- tier math
+
+def dominant_axis(polyline):
+ xs = [p[0] for p in polyline]
+ ys = [p[1] for p in polyline]
+ return "x" if (max(xs) - min(xs)) >= (max(ys) - min(ys)) else "y"
+
+
+def is_rectilinear(polyline, angle_tol_deg=ANGLE_TOL_DEG,
+ min_seg_ft=RECT_MIN_SEG_FT):
+ """True if every segment >= min_seg_ft is axis-aligned within
+ angle_tol_deg (short gap-bridge connectors may be diagonal)."""
+ for i in range(len(polyline) - 1):
+ dx = polyline[i + 1][0] - polyline[i][0]
+ dy = polyline[i + 1][1] - polyline[i][1]
+ if (dx * dx + dy * dy) ** 0.5 < min_seg_ft:
+ continue
+ angle = math.degrees(math.atan2(abs(dy), abs(dx)))
+ if angle_tol_deg < angle < (90.0 - angle_tol_deg):
+ return False
+ return True
+
+
+def find_jog_points(polyline, angle_tol_deg=ANGLE_TOL_DEG):
+ """Direction-change points plus both endpoints."""
+ if len(polyline) < 2:
+ raise GeometryError("Wall run needs at least 2 points")
+ if len(polyline) == 2:
+ return [polyline[0], polyline[1]]
+ jogs = [polyline[0]]
+ for v in range(1, len(polyline) - 1):
+ if _is_turn(polyline[v - 1], polyline[v], polyline[v + 1],
+ angle_tol_deg):
+ jogs.append(polyline[v])
+ jogs.append(polyline[-1])
+ return jogs
+
+
+def stab_positions(intervals):
+ """Minimal set of positions such that every [lo, hi] interval
+ contains at least one (classic greedy interval stabbing - sort by
+ upper bound, place a point at the upper bound of the first
+ uncovered interval). Used to choose interior dimension scan lines:
+ one position per band of rooms. Degenerate intervals (hi < lo)
+ are skipped."""
+ usable = [iv for iv in intervals if iv[1] >= iv[0]]
+ usable.sort(key=lambda iv: iv[1])
+ out = []
+ for lo, hi in usable:
+ if not out or lo > out[-1]:
+ out.append(hi)
+ return out
+
+
+def point_segment_distance(pt, a, b):
+ """2D distance from point pt to segment a-b."""
+ ax, ay = a
+ bx, by = b
+ px, py = pt
+ dx = bx - ax
+ dy = by - ay
+ seg_len_sq = dx * dx + dy * dy
+ if seg_len_sq == 0:
+ return _dist(pt, a)
+ t = ((px - ax) * dx + (py - ay) * dy) / seg_len_sq
+ t = max(0.0, min(1.0, t))
+ return _dist(pt, (ax + t * dx, ay + t * dy))
+
+
+def distance_to_polyline(pt, polyline):
+ """Min 2D distance from pt to any segment of the polyline."""
+ best = None
+ for i in range(len(polyline) - 1):
+ d = point_segment_distance(pt, polyline[i], polyline[i + 1])
+ if best is None or d < best:
+ best = d
+ return best
+
+
+def project_onto_axis(points, axis, tol=TOLERANCE_FT):
+ idx = 0 if axis == "x" else 1
+ out = []
+ for v in sorted(p[idx] for p in points):
+ if not out or abs(v - out[-1]) > tol:
+ out.append(v)
+ return out
+
+
+def build_tiers(polyline, opening_points,
+ angle_tol_deg=ANGLE_TOL_DEG,
+ tol=TIER_MERGE_TOL_FT):
+ """The exterior_wall_dimension_string rule for ONE single-axis run.
+
+ Returns {'axis', 'tier1', 'tier2', 'tier3'}:
+ tier1 - jogs + opening centerlines (closest to the wall)
+ tier2 - jogs only
+ tier3 - overall run, end to end (outermost)
+
+ (split_runs guarantees single-axis runs, so the old two-directions
+ guard is gone - it fired live on a legitimate bump-out and killed a
+ whole building side.)
+ """
+ if not is_rectilinear(polyline, angle_tol_deg):
+ raise NotRectilinearError(
+ "Run contains an angled or curved segment - out of scope "
+ "for v1, dimension manually")
+
+ axis = dominant_axis(polyline)
+ jogs = find_jog_points(polyline, angle_tol_deg)
+ return {
+ "axis": axis,
+ "tier1": project_onto_axis(jogs + list(opening_points), axis, tol),
+ "tier2": project_onto_axis(jogs, axis, tol),
+ "tier3": project_onto_axis([polyline[0], polyline[-1]], axis, tol),
+ }
+
+
+# ------------------------------------------------ room polygons (interior)
+#
+# A room is a CLOSED polygon of boundary points, in order, WITHOUT a
+# repeated last point. Edge k runs from poly[k] to poly[(k+1) % len(poly)],
+# so an edge index maps 1:1 back to the Revit BoundarySegment that produced
+# it - that is what lets an interior dimension bind to the wall that really
+# bounds the room instead of the nearest wall it can find.
+
+
+def polygon_bounds(poly):
+ """((min_x, min_y), (max_x, max_y)) of a room polygon."""
+ if not poly:
+ raise GeometryError("Empty room polygon")
+ xs = [p[0] for p in poly]
+ ys = [p[1] for p in poly]
+ return (min(xs), min(ys)), (max(xs), max(ys))
+
+
+def point_in_polygon(pt, poly):
+ """True if pt is inside the polygon (even-odd ray casting). Used to
+ assign a fixture/casework instance to the room that contains it.
+ Points exactly on an edge are not guaranteed either way - fixtures
+ sit well inside a room, so this is not worth the extra cost."""
+ if len(poly) < 3:
+ return False
+ x, y = pt
+ inside = False
+ n = len(poly)
+ for k in range(n):
+ ax, ay = poly[k]
+ bx, by = poly[(k + 1) % n]
+ # half-open rule (ay <= y < by): a vertex is counted once, never
+ # twice, so a ray grazing a corner does not flip `inside` twice
+ if (ay > y) != (by > y):
+ t = (y - ay) / (by - ay)
+ if x < ax + t * (bx - ax):
+ inside = not inside
+ return inside
+
+
+def polygon_crossings(poly, axis, perp):
+ """Where a scan line crosses the room's boundary.
+
+ axis 'x' = the line runs east-west at y=perp; axis 'y' = it runs
+ north-south at x=perp. Returns [(value, edge_index)] sorted by value,
+ where `value` is the coordinate ALONG the axis and `edge_index` is the
+ polygon edge (hence the wall) that produced the crossing.
+
+ Edges parallel to the scan line produce no crossing (the half-open
+ test skips them) - a wall parallel to the string is not one of its
+ ends, it is what the string runs alongside."""
+ idx = 0 if axis == "x" else 1
+ pidx = 1 - idx
+ hits = []
+ n = len(poly)
+ for k in range(n):
+ a = poly[k]
+ b = poly[(k + 1) % n]
+ if (a[pidx] > perp) == (b[pidx] > perp):
+ continue # both ends the same side of the line (or parallel)
+ t = (perp - a[pidx]) / (b[pidx] - a[pidx])
+ hits.append((a[idx] + t * (b[idx] - a[idx]), k))
+ hits.sort()
+ return hits
+
+
+def polygon_span_at(poly, axis, perp):
+ """The room's INTERIOR extent along `axis` at scan position `perp`:
+ ((lo_value, lo_edge), (hi_value, hi_edge)), or None when the line
+ misses the room.
+
+ Crossings pair up (0,1), (2,3), ... into interior intervals - a
+ U-shaped room cut through both legs gives two. The LONGEST interval
+ wins: it is the room's real extent at that line, and its two edges are
+ the two walls the dimension may reference. Everything else in interior
+ mode is downstream of this - a string can only ever bind to the two
+ walls this returns, which is what makes 'never go through a wall'
+ structural rather than a heuristic."""
+ hits = polygon_crossings(poly, axis, perp)
+ best = None
+ for k in range(0, len(hits) - 1, 2):
+ lo, hi = hits[k], hits[k + 1]
+ if best is None or (hi[0] - lo[0]) > (best[1][0] - best[0][0]):
+ best = (lo, hi)
+ if best is None or (best[1][0] - best[0][0]) <= 0:
+ return None
+ return best
+
+
+def outermost_index(items, chosen, window_ft=0.5):
+ """Index of the outermost face on the SAME wall and SAME side as
+ `chosen`. Pure decision logic behind revit_io.outermost_same_face.
+
+ items: [(wall_key, coord_along_axis, normal_along_axis)], chosen: index.
+
+ A wall solid can present several faces on one side, a fraction of an
+ inch apart (the finish layer, and what sits behind it). Picking a face
+ by distance-to-target lands on the NEARER one - one step INSIDE the
+ finish face, which is the live-reported exterior defect. This walks
+ outward, along the chosen face's own normal, to the last face of that
+ same wall on that same side.
+
+ It can only ever return a face with the same wall_key and the same
+ normal sign as `chosen`, so it cannot change which wall or which side
+ was picked - a wall exposing one face per side is a no-op."""
+ key, coord, normal = items[chosen]
+ if normal == 0:
+ return chosen
+ outward = 1.0 if normal > 0 else -1.0
+ base = coord * outward
+
+ best = chosen
+ best_out = base
+ for i in range(len(items)):
+ k, c, n = items[i]
+ if k != key or n * normal <= 0:
+ continue
+ reach = c * outward
+ if reach <= best_out or (reach - base) > window_ft:
+ continue
+ best_out = reach
+ best = i
+ return best
+
+
+def quarter_lines(poly, axis):
+ """The two candidate positions for a room-length string: a quarter of
+ the room's depth in from each parallel wall (user rule). axis 'x' =>
+ two y positions. Caller picks whichever is least obstructed."""
+ (min_x, min_y), (max_x, max_y) = polygon_bounds(poly)
+ if axis == "x":
+ lo, hi = min_y, max_y
+ else:
+ lo, hi = min_x, max_x
+ depth = hi - lo
+ return [lo + 0.25 * depth, hi - 0.25 * depth]
+
+
+# --------------------------------------- exterior placement (offsets)
+
+
+def tier_positions(base, side, first_ft, step_ft, count):
+ """Perpendicular positions of the stacked exterior tiers: the first
+ string sits first_ft off the base, each further tier step_ft beyond.
+ With first_ft == step_ft this reproduces the legacy base + N*step
+ stack exactly (back-compat identity, unit-tested)."""
+ return [base + side * (first_ft + i * step_ft) for i in range(count)]
+
+
+def snap_base_outward(base, side, face_perps, max_shift_ft=2.0):
+ """Move the placement base from the location-line extreme OUT to the
+ wall's real face plane, never inward.
+
+ base comes from location-line points, but witness lines run to finish
+ faces which can sit up to a full wall thickness beyond (depends on
+ each wall's Location Line setting - so this is MEASURED from the
+ faces, never guessed from Wall.Width). face_perps are the
+ perpendicular coordinates of the run walls' exterior long faces;
+ the outermost one in the `side` direction wins, clamped to
+ max_shift_ft so a distant stray face cannot yank the string away."""
+ if not face_perps:
+ return base
+ candidate = face_perps[0]
+ for p in face_perps:
+ if (p - candidate) * side > 0:
+ candidate = p
+ shift = (candidate - base) * side
+ if shift <= 0 or shift > max_shift_ft:
+ return base
+ return candidate
+
+
+# ----------------------------- exterior direction-group clustering
+#
+# One string set per facing direction (v3.4, image-confirmed) is right
+# for a single building mass - and wrong for a large plan with several
+# masses/wings, where it drags witness lines clean across the plan into
+# one far-away string (live report: "a mess of crossed lines"). The rule
+# that separates the two cases is the user's own phrasing: a dimension's
+# witness lines must not CROSS a wall. A run only joins a farther
+# cluster when every witness line it would extend to that cluster's
+# base travels through open space.
+
+
+def witness_crosses(value, perp_from, perp_to, axis, segments,
+ end_tol=WITNESS_END_TOL_FT,
+ val_tol=TIER_MERGE_TOL_FT):
+ """True when the witness line at `value` (a coordinate ALONG `axis`),
+ extended perpendicular from perp_from to perp_to, transversely
+ crosses any of the wall segments.
+
+ A segment only counts when the witness's value lies STRICTLY inside
+ the segment's along-axis extent (an endpoint touch is a shared jog
+ corner, not a crossing), and the intersection sits strictly inside
+ the witness span minus end_tol at both ends (walls at either end of
+ the witness are its own anchors, not obstructions). Segments running
+ parallel to the witness never cross - drafters run witness lines
+ alongside walls constantly."""
+ idx = 0 if axis == "x" else 1
+ pidx = 1 - idx
+ lo = min(perp_from, perp_to) + end_tol
+ hi = max(perp_from, perp_to) - end_tol
+ if lo >= hi:
+ return False
+ for a, b in segments:
+ a_along = a[idx]
+ b_along = b[idx]
+ if abs(b_along - a_along) < 1e-9:
+ continue # parallel to the witness direction
+ s_lo = min(a_along, b_along)
+ s_hi = max(a_along, b_along)
+ if not (s_lo + val_tol < value < s_hi - val_tol):
+ continue # outside, or an endpoint touch (shared corner)
+ t = (value - a_along) / (b_along - a_along)
+ perp_at = a[pidx] + t * (b[pidx] - a[pidx])
+ if lo < perp_at < hi:
+ return True
+ return False
+
+
+def cluster_exterior_runs(records, segments, axis, side, max_drag_ft=0.0):
+ """Split one (axis, side) direction bucket into clusters whose
+ witness lines never cross a wall.
+
+ records: [(perp_extreme, witness_values), ...] - one per run;
+ perp_extreme is the run's own outermost perpendicular coordinate in
+ the `side` direction, witness_values its tier-1 values (jogs +
+ openings - exactly where witness lines will stand).
+ segments: frame-local wall segments of the SAME selection and frame
+ (only selected walls may block a merge).
+ max_drag_ft: optional extra rule - 0 disables it; otherwise a run
+ whose face would sit farther than this behind the cluster's base
+ starts its own cluster even without a crossing.
+
+ Runs are visited outermost first, so a cluster's base is fixed by
+ its first member and membership can never invalidate retroactively.
+ Returns a list of clusters, each a list of record indices, in
+ outermost-first creation order. With one mass and no crossings this
+ returns a single cluster = exact v3.4 behavior."""
+ order = sorted(range(len(records)),
+ key=lambda i: -side * records[i][0])
+ clusters = [] # [{"base": float, "members": [record index]}]
+ for i in order:
+ perp_extreme, witness_values = records[i]
+ joined = False
+ for cluster in clusters:
+ if max_drag_ft > 0.0:
+ drag = (cluster["base"] - perp_extreme) * side
+ if drag > max_drag_ft:
+ continue
+ blocked = False
+ for v in witness_values:
+ if witness_crosses(v, perp_extreme, cluster["base"],
+ axis, segments):
+ blocked = True
+ break
+ if not blocked:
+ cluster["members"].append(i)
+ joined = True
+ break
+ if not joined:
+ clusters.append({"base": perp_extreme, "members": [i]})
+ return [c["members"] for c in clusters]
diff --git a/extensions/SwichDesign.extension/lib/autodimswichdesign/revit_io.py b/extensions/SwichDesign.extension/lib/autodimswichdesign/revit_io.py
new file mode 100644
index 0000000000..8cefcae2de
--- /dev/null
+++ b/extensions/SwichDesign.extension/lib/autodimswichdesign/revit_io.py
@@ -0,0 +1,886 @@
+# -*- coding: utf-8 -*-
+"""All Revit API interaction for the Automatic Dimensions rules (IronPython).
+
+Reference strategy for dimension anchoring, in priority order:
+ 1. openings: the family's centerline reference (CenterLeftRight,
+ then CenterFrontBack) - US practice dimensions openings to CL.
+ 2. wall positions: a VERTICAL PLANAR face whose normal is parallel
+ to the dimension axis, near the target point IN PLAN (z ignored -
+ the first live failure was caused by comparing against z=0 on an
+ upper-floor plan). Exterior-side faces preferred. Mitered corner
+ faces (~45 deg) are rejected by the 0.9 normal-alignment gate.
+ 3. fallback: the wall LOCATION-LINE ENDPOINT reference (requires
+ Options.IncludeNonVisibleObjects) - covers joined/mitered wall
+ ends that expose no clean axis-facing face.
+"""
+from autodimswichdesign import geometry
+
+from Autodesk.Revit.DB import (
+ UV,
+ XYZ,
+ Line,
+ Options,
+ FamilyInstance,
+ FamilyInstanceReferenceType,
+ FilteredElementCollector,
+ BuiltInCategory,
+ BuiltInParameter,
+ ElementId,
+ PlanViewPlane,
+ Reference,
+ ReferenceArray,
+ SpatialElementBoundaryLocation,
+ SpatialElementBoundaryOptions,
+ Wall,
+ WallKind,
+)
+
+FACE_NORMAL_MIN = 0.9 # rejects mitered (~0.707) corner faces
+FACE_PLANE_MAX_FT = 1.5 # max plan distance target-to-face-plane
+ENDPOINT_MATCH_FT = 0.1
+
+
+BELOW_LEVEL_TOL_FT = 3.0 # a wall based more than this below the
+ # view's level belongs to the level below,
+ # even when it rises through the cut plane
+
+
+def view_cut_elevation(doc, view):
+ """Absolute elevation of the plan view's cut plane, or None when
+ unavailable (non-plan view, odd view range)."""
+ try:
+ view_range = view.GetViewRange()
+ level = doc.GetElement(
+ view_range.GetLevelId(PlanViewPlane.CutPlane))
+ if level is None:
+ return None
+ return (level.ProjectElevation
+ + view_range.GetOffset(PlanViewPlane.CutPlane))
+ except Exception:
+ return None
+
+
+def make_view_wall_filter(doc, view):
+ """Predicate: does this wall belong to THIS view's level?
+
+ Two conditions (both live-motivated - below-level walls hijacked
+ dimension references, and a tall below-level wall survived a
+ cut-plane-only test):
+ 1. the wall's solid crosses the view's cut plane, AND
+ 2. the wall's base is not more than BELOW_LEVEL_TOL_FT below the
+ view's generate-level (kills walls based on the level below
+ that rise through the cut plane).
+ Degrades to always-True when the view has no cut plane/level."""
+ cut_z = view_cut_elevation(doc, view)
+ gen_level = getattr(view, "GenLevel", None)
+ base_min = None
+ if gen_level is not None:
+ try:
+ base_min = gen_level.ProjectElevation - BELOW_LEVEL_TOL_FT
+ except Exception:
+ base_min = None
+
+ def belongs(wall):
+ if cut_z is None and base_min is None:
+ return True
+ bbox = wall.get_BoundingBox(None)
+ if bbox is None:
+ return False
+ if cut_z is not None and not (
+ bbox.Min.Z - 0.1 <= cut_z <= bbox.Max.Z + 0.1):
+ return False
+ if base_min is not None and bbox.Min.Z < base_min:
+ return False
+ return True
+
+ return belongs
+
+
+def get_basic_walls(doc, view):
+ """Basic walls belonging to this plan view's level (cut by its cut
+ plane AND based on its level - see make_view_wall_filter).
+
+ NOTE: no Function=Exterior filtering anywhere anymore - live models
+ routinely mistype it (26 of 38 partitions in the test model), so
+ exterior mode relies on the user's manual selection instead."""
+ belongs = make_view_wall_filter(doc, view)
+ walls = []
+ collector = (FilteredElementCollector(doc, view.Id)
+ .OfCategory(BuiltInCategory.OST_Walls)
+ .WhereElementIsNotElementType())
+ for wall in collector:
+ if not isinstance(wall, Wall):
+ continue
+ wall_type = wall.WallType
+ if wall_type is None or wall_type.Kind != WallKind.Basic:
+ continue
+ if not belongs(wall):
+ continue
+ walls.append(wall)
+ return walls
+
+
+def _loop_area(pts):
+ """Shoelace area (sign ignored) - picks a room's outer loop."""
+ total = 0.0
+ for k in range(len(pts)):
+ x0, y0 = pts[k]
+ x1, y1 = pts[(k + 1) % len(pts)]
+ total += x0 * y1 - x1 * y0
+ return abs(total) / 2.0
+
+
+def get_room_polygon(room):
+ """The room's real boundary as ({'poly', 'wall_ids'}) or None.
+
+ poly[k] -> poly[k+1] is edge k, and wall_ids[k] is the ElementId of
+ the element that GENERATED that edge - so an edge index maps straight
+ back to the wall bounding the room there. This is the whole point of
+ using GetBoundarySegments instead of the room's bounding box: a bbox
+ cannot say WHICH wall bounds a room, which is why the old interior
+ code had to guess by nearest plane and ended up dimensioning to walls
+ across the plan.
+
+ SpatialElementBoundaryLocation.Finish puts the polygon exactly on the
+ room-facing FINISH faces. The core/finish switch is applied later, to
+ the face REFERENCE (core_face_reference) - the polygon stays on Finish
+ because it is only used for geometry (spans, scan lines, containment).
+
+ Curved boundary segments are reduced to their endpoints (v1 is
+ rectilinear); wall_ids[k] may be None for room-separation lines, which
+ the caller reports rather than silently dimensioning to nothing."""
+ options = SpatialElementBoundaryOptions()
+ options.SpatialElementBoundaryLocation = (
+ SpatialElementBoundaryLocation.Finish)
+ try:
+ loops = room.GetBoundarySegments(options)
+ except Exception:
+ return None
+ if not loops:
+ return None
+
+ best = None
+ best_area = 0.0
+ for loop in loops:
+ pts = []
+ wall_ids = []
+ for segment in loop:
+ try:
+ curve = segment.GetCurve()
+ except Exception:
+ continue
+ start = curve.GetEndPoint(0)
+ pts.append((start.X, start.Y))
+ # ElementId can come back null/invalid for walls protruding
+ # into the room (Building Coder #1046) - the caller falls back
+ # to matching the boundary line against any wall face there
+ wall_id = getattr(segment, "ElementId", None)
+ if wall_id is not None and wall_id == ElementId.InvalidElementId:
+ wall_id = None
+ wall_ids.append(wall_id)
+ if len(pts) < 3:
+ continue
+ area = _loop_area(pts)
+ if area > best_area:
+ best_area = area
+ best = {"poly": pts, "wall_ids": wall_ids}
+ return best
+
+
+def get_room_name(room):
+ """'NEW W.I.C 7.2' - name plus number, read from the room's own
+ parameters. Element.Name raised in the live run (every room came back
+ as the literal fallback 'Room', which made the notes unreadable), so
+ go to ROOM_NAME/ROOM_NUMBER directly and only then fall back."""
+ parts = []
+ for bip_name in ("ROOM_NAME", "ROOM_NUMBER"):
+ bip = getattr(BuiltInParameter, bip_name, None)
+ if bip is None:
+ continue
+ try:
+ param = room.get_Parameter(bip)
+ if param is not None and param.HasValue:
+ text = param.AsString()
+ if text:
+ parts.append(text)
+ except Exception:
+ pass
+ if parts:
+ return " ".join(parts)
+ try:
+ return room.Name
+ except Exception:
+ return "Room {0}".format(room.Id)
+
+
+def get_rooms(doc, view):
+ """Placed, bounded Rooms visible in the view, each with its real
+ boundary polygon: [{'room', 'name', 'poly', 'wall_ids'}]. Rooms whose
+ boundary cannot be read are skipped (reported by the caller)."""
+ rooms = []
+ collector = (FilteredElementCollector(doc, view.Id)
+ .OfCategory(BuiltInCategory.OST_Rooms)
+ .WhereElementIsNotElementType())
+ for room in collector:
+ try:
+ if room.Area <= 0:
+ continue # unplaced/unbounded
+ except Exception:
+ continue
+ boundary = get_room_polygon(room)
+ if boundary is None:
+ continue
+ name = get_room_name(room)
+ rooms.append({"room": room, "name": name,
+ "poly": boundary["poly"],
+ "wall_ids": boundary["wall_ids"]})
+ return rooms
+
+
+# fixtures/casework/columns dimensioned to their CENTRE (user rule 3).
+# Columns get both axes; the rest get one dimension to the nearest wall.
+FIXTURE_CATEGORIES = ("OST_PlumbingFixtures", "OST_Casework")
+COLUMN_CATEGORIES = ("OST_Columns", "OST_StructuralColumns")
+
+
+def get_room_objects(doc, view):
+ """Fixtures/casework/columns visible in the view, as
+ [{'inst', 'pt': (x, y), 'is_column': bool}].
+
+ Category enum members are resolved via getattr so a member missing in
+ a given Revit version degrades to 'that category contributes nothing'
+ instead of an import-time crash (same defensive pattern as
+ get_opening_width)."""
+ found = []
+ groups = ((FIXTURE_CATEGORIES, False), (COLUMN_CATEGORIES, True))
+ for names, is_column in groups:
+ for name in names:
+ bic = getattr(BuiltInCategory, name, None)
+ if bic is None:
+ continue
+ collector = (FilteredElementCollector(doc, view.Id)
+ .OfCategory(bic)
+ .WhereElementIsNotElementType())
+ for inst in collector:
+ if not isinstance(inst, FamilyInstance):
+ continue
+ point = getattr(inst.Location, "Point", None)
+ if point is None:
+ continue
+ found.append({"inst": inst,
+ "pt": (point.X, point.Y),
+ "is_column": is_column})
+ return found
+
+
+def face_at(faces, axis, value, inward, wall_id=None,
+ max_ft=FACE_PLANE_MAX_FT):
+ """The view-visible wall face that a room's boundary crossing sits on.
+
+ faces: records from collect_axis_faces(walls, axis, view)
+ value: the crossing's coordinate along `axis`
+ inward: +1 if the room lies on the +axis side of this face, -1 if on
+ the -axis side. The face's normal MUST point into the room -
+ that is what stops a dimension binding to the far side of a
+ wall and drawing its witness line through the wall body.
+ wall_id: when given, only that wall's faces are considered (the fast
+ path, from BoundarySegment.ElementId). Passing None searches
+ every wall's faces on this axis and matches by plane
+ coincidence - the fallback for the null-ElementId case.
+
+ Nearest matching plane wins. None if nothing matches."""
+ idx = 0 if axis == "x" else 1
+ best = None
+ best_d = None
+ for f in faces:
+ if wall_id is not None and f["wall"].Id != wall_id:
+ continue
+ if f["normal"][idx] * inward <= 0:
+ continue # face points away from the room
+ d = abs(f["origin"][idx] - value)
+ if d > max_ft:
+ continue
+ if best_d is None or d < best_d:
+ best_d = d
+ best = f
+ return best
+
+
+def get_wall_stats(doc, view):
+ """Diagnostic counts: how many walls the view-based collector sees
+ and how many survive each filter. NOTE: a wall outside the view
+ range or hidden by filters/worksets never reaches the collector at
+ all - 'visible' is the ceiling. Floors are irrelevant to all of
+ this; nothing in this extension reads floors."""
+ belongs = make_view_wall_filter(doc, view)
+ visible = 0
+ basic = 0
+ this_level = 0
+ collector = (FilteredElementCollector(doc, view.Id)
+ .OfCategory(BuiltInCategory.OST_Walls)
+ .WhereElementIsNotElementType())
+ for wall in collector:
+ if not isinstance(wall, Wall):
+ continue
+ visible += 1
+ wall_type = wall.WallType
+ if wall_type is None or wall_type.Kind != WallKind.Basic:
+ continue
+ basic += 1
+ if belongs(wall):
+ this_level += 1
+ return {"visible": visible, "basic": basic,
+ "this_level": this_level}
+
+
+def get_wall_endpoints(wall):
+ """LocationCurve endpoints as ((x, y), (x, y)). ValueError if curved."""
+ curve = getattr(wall.Location, "Curve", None)
+ if curve is None or not isinstance(curve, Line):
+ raise ValueError(
+ "Wall {0} is curved or has no straight location line"
+ .format(wall.Id))
+ p0 = curve.GetEndPoint(0)
+ p1 = curve.GetEndPoint(1)
+ return (p0.X, p0.Y), (p1.X, p1.Y)
+
+
+def get_hosted_openings(wall, doc, doors_only=False):
+ """Door/window FamilyInstances hosted by this wall."""
+ if doors_only:
+ categories = (BuiltInCategory.OST_Doors,)
+ else:
+ categories = (BuiltInCategory.OST_Doors,
+ BuiltInCategory.OST_Windows)
+ found = []
+ for bic in categories:
+ collector = (FilteredElementCollector(doc)
+ .OfCategory(bic)
+ .WhereElementIsNotElementType())
+ for inst in collector:
+ if (isinstance(inst, FamilyInstance)
+ and inst.Host is not None
+ and inst.Host.Id == wall.Id):
+ found.append(inst)
+ return found
+
+
+def get_opening_point(instance):
+ """(x, y) of a door/window, for projection math only."""
+ point = getattr(instance.Location, "Point", None)
+ if point is None:
+ raise ValueError(
+ "Opening {0} has no location point".format(instance.Id))
+ return (point.X, point.Y)
+
+
+def get_opening_centerline_reference(instance):
+ """Centerline Reference of a door/window, or ValueError naming the
+ element if its family exposes no centerline reference."""
+ for ref_type in (FamilyInstanceReferenceType.CenterLeftRight,
+ FamilyInstanceReferenceType.CenterFrontBack):
+ refs = list(instance.GetReferences(ref_type))
+ if refs:
+ return refs[0]
+ raise ValueError(
+ "Door/window {0} exposes no centerline reference - skipped"
+ .format(instance.Id))
+
+
+def get_opening_side_references(instance):
+ """(left_ref, right_ref) for rough-opening style dimensioning, from
+ the family's Left/Right references. Returns None if the family does
+ not expose both (caller falls back to centerline). The exact plane
+ (R.O. vs panel width) depends on how the family was built."""
+ lefts = list(instance.GetReferences(FamilyInstanceReferenceType.Left))
+ rights = list(instance.GetReferences(FamilyInstanceReferenceType.Right))
+ if lefts and rights:
+ return lefts[0], rights[0]
+ return None
+
+
+def get_opening_width(instance):
+ """Opening width in feet for positioning math (Rough Width preferred,
+ then Width), from instance then type. None if not found - caller
+ then uses centerline positioning for that opening.
+
+ Enum member names resolved via getattr because they could not be
+ verified against RevitAPI.dll offline - a missing member silently
+ degrades to the next candidate instead of crashing."""
+ holders = (instance, instance.Symbol)
+ for bip_name in ("FAMILY_ROUGH_WIDTH_PARAM",
+ "DOOR_WIDTH",
+ "WINDOW_WIDTH",
+ "FAMILY_WIDTH_PARAM"):
+ bip = getattr(BuiltInParameter, bip_name, None)
+ if bip is None:
+ continue
+ for holder in holders:
+ if holder is None:
+ continue
+ param = holder.get_Parameter(bip)
+ if param is not None and param.HasValue:
+ width = param.AsDouble()
+ if width > 0:
+ return width
+ return None
+
+
+def get_wall_centerline_reference(wall):
+ """Reference of the wall's invisible location line, for interior
+ partition-centerline dimensioning. None if not found."""
+ try:
+ curve = wall.Location.Curve
+ c0 = curve.GetEndPoint(0)
+ c1 = curve.GetEndPoint(1)
+ except Exception:
+ return None
+
+ options = Options()
+ options.ComputeReferences = True
+ options.IncludeNonVisibleObjects = True
+ for geom_obj in wall.get_Geometry(options):
+ if not isinstance(geom_obj, Line):
+ continue
+ if geom_obj.Reference is None:
+ continue
+ g0 = geom_obj.GetEndPoint(0)
+ g1 = geom_obj.GetEndPoint(1)
+ # match against the location curve (either direction)
+ same = ((g0.DistanceTo(c0) < 0.5 and g1.DistanceTo(c1) < 0.5)
+ or (g0.DistanceTo(c1) < 0.5 and g1.DistanceTo(c0) < 0.5))
+ if same:
+ return geom_obj.Reference
+ return None
+
+
+def get_instance_center_reference(instance, axis, frame=None):
+ """Center reference of a fixture/casework instance for dimensioning
+ along `axis`. Picks CenterLeftRight vs CenterFrontBack based on the
+ instance's HandOrientation (heuristic - families vary). None if the
+ family exposes neither. HandOrientation is compared in FRAME-LOCAL
+ coordinates, so a fixture in an angled wing picks the same reference
+ its orthogonal twin would."""
+ if frame is None:
+ frame = geometry.Frame(0.0)
+ hand = getattr(instance, "HandOrientation", None)
+ prefer_lr = True
+ if hand is not None:
+ local = frame.to_local((hand.X, hand.Y))
+ along = abs(local[0]) if axis == "x" else abs(local[1])
+ prefer_lr = along > 0.7
+ if prefer_lr:
+ order = (FamilyInstanceReferenceType.CenterLeftRight,
+ FamilyInstanceReferenceType.CenterFrontBack)
+ else:
+ order = (FamilyInstanceReferenceType.CenterFrontBack,
+ FamilyInstanceReferenceType.CenterLeftRight)
+ for ref_type in order:
+ refs = list(instance.GetReferences(ref_type))
+ if refs:
+ return refs[0]
+ return None
+
+
+def collect_axis_faces(walls, axis, view=None, frame=None):
+ """Pre-compute all dimensionable faces for the given axis.
+
+ Returns a list of dicts with keys ref/origin/normal/exterior:
+ vertical planar faces whose normal is parallel to `axis`
+ (within FACE_NORMAL_MIN), from every wall given. `exterior` is
+ True when the face normal points the same way as Wall.Orientation.
+
+ view: when given, geometry is computed VIEW-AWARE (Options.View),
+ so the returned face references are guaranteed VISIBLE in that
+ view. This is required for interior opening strings - their raw
+ model-geometry references produced dimensions that existed but
+ rendered in no view (forensics: bbox None). Room lines/exterior
+ call WITHOUT a view (default) and are intentionally unchanged.
+
+ frame: the building direction this pass is working in. `origin` and
+ `normal` come back in FRAME-LOCAL coordinates, and the axis-alignment
+ gate is applied to the LOCAL normal - so a wall of an angled wing
+ passes its own frame's gate exactly as an orthogonal wall passes the
+ world one. Frame(0) is the exact identity, so orthogonal models are
+ bit-for-bit unchanged. Without this, every face of a rotated wall has
+ normal components of ~0.7 and is rejected by FACE_NORMAL_MIN - which
+ is why angled buildings lost their anchors entirely.
+ """
+ if frame is None:
+ frame = geometry.Frame(0.0)
+ options = Options()
+ options.ComputeReferences = True
+ if view is not None:
+ options.View = view
+
+ faces = []
+ for wall in walls:
+ orientation = getattr(wall, "Orientation", None)
+ for geom_obj in wall.get_Geometry(options):
+ solid_faces = getattr(geom_obj, "Faces", None)
+ if solid_faces is None:
+ continue
+ for face in solid_faces:
+ bbox = face.GetBoundingBox()
+ mid = UV((bbox.Min.U + bbox.Max.U) / 2.0,
+ (bbox.Min.V + bbox.Max.V) / 2.0)
+ normal = face.ComputeNormal(mid)
+ if abs(normal.Z) > 0.1:
+ continue # top/bottom faces
+ local_n = frame.to_local((normal.X, normal.Y))
+ aligned = abs(local_n[0]) if axis == "x" else abs(local_n[1])
+ if aligned < FACE_NORMAL_MIN:
+ continue # long faces and mitered corner faces
+ if face.Reference is None:
+ continue
+ center = face.Evaluate(mid)
+ is_ext = (orientation is not None
+ and (normal.X * orientation.X
+ + normal.Y * orientation.Y) > 0.5)
+ faces.append({
+ "ref": face.Reference,
+ "wall": wall,
+ "origin": frame.to_local((center.X, center.Y)),
+ "normal": local_n,
+ "exterior": is_ext,
+ })
+ return faces
+
+
+def exterior_face_perps(walls, axis, frame=None):
+ """Perpendicular coordinates (frame-local) of the walls' exterior
+ LONG faces, for a run measuring along `axis`.
+
+ A run measuring along x is bounded by faces whose normals point in
+ y - so this collects the PERPENDICULAR axis's faces and returns
+ each one's plane position. Used to snap the dimension-line base
+ from the location-line extreme out to the real finish face
+ (geometry.snap_base_outward): measured per wall, never guessed
+ from Wall.Width, because the location line can sit anywhere in the
+ wall depending on its Location Line setting."""
+ perp_axis = "y" if axis == "x" else "x"
+ pidx = 1 if axis == "x" else 0
+ perps = []
+ for f in collect_axis_faces(walls, perp_axis, None, frame):
+ if f["exterior"]:
+ perps.append(f["origin"][pidx])
+ return perps
+
+
+def find_face_reference(axis_faces, pt_xy, prefer_exterior=True):
+ """Best face record for a target plan point, or None if nothing
+ within FACE_PLANE_MAX_FT.
+
+ prefer_exterior=True (exterior mode): exterior shell faces win
+ first, then plane distance - the consistent perimeter drafter rule.
+ prefer_exterior=False (interior strings): nearest plane wins -
+ partitions' 'exterior' flag is arbitrary and preferring it made
+ witness lines jump through walls (live feedback)."""
+ best = None
+ best_key = None
+ for f in axis_faces:
+ ox, oy = f["origin"]
+ nx, ny = f["normal"]
+ plane_d = abs(nx * (pt_xy[0] - ox) + ny * (pt_xy[1] - oy))
+ if plane_d > FACE_PLANE_MAX_FT:
+ continue
+ center_d = ((pt_xy[0] - ox) ** 2 + (pt_xy[1] - oy) ** 2) ** 0.5
+ if prefer_exterior:
+ key = (0 if f["exterior"] else 1, round(plane_d, 1), center_d)
+ else:
+ key = (round(plane_d, 1), center_d)
+ if best_key is None or key < best_key:
+ best_key = key
+ best = f
+ return best
+
+
+def outermost_same_face(axis_faces, face_info, axis, window_ft=0.5):
+ """The outermost face of the SAME wall on the SAME side as face_info.
+
+ A wall solid can present more than one face on one side, a fraction of
+ an inch apart (finish layer vs what sits behind it). find_face_reference
+ scores by distance to the target, so it lands on the NEARER of them -
+ i.e. one step INSIDE the finish face. Live audit, wall 5298947:
+
+ --> @ 10'-5.1" | outer | 0.267 ft from target <- was chosen
+ @ 10'-5.4" | outer | 0.297 ft from target <- the finish face
+
+ This walks outward along the face normal to the last face of that same
+ wall on that same side. It CANNOT change which wall or which side was
+ chosen - so a wall that exposes a single face per side is unaffected,
+ and only the reported defect moves."""
+ idx = 0 if axis == "x" else 1
+ if abs(face_info["normal"][idx]) < 0.5:
+ return face_info
+
+ # the decision itself is pure and unit-tested (geometry.outermost_index,
+ # exercised against the real face-audit numbers from the live model)
+ faces = [face_info] + [f for f in axis_faces if f is not face_info]
+ items = [(str(f["wall"].Id), f["origin"][idx], f["normal"][idx])
+ for f in faces]
+ return faces[geometry.outermost_index(items, 0, window_ft)]
+
+
+def face_candidates(axis_faces, pt_xy, max_ft=FACE_PLANE_MAX_FT):
+ """Every face find_face_reference could legally have picked for this
+ target point, nearest plane first.
+
+ Diagnostic only. Exterior dimensions were reported landing an arbitrary
+ distance INSIDE the finish face, which means the wrong plane is winning
+ the contest in find_face_reference - this shows the whole contest
+ (which wall, which coordinate, exterior side or not, how far) so the
+ losing/winning faces can be compared against the model instead of
+ guessed at."""
+ out = []
+ for f in axis_faces:
+ ox, oy = f["origin"]
+ nx, ny = f["normal"]
+ plane_d = abs(nx * (pt_xy[0] - ox) + ny * (pt_xy[1] - oy))
+ if plane_d > max_ft:
+ continue
+ out.append({"wall_id": f["wall"].Id,
+ "origin": f["origin"],
+ "normal": f["normal"],
+ "exterior": f["exterior"],
+ "plane_d": plane_d})
+ out.sort(key=lambda c: c["plane_d"])
+ return out
+
+
+def get_jamb_faces(instance, axis, center_value, width, frame=None):
+ """R.O. fallback when a door/window family exposes no Left/Right
+ references: the opening CUT itself creates real jamb faces in the
+ HOST WALL's solid, with normals along the wall's run axis. Returns
+ (left_face, right_face) face records nearest the opening center on
+ each side, or None. center_value is frame-local."""
+ host = getattr(instance, "Host", None)
+ if host is None or not isinstance(host, Wall):
+ return None
+ idx = 0 if axis == "x" else 1
+ window = (width / 2.0 + 0.75) if width else 2.5
+ left = None
+ right = None
+ for face in collect_axis_faces([host], axis, None, frame):
+ delta = face["origin"][idx] - center_value
+ if -window <= delta < 0:
+ if left is None or face["origin"][idx] > left["origin"][idx]:
+ left = face
+ elif 0 < delta <= window:
+ if right is None or face["origin"][idx] < right["origin"][idx]:
+ right = face
+ if left is not None and right is not None:
+ return left, right
+ return None
+
+
+def jamb_faces_from(faces, center_value, idx, width):
+ """From a precollected face list (typically ONE host wall's
+ view-aware axis faces), the two faces flanking an opening center -
+ the rough-opening jambs. Returns (left, right) or None."""
+ window = (width / 2.0 + 0.75) if width else 2.5
+ left = None
+ right = None
+ for face in faces:
+ delta = face["origin"][idx] - center_value
+ if -window <= delta < 0:
+ if left is None or face["origin"][idx] > left["origin"][idx]:
+ left = face
+ elif 0 < delta <= window:
+ if right is None or face["origin"][idx] < right["origin"][idx]:
+ right = face
+ if left is not None and right is not None:
+ return left, right
+ return None
+
+
+def _shell_thicknesses(wall):
+ """(exterior_shell_ft, interior_shell_ft, total_ft) from the wall
+ type's CompoundStructure - the finish thicknesses outside the core
+ on each side. None if the wall has no core."""
+ structure = wall.WallType.GetCompoundStructure()
+ if structure is None:
+ return None
+ first = structure.GetFirstCoreLayerIndex()
+ last = structure.GetLastCoreLayerIndex()
+ if first < 0 or last < 0:
+ return None
+ ext = 0.0
+ for i in range(first):
+ ext += structure.GetLayerWidth(i)
+ inte = 0.0
+ for i in range(last + 1, structure.LayerCount):
+ inte += structure.GetLayerWidth(i)
+ return ext, inte, structure.GetWidth()
+
+
+def calibrate_core_indices(wall, view, doc, frame=None):
+ """Find which stable-reference indices are THIS wall's core faces,
+ by MEASUREMENT instead of the fixed 1-4 table: the fixed table
+ ("{UniqueId}:-9999:{index}", Building Coder #1684) is unreliable -
+ both that article and a Dynamo-forum reverse-engineering thread
+ confirm the index varies between wall types with no known formula.
+
+ Method: for each candidate index, create a TEMPORARY dimension from
+ the wall's exterior finish face to the candidate reference, read
+ its value, delete it, and keep the index whose measured distance
+ equals the shell thickness computed from the CompoundStructure.
+ Must run inside an open Transaction. Returns
+ {"exterior": idx, "interior": idx} (possibly partial) or None."""
+ shells = _shell_thicknesses(wall)
+ if shells is None:
+ return None
+ ext_shell, int_shell, total = shells
+
+ if frame is None:
+ frame = geometry.Frame(0.0)
+ orientation = getattr(wall, "Orientation", None)
+ if orientation is None:
+ return None
+ # the wall's own frame decides which local axis its faces face along -
+ # using world X/Y here found no faces on an angled wall, so core mode
+ # silently fell back to finish for the whole wing
+ local_o = frame.to_local((orientation.X, orientation.Y))
+ axis = "x" if abs(local_o[0]) >= abs(local_o[1]) else "y"
+ ext_face = None
+ for f in collect_axis_faces([wall], axis, None, frame):
+ if f["exterior"]:
+ ext_face = f
+ break
+ if ext_face is None:
+ return None
+
+ curve = getattr(wall.Location, "Curve", None)
+ if curve is None:
+ return None
+ mid = curve.Evaluate(0.5, True)
+ p0 = XYZ(mid.X - orientation.X * 5.0, mid.Y - orientation.Y * 5.0, mid.Z)
+ p1 = XYZ(mid.X + orientation.X * 5.0, mid.Y + orientation.Y * 5.0, mid.Z)
+ line = Line.CreateBound(p0, p1)
+
+ layer_count = wall.WallType.GetCompoundStructure().LayerCount
+ tol = 0.004 # ~1/20"
+ found = {}
+ for idx in range(0, 2 * layer_count + 6):
+ stable = "{0}:-9999:{1}".format(wall.UniqueId, idx)
+ try:
+ ref = Reference.ParseFromStableRepresentation(doc, stable)
+ except Exception:
+ continue
+ ref_array = ReferenceArray()
+ ref_array.Append(ext_face["ref"])
+ ref_array.Append(ref)
+ dim = None
+ try:
+ dim = doc.Create.NewDimension(view, line, ref_array)
+ value = dim.Value
+ except Exception:
+ value = None
+ if dim is not None:
+ doc.Delete(dim.Id)
+ if value is None:
+ continue
+ if "exterior" not in found and abs(value - ext_shell) <= tol:
+ found["exterior"] = idx
+ if "interior" not in found and abs(value - (total - int_shell)) <= tol:
+ found["interior"] = idx
+ if "exterior" in found and "interior" in found:
+ break
+ return found or None
+
+
+def core_face_reference(face_info, view, doc, cache, notes, frame=None):
+ """Reference to the wall's CORE boundary on the same side as the
+ given finish face ("face of stud"). Uses per-wall measured
+ calibration (see calibrate_core_indices), cached by wall id.
+ Returns None (caller falls back to finish face) when the wall has
+ no core or calibration finds nothing."""
+ wall = face_info["wall"]
+ key = str(wall.Id)
+ if key not in cache:
+ try:
+ cache[key] = calibrate_core_indices(wall, view, doc, frame)
+ except Exception as ex:
+ cache[key] = None
+ notes.append("Wall {0}: core calibration raised {1}".format(
+ wall.Id, ex))
+ if cache[key] is None:
+ notes.append(
+ "Wall {0}: no core reference found - finish face used"
+ .format(wall.Id))
+ calibration = cache[key]
+ if not calibration:
+ return None
+ side = "exterior" if face_info["exterior"] else "interior"
+ idx = calibration.get(side)
+ if idx is None:
+ return None
+ stable = "{0}:-9999:{1}".format(wall.UniqueId, idx)
+ return Reference.ParseFromStableRepresentation(doc, stable)
+
+
+def get_centerline_end_reference(wall, pt_xy):
+ """Fallback: Reference of the wall location-line endpoint at pt_xy.
+ Requires IncludeNonVisibleObjects (the location line is invisible
+ geometry). Returns None if not found."""
+ options = Options()
+ options.ComputeReferences = True
+ options.IncludeNonVisibleObjects = True
+ for geom_obj in wall.get_Geometry(options):
+ if not isinstance(geom_obj, Line):
+ continue
+ for i in (0, 1):
+ end = geom_obj.GetEndPoint(i)
+ d = ((end.X - pt_xy[0]) ** 2 + (end.Y - pt_xy[1]) ** 2) ** 0.5
+ if d <= ENDPOINT_MATCH_FT:
+ ref = geom_obj.GetEndPointReference(i)
+ if ref is not None:
+ return ref
+ return None
+
+
+def create_dimension_tier(doc, view, references, axis,
+ value_range, perpendicular_position,
+ base_z=0.0, frame=None):
+ """One dimension string via Document.Create.NewDimension.
+ Caller manages the Transaction. Duplicate references (same stable
+ representation - e.g. a wall-end face that is also an opening jamb)
+ are silently collapsed; NewDimension rejects arrays containing the
+ same reference twice.
+
+ value_range and perpendicular_position are FRAME-LOCAL; the dimension
+ line is rotated back into world coordinates here. A dimension always
+ measures ALONG its line, so on an angled building a world-axis line
+ would return the cosine-shortened projection of the wall - the wrong
+ number. Frame(0) is the exact identity."""
+ if frame is None:
+ frame = geometry.Frame(0.0)
+ ref_array = ReferenceArray()
+ seen = []
+ for ref in references:
+ try:
+ stable = ref.ConvertToStableRepresentation(doc)
+ except Exception:
+ stable = None
+ if stable is not None and stable in seen:
+ continue
+ if stable is not None:
+ seen.append(stable)
+ ref_array.Append(ref)
+
+ lo, hi = value_range
+ margin = 1.0
+ if axis == "x":
+ local0 = (lo - margin, perpendicular_position)
+ local1 = (hi + margin, perpendicular_position)
+ else:
+ local0 = (perpendicular_position, lo - margin)
+ local1 = (perpendicular_position, hi + margin)
+
+ w0 = frame.to_world(local0)
+ w1 = frame.to_world(local1)
+ p0 = XYZ(w0[0], w0[1], base_z)
+ p1 = XYZ(w1[0], w1[1], base_z)
+
+ return doc.Create.NewDimension(
+ view, Line.CreateBound(p0, p1), ref_array)
diff --git a/extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py b/extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py
new file mode 100644
index 0000000000..e35a8c4a22
--- /dev/null
+++ b/extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py
@@ -0,0 +1,144 @@
+# -*- coding: utf-8 -*-
+"""US CD dimensioning constants for the Automatic Dimensions rules.
+
+SOURCES: values taken from the user-supplied reference document
+("United States CAD and Architectural Dimensioning Standards"), which
+summarizes NCS/ANSI conventions. They have NOT been verified against a
+licensed primary NCS copy (paywalled). Treat as project defaults, not
+certified standard values. See PROJECT_BRIEF.md section 1.
+
+UNITS: Revit's internal length unit is always decimal FEET - every
+constant here is feet.
+
+PAPER vs MODEL SPACE: these are PAPER (printed sheet) distances.
+For model-space placement multiply by the view scale (View.Scale):
+3/8" on paper at 1/8"=1'-0" (scale 96) is 3.0 ft in the model.
+
+IronPython 2.7 note: 1/16 is INTEGER division = 0 there, which would
+silently zero these constants - hence the __future__ import and float
+literals. Keep both.
+"""
+from __future__ import division
+
+INCH = 1.0 / 12.0
+
+# Gap between the object and the start of the extension line.
+EXTENSION_LINE_GAP_FT = (1.0 / 16.0) * INCH # 1/16"
+
+# Spacing between stacked/parallel dimension tiers, and minimum
+# distance from the object outline to the first dimension line.
+TIER_SPACING_FT = (3.0 / 8.0) * INCH # 3/8"
+
+# --- exterior first-string offset (user-configurable, paper inches) ---
+#
+# Drafting convention (secondary sources; NCS primary text is paywalled,
+# see PROJECT_BRIEF section 1): the FIRST dimension line sits ~1/2" off
+# the object, SUBSEQUENT tiers 3/8" apart. The old code used 3/8" for
+# both, which put the first string too close to the wall (live report).
+# Both values are PAPER inches; multiply by View.Scale for model feet.
+
+TIER_SPACING_DEFAULT_IN = 0.375 # 3/8" between stacked tiers
+FIRST_OFFSET_DEFAULT_IN = 0.75 # fallback when scale is unlisted
+
+# "Auto" preset: constant paper offset reads cramped at large scales and
+# wasteful at small ones, so the preset tapers with the view scale.
+SCALE_FIRST_OFFSET_IN = {
+ 12: 1.0, # 1" = 1'-0"
+ 16: 1.0, # 3/4" = 1'-0"
+ 24: 0.75, # 1/2" = 1'-0"
+ 32: 0.75, # 3/8" = 1'-0"
+ 48: 0.75, # 1/4" = 1'-0"
+ 64: 0.625, # 3/16" = 1'-0"
+ 96: 0.5, # 1/8" = 1'-0"
+}
+
+
+def first_offset_for_scale(scale):
+ """Preset first-string offset (paper inches) for a view scale.
+ Exact table hit, else the nearest listed scale, else the default."""
+ if scale in SCALE_FIRST_OFFSET_IN:
+ return SCALE_FIRST_OFFSET_IN[scale]
+ best = None
+ best_d = None
+ for key in SCALE_FIRST_OFFSET_IN:
+ d = abs(key - scale)
+ if best_d is None or d < best_d:
+ best_d = d
+ best = key
+ if best is None:
+ return FIRST_OFFSET_DEFAULT_IN
+ return SCALE_FIRST_OFFSET_IN[best]
+
+
+def parse_paper_inches(text):
+ """User-typed paper distance -> float inches, or None.
+
+ Accepts '0.75', '3/4', '3/4"', '1 1/2"', '1-1/2'. Anything else
+ (including the 'Auto (by view scale)' combo entry) returns None -
+ the caller decides what None means."""
+ if text is None:
+ return None
+ s = str(text).strip().replace('"', '').replace("''", '')
+ if not s:
+ return None
+ s = s.replace('-', ' ')
+ parts = s.split()
+ total = 0.0
+ seen = False
+ for part in parts:
+ if '/' in part:
+ top_bot = part.split('/')
+ if len(top_bot) != 2:
+ return None
+ try:
+ total += float(top_bot[0]) / float(top_bot[1])
+ seen = True
+ except (ValueError, ZeroDivisionError):
+ return None
+ else:
+ try:
+ total += float(part)
+ seen = True
+ except ValueError:
+ return None
+ return total if seen else None
+
+# Minimum overall extension line length.
+MIN_EXTENSION_LINE_FT = (9.0 / 16.0) * INCH # 9/16"
+
+# Minimum plotted text height for dimensions/annotation.
+MIN_TEXT_HEIGHT_FT = (3.0 / 32.0) * INCH # 3/32"
+
+# Exterior dimensioning targets: structural stud faces at run ends,
+# centerlines of door/window openings. (Informational - enforced by
+# which references revit_io functions return.)
+EXTERIOR_TARGET = "stud_face_to_opening_centerline"
+
+# Terminator per US architectural practice is the oblique tick, but the
+# actual terminator comes from the DimensionType the user has active -
+# not enforced in code.
+TERMINATOR = "tick"
+
+# Opening dimensioning modes (user-selectable per run of the tool):
+# 'center' - dimension to the opening centerline (classic exterior practice)
+# 'ro' - dimension to both sides of the opening via the family's
+# Left/Right references (rough-opening style; the exact plane
+# depends on how the family's width references were built)
+OPENING_MODE_CENTER = "center"
+OPENING_MODE_RO = "ro"
+
+
+def format_ft_in(value_ft):
+ """Decimal feet -> readable feet-inches string.
+
+ Normalizes the inches component so 34.9967 ft renders as 35'-0.0",
+ never 34'-12.0" (live-observed rounding bug).
+ """
+ sign = "-" if value_ft < 0 else ""
+ total_in = abs(value_ft) * 12.0
+ feet = int(total_in // 12)
+ inches = total_in - feet * 12
+ if inches >= 11.95:
+ feet += 1
+ inches = 0.0
+ return "{0}{1}'-{2:.1f}\"".format(sign, feet, inches)
diff --git a/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/AutoDimWindow.xaml b/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/AutoDimWindow.xaml
new file mode 100644
index 0000000000..aad5388c6d
--- /dev/null
+++ b/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/AutoDimWindow.xaml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/bundle.yaml b/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/bundle.yaml
new file mode 100644
index 0000000000..9ffe625dd1
--- /dev/null
+++ b/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/bundle.yaml
@@ -0,0 +1,163 @@
+title:
+ en_us: |-
+ Automatic
+ Dimensions
+ ko: |-
+ 자동
+ 치수
+ fr_fr: |-
+ Cotation
+ automatique
+ ru: |-
+ Авторазмеры
+ chinese_s: 自动标注
+ es_es: |-
+ Acotación
+ automática
+ de_de: |-
+ Automatische
+ Bemaßung
+ pt_br: |-
+ Cotagem
+ automática
+tooltip:
+ en_us: |-
+ Places dimension strings automatically on the active floor plan.
+
+ Exterior:
+ Select the perimeter walls first. Places one 3-tier string per facing
+ direction: wall jogs + openings, then jogs only, then the overall side.
+
+ Interior:
+ Needs placed Rooms, and ignores your selection. Per room it places the
+ room lengths (a quarter of the room depth off a parallel wall), one
+ string per wall that hosts openings, and a dimension from the centre of
+ each plumbing fixture, casework item and column to the nearest wall.
+ Every string is bounded by the room, so it never measures through a wall.
+
+ Walls can be measured to their finish face or their core (stud) face,
+ and openings to their centreline or their rough opening. Both choices
+ are remembered between runs.
+
+ Run a Dry run first: it reports every dimension it would place, and
+ changes nothing in the model.
+ ko: |-
+ 활성 평면도에 치수를 자동으로 배치합니다.
+
+ 외부: 외벽을 먼저 선택하세요. 방향별로 3단 치수(벽 꺾임 + 개구부 / 꺾임 / 전체)를 배치합니다.
+
+ 내부: 배치된 방(Room)이 필요하며 선택은 무시됩니다. 방마다 방 크기, 개구부가 있는 벽마다 치수, 위생기구·붙박이장·기둥 중심에서 가장 가까운 벽까지의 치수를 배치합니다. 모든 치수는 방 경계 안에 있으므로 벽을 관통하지 않습니다.
+
+ 벽은 마감면 또는 코어(스터드)면으로, 개구부는 중심선 또는 러프 오프닝으로 측정할 수 있으며, 선택한 설정은 다음 실행에도 유지됩니다.
+
+ 먼저 시험 실행(Dry run)을 사용하세요. 모델을 변경하지 않고 배치될 치수만 보고합니다.
+ fr_fr: |-
+ Place automatiquement les cotes sur le plan actif.
+
+ Extérieur :
+ Sélectionnez d'abord les murs périmétriques. Place une chaîne de cotes à
+ 3 niveaux par direction : décrochements + ouvertures, décrochements
+ seuls, puis la cote totale.
+
+ Intérieur :
+ Nécessite des pièces placées et ignore la sélection. Pour chaque pièce :
+ les dimensions de la pièce (placées au quart de la profondeur depuis un
+ mur parallèle), une chaîne par mur comportant des ouvertures, et une cote
+ du centre de chaque appareil sanitaire, meuble et poteau au mur le plus
+ proche. Chaque chaîne est limitée à la pièce et ne traverse jamais un mur.
+
+ Les murs peuvent être cotés au nu de finition ou au nu de l'ossature, et
+ les ouvertures à l'axe ou au tableau. Ces choix sont mémorisés.
+
+ Lancez d'abord un test (Dry run) : il rapporte les cotes sans rien
+ modifier dans le modèle.
+ ru: |-
+ Автоматически расставляет размеры на активном плане.
+
+ Снаружи:
+ Сначала выберите наружные стены. Для каждого направления создаётся
+ трёхуровневая цепочка: уступы стен и проёмы, только уступы, общий размер.
+
+ Внутри:
+ Требуются размещённые помещения; выделение игнорируется. Для каждого
+ помещения: его габариты (на расстоянии четверти глубины от параллельной
+ стены), цепочка для каждой стены с проёмами и размер от центра каждого
+ сантехприбора, элемента мебели и колонны до ближайшей стены. Каждая
+ цепочка ограничена помещением и никогда не проходит сквозь стену.
+
+ Стены можно обмерять по чистовой отделке или по несущему слою, а проёмы —
+ по оси или по проёму в стене. Выбор сохраняется между запусками.
+
+ Сначала выполните пробный запуск (Dry run): он покажет результат, ничего
+ не меняя в модели.
+ chinese_s: |-
+ 在当前平面视图中自动放置尺寸标注。
+
+ 外部:请先选择外墙。按每个朝向放置三层标注:墙体转折与洞口、仅转折、总长。
+
+ 内部:需要已放置的房间,并忽略当前选择。每个房间将放置房间净尺寸(距平行墙约四分之一进深处)、每面含洞口墙体的标注,以及每个卫浴设备、固定家具和柱子中心到最近墙体的标注。所有标注均限定在房间内,绝不会穿墙。
+
+ 墙体可标注至面层或核心层(龙骨)面,洞口可标注至中心线或粗洞口。所选设置会在下次运行时保留。
+
+ 建议先执行试运行(Dry run):仅报告将要放置的标注,不修改模型。
+ es_es: |-
+ Coloca automáticamente las cotas en la planta activa.
+
+ Exterior:
+ Seleccione primero los muros perimetrales. Coloca una cadena de 3 niveles
+ por orientación: quiebres y huecos, solo quiebres, y la cota total.
+
+ Interior:
+ Requiere habitaciones colocadas e ignora la selección. Por habitación
+ coloca sus dimensiones (a un cuarto de la profundidad desde un muro
+ paralelo), una cadena por cada muro con huecos, y una cota desde el centro
+ de cada aparato sanitario, mobiliario y pilar al muro más cercano. Cada
+ cadena queda dentro de la habitación, por lo que nunca mide a través de
+ un muro.
+
+ Los muros pueden medirse a la cara de acabado o a la del núcleo, y los
+ huecos al eje o al hueco en bruto. Ambas opciones se recuerdan.
+
+ Ejecute primero una prueba (Dry run): informa de las cotas sin modificar
+ el modelo.
+ de_de: |-
+ Setzt Bemaßungen automatisch im aktiven Grundriss.
+
+ Außen:
+ Wählen Sie zuerst die Außenwände. Pro Richtung wird eine dreistufige
+ Maßkette gesetzt: Wandversprünge und Öffnungen, nur Versprünge, Gesamtmaß.
+
+ Innen:
+ Benötigt platzierte Räume und ignoriert die Auswahl. Je Raum werden die
+ Raummaße (im Abstand eines Viertels der Raumtiefe von einer parallelen
+ Wand), eine Maßkette je Wand mit Öffnungen sowie ein Maß von der Mitte
+ jedes Sanitärobjekts, Einbaumöbels und jeder Stütze zur nächsten Wand
+ gesetzt. Jede Maßkette bleibt im Raum und misst nie durch eine Wand.
+
+ Wände können auf die Oberfläche oder auf die Tragschicht (Ständer) bemaßt
+ werden, Öffnungen auf Achse oder Rohbaumaß. Beide Einstellungen bleiben
+ erhalten.
+
+ Führen Sie zuerst einen Testlauf (Dry run) aus: Er meldet alle Maße, ohne
+ das Modell zu ändern.
+ pt_br: |-
+ Posiciona cotas automaticamente na planta ativa.
+
+ Exterior:
+ Selecione primeiro as paredes do perímetro. Posiciona uma cadeia de 3
+ níveis por orientação: recortes e aberturas, apenas recortes, e a cota
+ total.
+
+ Interior:
+ Requer ambientes posicionados e ignora a seleção. Por ambiente posiciona
+ as dimensões do ambiente (a um quarto da profundidade a partir de uma
+ parede paralela), uma cadeia por parede com aberturas e uma cota do centro
+ de cada louça sanitária, marcenaria e pilar até a parede mais próxima.
+ Cada cadeia fica dentro do ambiente e nunca mede através de uma parede.
+
+ As paredes podem ser medidas na face de acabamento ou na face do núcleo, e
+ as aberturas no eixo ou no vão bruto. As duas escolhas são lembradas.
+
+ Faça primeiro uma simulação (Dry run): ela relata as cotas sem alterar o
+ modelo.
+author: Roi Hason @ Swich Design
diff --git a/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/icon.dark.png b/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/icon.dark.png
new file mode 100644
index 0000000000000000000000000000000000000000..039c3d74c7bc242c7a9c250ef9a8958af959e7b1
GIT binary patch
literal 841
zcmV-P1GfB$P)JeL3n
z3Gru$|L0*V1mFyANuj=toY
zabk`j0>@hS&%OTq?HeA$m2&YOeaX?!$Ys_GcS3D(_S$gZqkW@ea1s5g0Z{&_8+2`m
z1WI3d3}pQ*0&tNG?-K+O03^AfX&0FSfpQFN0Hg%rQV5h|U;`k{s1POqkO2Vzm>~c_
zLjWZZ0HC4xB@mDWH5Q)=0d)Xz?I)pYVgf4R#9E(mmDdDj0En!m2%B2#;DTstORnnJ
zX99w7{@NOsa`f4NH1xbe%YWF&aR6{`m7BYj2>_jMwxJRpSbP9r7TFP|@l664!R?rJL8FOpDhQ7#zL_+1RPjv!7+HMv07e&IH2?|Xs|6rMe3e;{B=J?(
z0TRSlRSA26;;R85&F!diL6XH6AB3fgFFFm?Aih`tTErI#K$H050B94REe$Q{y`WM2
z(*czBUeGH3=>TebFK82g2mqf&sq!f3M5>iS^NGT~{m)+rz-3hkk2ePF9pqV{5S@=Jq2L@?c
zmSuwR8AJd80Du7i0F>Ig*=@7OW?#nav7h^THh_7vu-VI?=tuV2HJN(@SRs58#M%qM
z9Rg?Dy)^)c-w6W5XNHjD^K-I+>t@bS`sns^&dL7PQUG%z;669IAeaBu;F$=_a|wWu
z5PydFe=gP-gir$D1@W6fcu@hwd_UYy+#Qi(!O<7pA^}%R5DmMvP9BZ|^Nj-F=u55{
zC*}wuaIAIz-0RQZzTq)kDHre2mmK|!TxPv+C)5^auMG!2+BZ4|7tya80Og;$LDz;z
zp!Ai;K-SM902j&dK0y!xK#~iZc9AI%D96ACKuQoUg+Mt5HUQF$3Sj~O84v(~83F(_
z1W*D202+#40s&c2WAUjFPzMm#eiFJSCZG~dto0dJc}-vjfXG^ku&K2UE{L|a4tO
zCF}TinG@AIPg7Apqn@K}Q72gDak;PXJV07_S1CSuTS^!eSSD6J#5?^&4
zAVGXpm9Q5mz8V11+>R<2Bw2j%L0G!@qSH_f;)?~KMSPI}G>I<`fHv{j($JFL3mU~g
z9YAUC1+C(r4xqO8f;RDo0FZjzTeJA=E~w^(2Myx01mPTEt%%Q-hNc9zB0g&!(Cf8N
z03Z}%JzjhU0AeNF`qh5`D7Y2jjrsi{?*&@QgJ7^ozJ "menu does not save previous selection";
+# traced in pyRevit 6.5.3 userconfig.py, PROJECT_BRIEF session 42).
+# One json file per user under %APPDATA% is immune to all of it.
+
+SETTINGS_DIR = os.path.join(
+ os.getenv("APPDATA") or os.path.expanduser("~"), "SwichDesign")
+SETTINGS_FILE = os.path.join(SETTINGS_DIR, "autodim_settings.json")
+
+SETTING_KEYS = ("mode", "measure_core", "openings_ro", "dry_run",
+ "face_audit", "first_offset_text", "tier_spacing_text",
+ "max_drag_text")
+
+
+class ToolSettings(object):
+ """Per-user settings in autodim_settings.json. Same get_option/
+ set_option interface the dialog used with pyRevit's config.
+ Self-healing: a missing or unparseable file simply means defaults -
+ it can never disable saving the way a corrupt pyRevit ini does."""
+
+ def __init__(self):
+ self.values = {}
+ try:
+ with open(SETTINGS_FILE, "r") as f:
+ data = json.load(f)
+ if isinstance(data, dict):
+ self.values = data
+ except Exception:
+ self.values = {}
+ if not self.values:
+ self._migrate_from_pyrevit()
+
+ def _migrate_from_pyrevit(self):
+ """One-time pickup of settings saved by older versions through
+ script.get_config(), so nobody loses their choices. Best-effort:
+ a broken pyRevit config just yields defaults."""
+ try:
+ cfg = script.get_config()
+ for key in SETTING_KEYS:
+ val = cfg.get_option(key, None)
+ if val is not None:
+ self.values[key] = val
+ except Exception:
+ pass
+
+ def get_option(self, key, default=None):
+ return self.values.get(key, default)
+
+ def set_option(self, key, value):
+ self.values[key] = value
+
+ def save(self):
+ """Write the file; returns an error string instead of raising
+ (a failed settings save must never kill a dimension run). The
+ write goes to a temp file first so an interrupted write cannot
+ leave a half-written settings file - which is exactly how the
+ user's pyRevit ini most likely got corrupted."""
+ try:
+ if not os.path.isdir(SETTINGS_DIR):
+ os.makedirs(SETTINGS_DIR)
+ tmp = SETTINGS_FILE + ".tmp"
+ with open(tmp, "w") as f:
+ json.dump(self.values, f, indent=1, sort_keys=True)
+ if os.path.exists(SETTINGS_FILE):
+ os.remove(SETTINGS_FILE)
+ os.rename(tmp, SETTINGS_FILE)
+ return None
+ except Exception as ex:
+ return str(ex)
+
+
+def pyrevit_config_broken():
+ """True when pyRevit itself is running on an in-memory config (its
+ ini is corrupt/unreadable) - OUR settings still save fine, but the
+ user's pyRevit-wide settings will not, and they should know."""
+ try:
+ from pyrevit.userconfig import user_config
+ return not user_config.config_file
+ except Exception:
+ return False
+
+
+class AutoDimWindow(forms.WPFWindow):
+ """The Automatic Dimensions dialog. Every choice is remembered
+ between runs via the tool's OWN settings file (ToolSettings), so a
+ broken/relocated pyRevit config cannot make the menu forget."""
+
+ def __init__(self, wall_count, fixture_count):
+ forms.WPFWindow.__init__(self, XAML)
+ self.result = None
+ self.wall_count = wall_count
+ self.config = ToolSettings()
+
+ mode = self.config.get_option("mode", MODE_INT)
+ self.mode_ext_rb.IsChecked = (mode == MODE_EXT)
+ self.mode_int_rb.IsChecked = (mode != MODE_EXT)
+
+ core = bool(self.config.get_option("measure_core", False))
+ self.face_core_rb.IsChecked = core
+ self.face_finish_rb.IsChecked = not core
+
+ ro = bool(self.config.get_option("openings_ro", False))
+ self.open_ro_rb.IsChecked = ro
+ self.open_cl_rb.IsChecked = not ro
+
+ self.dry_cb.IsChecked = bool(self.config.get_option("dry_run", True))
+ self.audit_cb.IsChecked = bool(
+ self.config.get_option("face_audit", False))
+
+ # exterior offsets: editable combos, raw text persisted so the
+ # user sees exactly what they typed on the next run. Items are
+ # set from code (plain strings) - ComboBoxItem elements in XAML
+ # would turn .Text into "System.Windows.Controls.ComboBoxItem:...".
+ self.first_offset_cb.ItemsSource = [
+ AUTO_OFFSET_LABEL, '1/2"', '5/8"', '3/4"', '1"', '1 1/4"',
+ '1 1/2"']
+ self.first_offset_cb.Text = str(self.config.get_option(
+ "first_offset_text", AUTO_OFFSET_LABEL))
+ self.spacing_cb.ItemsSource = ['1/4"', '3/8"', '1/2"']
+ self.spacing_cb.Text = str(self.config.get_option(
+ "tier_spacing_text", '3/8"'))
+ self.split_tb.Text = str(self.config.get_option(
+ "max_drag_text", "0"))
+
+ status = "{0} wall(s), {1} element(s) selected.".format(
+ wall_count, fixture_count)
+ if pyrevit_config_broken():
+ status += (" NOTE: pyRevit's own config file is unreadable "
+ "on this machine - this tool saves its settings "
+ "separately and is unaffected, but pyRevit-wide "
+ "settings will not persist until it is repaired.")
+ self.status_tb.Text = status
+ self._update_hint()
+
+ def _update_hint(self):
+ if self.mode_ext_rb.IsChecked:
+ if self.wall_count:
+ self.mode_hint_tb.Text = (
+ "Dimensions the {0} wall(s) you selected, one string set "
+ "per facing direction.".format(self.wall_count))
+ else:
+ self.mode_hint_tb.Text = (
+ "Select the perimeter walls first - exterior mode "
+ "dimensions your selection.")
+ else:
+ self.mode_hint_tb.Text = (
+ "Whole view, driven by placed Rooms. Selection is ignored.")
+
+ def mode_changed(self, sender, args):
+ # fires during XAML load too, before the hint element exists
+ if getattr(self, "mode_hint_tb", None) is not None:
+ self._update_hint()
+
+ def dimension_clicked(self, sender, args):
+ # editable combos: read .Text (SelectedItem is null for typed
+ # values). Unparseable first offset -> auto; unparseable spacing
+ # -> default; both clamped to a 1/4" floor so a typo cannot put
+ # a string inside the wall poche.
+ first_text = self.first_offset_cb.Text
+ spacing_text = self.spacing_cb.Text
+ split_text = self.split_tb.Text
+
+ first_in = standards.parse_paper_inches(first_text)
+ if first_in is not None and first_in < 0.25:
+ first_in = 0.25
+ spacing_in = standards.parse_paper_inches(spacing_text)
+ if spacing_in is None:
+ spacing_in = standards.TIER_SPACING_DEFAULT_IN
+ elif spacing_in < 0.25:
+ spacing_in = 0.25
+ try:
+ max_drag_ft = float(str(split_text).strip())
+ except ValueError:
+ max_drag_ft = 0.0
+ if max_drag_ft < 0.0:
+ max_drag_ft = 0.0
+
+ self.result = {
+ "mode": MODE_EXT if self.mode_ext_rb.IsChecked else MODE_INT,
+ "measure_core": bool(self.face_core_rb.IsChecked),
+ "openings_ro": bool(self.open_ro_rb.IsChecked),
+ "dry_run": bool(self.dry_cb.IsChecked),
+ "face_audit": bool(self.audit_cb.IsChecked),
+ "first_offset_in": first_in, # None = auto per view scale
+ "tier_spacing_in": spacing_in,
+ "max_drag_ft": max_drag_ft,
+ }
+ for key in ("mode", "measure_core", "openings_ro", "dry_run",
+ "face_audit"):
+ self.config.set_option(key, self.result[key])
+ self.config.set_option("first_offset_text", first_text)
+ self.config.set_option("tier_spacing_text", spacing_text)
+ self.config.set_option("max_drag_text", split_text)
+ save_err = self.config.save()
+ if save_err:
+ # settings failing to save must never block dimensioning -
+ # main() surfaces this in the run notes
+ self.result["settings_warning"] = (
+ "Settings could not be saved ({0}) - this run works, "
+ "but choices will not be remembered.".format(save_err))
+ self.Close()
+
+ def cancel_clicked(self, sender, args):
+ self.result = None
+ self.Close()
+
+
+# ---------------------------------------------------------------- gather
+
+def gather_selection(view):
+ """Returns (walls, fixtures, skipped_walls). Fixtures are selected
+ standalone FamilyInstances (not wall-hosted - hosted ones are
+ openings and arrive via their host wall).
+
+ Selected walls pass the SAME view-level filter as auto-collection:
+ a crossing selection easily grabs below-level walls, and unfiltered
+ they hijacked references (live: 'wall below was still selected and
+ used' - the selection path bypassed the filter entirely).
+
+ There is NO Function=Exterior auto-detection anymore (user rule:
+ wall Function data cannot be trusted - same types get used both
+ ways, or nobody sets it). Exterior mode = manual selection;
+ interior mode = catch-all from the view's Rooms and walls."""
+ belongs = revit_io.make_view_wall_filter(doc, view)
+ walls = []
+ skipped = 0
+ fixtures = []
+ for el in revit.get_selection().elements:
+ if isinstance(el, Wall):
+ if belongs(el):
+ walls.append(el)
+ else:
+ skipped += 1
+ elif isinstance(el, FamilyInstance):
+ host = getattr(el, "Host", None)
+ if host is None or not isinstance(host, Wall):
+ if getattr(el.Location, "Point", None) is not None:
+ fixtures.append(el)
+ return walls, fixtures, skipped
+
+
+def build_runs(walls, notes, frame):
+ """Walls -> list of run dicts, in FRAME-LOCAL coordinates.
+
+ Every point that enters the geometry pipeline is rotated into the
+ frame here, at the boundary. Inside the pipeline an angled wing looks
+ exactly like an orthogonal building, so build_tiers/split_runs need no
+ changes at all - and with Frame(0) an orthogonal model is untouched."""
+ segments = []
+ seg_walls = []
+ openings_by_wall = {}
+ for wall in walls:
+ try:
+ p0, p1 = revit_io.get_wall_endpoints(wall)
+ except ValueError as ex:
+ notes.append("Skipped: {0}".format(ex))
+ continue
+ segments.append((frame.to_local(p0), frame.to_local(p1)))
+ seg_walls.append(wall)
+ pairs = []
+ for inst in revit_io.get_hosted_openings(wall, doc):
+ try:
+ pairs.append(
+ (frame.to_local(revit_io.get_opening_point(inst)), inst))
+ except ValueError as ex:
+ notes.append("Skipped opening: {0}".format(ex))
+ openings_by_wall[wall.Id] = pairs
+
+ runs = []
+ for pts, idxs, closed in geometry.order_segments(segments):
+ try:
+ parts = geometry.split_runs(pts, idxs, closed)
+ except geometry.GeometryError as ex:
+ notes.append("Skipped a wall group: {0}".format(ex))
+ continue
+ for run_pts, run_idxs in parts:
+ run_walls = []
+ for i in run_idxs:
+ if i is None:
+ continue # gap-bridged connector, no source wall
+ if seg_walls[i] not in run_walls:
+ run_walls.append(seg_walls[i])
+ run_openings = []
+ for w in run_walls:
+ run_openings.extend(openings_by_wall.get(w.Id, []))
+ try:
+ tiers = geometry.build_tiers(
+ run_pts, [pt for pt, _ in run_openings])
+ except geometry.GeometryError as ex:
+ notes.append("Skipped one side: {0}".format(ex))
+ continue
+ runs.append({"pts": run_pts, "walls": run_walls,
+ "openings": run_openings, "tiers": tiers})
+ return runs
+
+
+# ------------------------------------------------- entries (value+kind)
+
+def opening_entries(run, ro_mode, notes, frame):
+ """[(value, kind, payload)] for the run's openings.
+ kind 'open_c' payload=instance; 'open_side' payload=Reference;
+ 'jamb' payload=face record (host-wall cut face, no core swap).
+
+ R.O. resolution order (USER RULING: the wall's cut opening IS the
+ R.O. - jamb faces first; they are also the same reference species
+ as the room-line faces that demonstrably survive placement, while
+ family references are suspected of being silently deleted by
+ Revit's commit-time failure resolution):
+ 1. the HOST WALL's jamb faces created by the opening cut,
+ 2. family Left/Right references,
+ 3. centerline, with a note."""
+ axis = run["tiers"]["axis"]
+ idx = 0 if axis == "x" else 1
+ entries = []
+ for pt, inst in run["openings"]:
+ center = pt[idx]
+ if ro_mode:
+ width = revit_io.get_opening_width(inst)
+ jambs = revit_io.get_jamb_faces(inst, axis, center, width, frame)
+ if jambs is not None:
+ entries.append(
+ (jambs[0]["origin"][idx], "jamb", jambs[0]))
+ entries.append(
+ (jambs[1]["origin"][idx], "jamb", jambs[1]))
+ continue
+ sides = revit_io.get_opening_side_references(inst)
+ if sides is not None:
+ half = (width / 2.0) if width else 0.5
+ entries.append((center - half, "open_side", sides[0]))
+ entries.append((center + half, "open_side", sides[1]))
+ continue
+ notes.append(
+ "Opening {0}: no jamb faces and no Left/Right "
+ "references - using centerline".format(inst.Id))
+ entries.append((center, "open_c", inst))
+ return entries
+
+
+def wallpoint_entries(run, values):
+ """[(value, 'wallpt', pt)] mapping tier values back to run points."""
+ axis = run["tiers"]["axis"]
+ idx = 0 if axis == "x" else 1
+ entries = []
+ for v in values:
+ for p in run["pts"]:
+ if abs(p[idx] - v) <= geometry.TIER_MERGE_TOL_FT:
+ entries.append((v, "wallpt", p))
+ break
+ return entries
+
+
+def dedupe_entries(entries):
+ """Drop entries within tolerance of an earlier one (list order =
+ anchor priority, so put preferred kinds first), then sort the
+ survivors by value."""
+ kept = []
+ for e in entries:
+ if all(abs(e[0] - o[0]) > geometry.TIER_MERGE_TOL_FT for o in kept):
+ kept.append(e)
+ return sorted(kept, key=lambda x: x[0])
+
+
+def resolve_entries(entries, axis, axis_faces, run, notes,
+ measure_core=False, view=None, core_cache=None,
+ prefer_exterior=True, face_audit=False, frame=None):
+ """Entries -> (references, kept_values). measure_core swaps wall
+ finish-face references for the CORE boundary on the same side
+ ("face of stud"), using per-wall MEASURED calibration - the fixed
+ index table proved wrong on live walls (witness lines landed on
+ random layers)."""
+ refs = []
+ kept = []
+ for value, kind, payload in entries:
+ ref = None
+ if kind in ("jamb", "vwall"):
+ # view-aware host-wall face (opening jamb or wall end):
+ # already a visible reference, never core-swapped
+ ref = payload["ref"]
+ elif kind == "face":
+ # scan-line entries carry their face record directly
+ ref = payload["ref"]
+ if measure_core and view is not None:
+ core_ref = revit_io.core_face_reference(
+ payload, view, doc,
+ core_cache if core_cache is not None else {}, notes, frame)
+ if core_ref is not None:
+ ref = core_ref
+ elif kind == "open_side":
+ ref = payload
+ elif kind == "open_c":
+ try:
+ ref = revit_io.get_opening_centerline_reference(payload)
+ except ValueError as ex:
+ notes.append(str(ex))
+ elif kind == "cross":
+ ref = revit_io.get_wall_centerline_reference(payload)
+ if ref is None:
+ notes.append(
+ "Partition {0}: no centerline reference - skipped"
+ .format(payload.Id))
+ elif kind == "fixture":
+ ref = revit_io.get_instance_center_reference(payload, axis, frame)
+ if ref is None:
+ notes.append(
+ "Fixture {0}: no center reference - skipped"
+ .format(payload.Id))
+ elif kind == "wallpt":
+ face_info = revit_io.find_face_reference(
+ axis_faces, payload, prefer_exterior)
+ if face_info is not None:
+ # snap out to the wall's true finish face: the solid can
+ # show two faces on one side a fraction of an inch apart,
+ # and scoring by distance-to-target lands on the inner one
+ # (live audit: 0.3" inside the finish). Same wall, same
+ # side - this only ever moves the anchor outward.
+ face_info = revit_io.outermost_same_face(
+ axis_faces, face_info, axis)
+ if face_audit:
+ idx = 0 if axis == "x" else 1
+ lines = []
+ for c in revit_io.face_candidates(axis_faces, payload):
+ chosen = (face_info is not None
+ and c["origin"] == face_info["origin"]
+ and c["wall_id"] == face_info["wall"].Id)
+ lines.append(
+ "{0} wall {1} @ {2} | {3} side | {4:.3f} ft from "
+ "target".format(
+ "-->" if chosen else " ",
+ c["wall_id"], format_ft_in(c["origin"][idx]),
+ "outer" if c["exterior"] else "inner",
+ c["plane_d"]))
+ notes.append(
+ "FACE AUDIT at ({0:.2f}, {1:.2f}) [{2}]:\n {3}".format(
+ payload[0], payload[1], axis.upper(),
+ "\n ".join(lines) or "no candidate faces"))
+ if face_info is not None:
+ ref = face_info["ref"]
+ if measure_core and view is not None:
+ core_ref = revit_io.core_face_reference(
+ face_info, view, doc,
+ core_cache if core_cache is not None else {},
+ notes, frame)
+ if core_ref is not None:
+ ref = core_ref
+ if ref is None and run is not None:
+ for w in run["walls"]:
+ ref = revit_io.get_centerline_end_reference(w, payload)
+ if ref is not None:
+ notes.append(
+ "No face at ({0:.1f}, {1:.1f}) - used wall "
+ "end point".format(payload[0], payload[1]))
+ break
+ if ref is None:
+ notes.append(
+ "No reference at ({0:.1f}, {1:.1f}) - skipped"
+ .format(payload[0], payload[1]))
+ if ref is not None:
+ refs.append(ref)
+ kept.append(value)
+ return refs, kept
+
+
+# ------------------------------------- direction grouping (exterior)
+
+def orientation_side(run, frame):
+ """+1/-1: which perpendicular side the run's exterior faces, from
+ the average Wall.Orientation of its walls, measured in the frame."""
+ axis = run["tiers"]["axis"]
+ total = 0.0
+ for w in run["walls"]:
+ orientation = getattr(w, "Orientation", None)
+ if orientation is not None:
+ local = frame.to_local((orientation.X, orientation.Y))
+ total += local[1] if axis == "x" else local[0]
+ return 1.0 if total >= 0 else -1.0
+
+
+def group_label(axis, side):
+ """Compass name, assuming project +Y is north (report label only)."""
+ if axis == "x":
+ return "North" if side > 0 else "South"
+ return "East" if side > 0 else "West"
+
+
+def _merge_vals(values):
+ out = []
+ for v in sorted(values):
+ if not out or abs(v - out[-1]) > geometry.TIER_MERGE_TOL_FT:
+ out.append(v)
+ return out
+
+
+def _merge_group(group, axis, side, label):
+ """Union tier1/tier2 across the group's runs; tier3 (overall) spans
+ the FULL extent of the direction - min to max across every run -
+ per explicit user rule: the outermost string measures the TOTAL
+ building length including jog extensions (and _drop_duplicate_tiers
+ removes it when there is no jog, so it only appears when it adds
+ information)."""
+ pts = []
+ walls = []
+ openings = []
+ t1 = []
+ t2 = []
+ t3_vals = []
+ for r in group:
+ pts.extend(r["pts"])
+ for w in r["walls"]:
+ if w not in walls:
+ walls.append(w)
+ openings.extend(r["openings"])
+ t1.extend(r["tiers"]["tier1"])
+ t2.extend(r["tiers"]["tier2"])
+ t3_vals.extend(r["tiers"]["tier3"])
+ t3 = [min(t3_vals), max(t3_vals)]
+ return {
+ "pts": pts,
+ "walls": walls,
+ "openings": openings,
+ "tiers": {"axis": axis,
+ "tier1": _merge_vals(t1),
+ "tier2": _merge_vals(t2),
+ "tier3": t3},
+ "side": side,
+ "label": label,
+ }
+
+
+def group_exterior_runs(runs, frame, segments_local, max_drag_ft=0.0):
+ """Merge the runs facing one direction (axis + outside side) into
+ string sets - ONE per CLUSTER of runs whose witness lines can reach
+ a shared line without crossing a selected wall.
+
+ Single mass: one cluster per direction = the v3.4 image-confirmed
+ convention (the west string carries main-wall corners AND
+ bump-attachment jogs; a bump's width reads in the north/south
+ string with long witness lines). Large plan with several masses:
+ a far run whose witness lines would have to pass THROUGH another
+ selected wall gets its own cluster, placed at its own extreme on
+ its own correct side (live report: one global string per direction
+ dragged witness lines across the plan into "a mess of crossed
+ lines"). segments_local are this frame's SELECTED wall segments in
+ frame coordinates - only selected walls can block a merge.
+ max_drag_ft > 0 adds the optional distance rule: a run farther than
+ this behind a cluster's base splits off even without a crossing."""
+ buckets = {}
+ for r in runs:
+ key = (r["tiers"]["axis"], orientation_side(r, frame))
+ buckets.setdefault(key, []).append(r)
+
+ merged = []
+ for key in sorted(buckets.keys()):
+ axis, side = key
+ group = buckets[key]
+ pidx = 1 if axis == "x" else 0
+ records = []
+ for r in group:
+ perp_vals = [p[pidx] for p in r["pts"]]
+ extreme = max(perp_vals) if side > 0 else min(perp_vals)
+ records.append((extreme, r["tiers"]["tier1"]))
+ clusters = geometry.cluster_exterior_runs(
+ records, segments_local, axis, side, max_drag_ft)
+ for c_no in range(len(clusters)):
+ c_runs = [group[i] for i in clusters[c_no]]
+ name = group_label(axis, side)
+ if c_no:
+ name = "{0} {1}".format(name, c_no + 1)
+ label = "{0} ({1} run(s))".format(name, len(c_runs))
+ merged.append(_merge_group(c_runs, axis, side, label))
+ return merged
+
+
+# ------------------------------------------------------------ placement
+
+def run_side_and_base(run, fixtures_assigned, frame):
+ """(side, base) for exterior placement: direction groups carry
+ their side; Wall.Orientation is the fallback. (Interior placement
+ is scan-line based and never calls this.)"""
+ axis = run["tiers"]["axis"]
+ pidx = 1 if axis == "x" else 0
+ perp_vals = [p[pidx] for p in run["pts"]]
+
+ if run.get("side") is not None:
+ side = run["side"]
+ else:
+ side = orientation_side(run, frame)
+
+ base = max(perp_vals) if side > 0 else min(perp_vals)
+ return side, base
+
+
+def _drop_duplicate_tiers(plan):
+ """Remove tiers whose values duplicate an earlier tier - a jog-free
+ run otherwise stacks identical strings (live-observed: doubled
+ overall dimension)."""
+ kept = []
+ seen = []
+ for label, entries in plan:
+ sig = tuple(int(round(e[0] * 20)) for e in entries) # ~0.05 ft
+ if sig in seen:
+ continue
+ seen.append(sig)
+ kept.append((label, entries))
+ return kept
+
+
+def build_tier_plan(run, ro_mode, notes, frame):
+ """Exterior 3-tier plan: list of (label, entries), innermost first.
+ (Interior no longer uses per-run plans - see run_interior.)"""
+ tiers = run["tiers"]
+ t1 = dedupe_entries(
+ opening_entries(run, ro_mode, notes, frame)
+ + wallpoint_entries(run, tiers["tier1"]))
+ t2 = dedupe_entries(wallpoint_entries(run, tiers["tier2"]))
+ t3 = dedupe_entries(wallpoint_entries(run, tiers["tier3"]))
+ return _drop_duplicate_tiers(
+ [("jogs+openings", t1), ("jogs", t2), ("overall", t3)])
+
+
+def place_run(view, run, plan, all_walls, face_cache, fixtures, notes,
+ measure_core=False, core_cache=None, face_audit=False,
+ frame=None, first_offset_in=None, spacing_in=None):
+ if frame is None:
+ frame = geometry.Frame(0.0)
+ axis = run["tiers"]["axis"]
+ if axis not in face_cache:
+ face_cache[axis] = revit_io.collect_axis_faces(
+ all_walls, axis, None, frame)
+ axis_faces = face_cache[axis]
+
+ side, base = run_side_and_base(run, fixtures, frame)
+
+ # base is a location-line extreme; the visible finish face can sit
+ # up to a full wall thickness beyond it (depends on each wall's
+ # Location Line setting), which ate most of the small first-tier
+ # offset. Snap outward to the measured face - never inward.
+ raw_base = base
+ base = geometry.snap_base_outward(
+ base, side,
+ revit_io.exterior_face_perps(run["walls"], axis, frame))
+ if abs(base - raw_base) > 0.01:
+ notes.append(
+ "{0}: base snapped {1:.2f} ft outward, location line -> "
+ "outermost finish face.".format(
+ run.get("label") or "Run", (base - raw_base) * side))
+
+ # paper inches -> model feet. None = the per-scale Auto preset, so
+ # one saved setting works across sheets at different scales.
+ if first_offset_in is None:
+ first_offset_in = standards.first_offset_for_scale(view.Scale)
+ if spacing_in is None:
+ spacing_in = standards.TIER_SPACING_DEFAULT_IN
+ first = first_offset_in * standards.INCH * view.Scale
+ step = spacing_in * standards.INCH * view.Scale
+ positions = geometry.tier_positions(base, side, first, step, len(plan))
+
+ origin = getattr(view, "Origin", None)
+ base_z = origin.Z if origin is not None else 0.0
+
+ placed = 0
+ tier_no = 0
+ for label, entries in plan:
+ # increments even when a tier is skipped - tier identity (and
+ # therefore each string's line position) stays deterministic
+ tier_no += 1
+ refs, kept = resolve_entries(entries, axis, axis_faces, run, notes,
+ measure_core, view, core_cache,
+ True, face_audit, frame)
+ if len(refs) < 2:
+ notes.append("Tier '{0}': fewer than 2 references - skipped"
+ .format(label))
+ continue
+ revit_io.create_dimension_tier(
+ doc, view, refs, axis, (kept[0], kept[-1]),
+ positions[tier_no - 1], base_z, frame)
+ placed += 1
+ return placed
+
+
+# ---------------------------------------- interior: per-room strings
+
+# Every interior string is bounded by the room's own boundary polygon
+# (revit_io.get_room_polygon -> geometry.polygon_span_at). A string can
+# only ever reference the two walls the room's boundary actually crosses
+# at that line, so "never go through a wall" is structural, not a
+# heuristic. The previous build let strings continue past walls and pick
+# up whatever wall face was nearest - that is what produced measurements
+# to random walls across the plan.
+
+MIN_ROOM_FT = 2.5 # a room thinner than this gets no string
+OBSTRUCTION_CLEAR_FT = 1.5 # object counts as "on" a line within this
+LINE_CLEAR_FT = 1.0 # two strings closer than this overlap
+
+
+def room_inward(poly, edge_mid, axis_perp_idx, edge_perp):
+ """+1/-1: which side of a boundary edge the room interior is on.
+ Decided by testing a point a few inches off the edge against the
+ polygon itself - exact for any room shape, unlike a centroid guess
+ (an L-shaped room's centroid can fall outside the room)."""
+ for sign in (1.0, -1.0):
+ probe = list(edge_mid)
+ probe[axis_perp_idx] = edge_perp + sign * 0.25
+ if geometry.point_in_polygon((probe[0], probe[1]), poly):
+ return sign
+ return 1.0
+
+
+def edge_face_entry(room, edge, axis, value, inward, view_faces, notes):
+ """(value, 'face', face_record) for one end of a room span.
+
+ Fast path: the wall named by the boundary segment. Fallback: any
+ view-visible wall face on the same plane facing into the room -
+ BoundarySegment.ElementId comes back null for walls that protrude
+ into a room (Building Coder #1046), and room-separation lines have no
+ wall at all. Entry kind is 'face', so the core/finish switch applies."""
+ wall_id = room["wall_ids"][edge] if edge < len(room["wall_ids"]) else None
+ faces = view_faces[axis]
+ rec = None
+ if wall_id is not None:
+ rec = revit_io.face_at(faces, axis, value, inward, wall_id)
+ if rec is None:
+ rec = revit_io.face_at(faces, axis, value, inward)
+ if rec is not None and wall_id is not None:
+ notes.append(
+ "{0}: boundary wall {1} exposed no face at {2} - used the "
+ "coincident face instead".format(
+ room["name"], wall_id, format_ft_in(value)))
+ if rec is None:
+ notes.append(
+ "{0}: no view-visible wall face at {1} ({2} axis) - that end "
+ "of the string is missing (room-separation line?)".format(
+ room["name"], format_ft_in(value), axis.upper()))
+ return None
+ return (rec["origin"][0 if axis == "x" else 1], "face", rec)
+
+
+def span_entries(room, span, axis, view_faces, notes):
+ """Both ends of a room span as 'face' entries. The low end's wall
+ faces +axis into the room, the high end's faces -axis."""
+ entries = []
+ for (value, edge), inward in ((span[0], 1.0), (span[1], -1.0)):
+ entry = edge_face_entry(room, edge, axis, value, inward,
+ view_faces, notes)
+ if entry is not None:
+ entries.append(entry)
+ return entries
+
+
+def obstruction_cost(axis, perp, objects, occupied):
+ """How cluttered a candidate dimension line is: objects sitting on it
+ plus strings already placed there. Picks the least-obstructed quarter
+ line (user rule: room length sits a quarter off a parallel wall)."""
+ pidx = 1 if axis == "x" else 0
+ cost = 0
+ for obj in objects:
+ if abs(obj["pt"][pidx] - perp) <= OBSTRUCTION_CLEAR_FT:
+ cost += 1
+ for pos in occupied[axis]:
+ if abs(pos - perp) <= LINE_CLEAR_FT:
+ cost += 1
+ return cost
+
+
+def nudge(axis, pos, occupied, step, away, bounds=None):
+ """Move a string off any line already occupied, stepping in the `away`
+ direction (which must point INTO the room), then claim its position.
+
+ bounds (lo, hi) is the room's extent along the direction being nudged;
+ a nudge is never allowed to push the dimension line out of the room,
+ so a crowded small room ends up with two lines close together rather
+ than one line outside the wall."""
+ limit_lo = bounds[0] + 0.25 if bounds else None
+ limit_hi = bounds[1] - 0.25 if bounds else None
+ tries = 0
+ while (any(abs(p - pos) < LINE_CLEAR_FT for p in occupied[axis])
+ and tries < 4):
+ moved = pos + away * max(step, 0.5)
+ if limit_lo is not None and not (limit_lo <= moved <= limit_hi):
+ break
+ pos = moved
+ tries += 1
+ occupied[axis].append(pos)
+ return pos
+
+
+def drop_redundant(items, room_name, notes):
+ """Within one room, keep ONE dimension per distinct measurement.
+
+ Two strings on the same axis whose reference values are the same, to
+ ~1/8", ARE the same dimension - drawing both is pure cleanup work.
+ Live example: six stacked casework shelves in the W.I.C. each produced
+ the identical 8'-0.3" -> 9'-10.3" dimension, because they share an X
+ position; one line locates all six.
+
+ Deliberate consequence, worth knowing: two separate fixtures that
+ happen to sit the same distance from the same wall collapse to one
+ dimension. That is the same number in the same place on the sheet, so
+ it reads correctly - but it means the second fixture is located by the
+ first one's line rather than its own."""
+ kept = []
+ seen = set()
+ dropped = 0
+ for item in items:
+ signature = (item["axis"],
+ tuple(int(round(value * 100.0))
+ for value, _, _ in item["entries"]))
+ if signature in seen:
+ dropped += 1
+ continue
+ seen.add(signature)
+ kept.append(item)
+ if dropped:
+ notes.append(
+ "{0}: {1} redundant dimension(s) dropped (same measurement "
+ "already drawn).".format(room_name, dropped))
+ return kept
+
+
+# ---- level 1: room lengths (east-west and north-south) ----
+
+def room_length_items(room, view_faces, objects, occupied, notes):
+ """One string per axis measuring the room clear across, placed at a
+ quarter of the room's depth off a parallel wall - whichever of the two
+ quarter lines is least obstructed. Both axes landing on the room CENTRE
+ (and therefore crossing each other) was the reported bug."""
+ poly = room["poly"]
+ items = []
+ for axis in ("x", "y"):
+ best = None
+ for perp in geometry.quarter_lines(poly, axis):
+ span = geometry.polygon_span_at(poly, axis, perp)
+ if span is None:
+ continue # quarter line misses the room (L-shaped leg)
+ if (span[1][0] - span[0][0]) < MIN_ROOM_FT:
+ continue
+ cost = obstruction_cost(axis, perp, objects, occupied)
+ if best is None or cost < best[0]:
+ best = (cost, perp, span)
+ if best is None:
+ notes.append("{0} [{1}]: no usable quarter line - skipped"
+ .format(room["name"], axis.upper()))
+ continue
+ _, perp, span = best
+ entries = dedupe_entries(span_entries(room, span, axis,
+ view_faces, notes))
+ if len(entries) < 2:
+ continue
+ occupied[axis].append(perp)
+ items.append({"axis": axis, "pos": perp, "entries": entries,
+ "run": None, "kind": "length",
+ "label": "{0} [{1}] length".format(
+ room["name"], axis.upper())})
+ return items
+
+
+# ---- level 2: openings, measured inside the room ----
+
+def room_opening_items(room, view, view_faces, occupied, ro_mode, notes,
+ frame):
+ """One string per room wall that hosts openings, running just inside
+ the room parallel to that wall, spanning room corner to room corner so
+ it never leaves the room.
+
+ ro_mode picks what the opening itself is measured to, and it now drives
+ INTERIOR as well as exterior (it used to be ignored here, which is why
+ interior strings never showed opening centres):
+ R.O. -> the host wall's own view-visible cut faces, i.e. the jambs
+ false -> the opening's centreline reference"""
+ poly = room["poly"]
+ step = standards.TIER_SPACING_FT * view.Scale
+ items = []
+ n = len(poly)
+
+ # ONE string per WALL, not per boundary edge. A single wall routinely
+ # generates several boundary segments of a room (Revit splits them at
+ # every junction), and emitting a string per edge produced stacks of
+ # byte-identical strings nudged apart - live: wall 5307847 gave three
+ # identical [Y] strings in one room. Grouping by wall also means an
+ # opening sitting on a different segment OF THE SAME WALL is still
+ # picked up, which is where some of the missed openings came from.
+ by_wall = {}
+ for edge in range(n):
+ wall_id = room["wall_ids"][edge]
+ if wall_id is None:
+ continue
+ wall = doc.GetElement(wall_id)
+ if not isinstance(wall, Wall):
+ continue
+ a = poly[edge]
+ b = poly[(edge + 1) % n]
+ axis = "x" if abs(b[0] - a[0]) >= abs(b[1] - a[1]) else "y"
+ idx = 0 if axis == "x" else 1
+ length = abs(b[idx] - a[idx])
+ key = (str(wall_id), axis)
+ rec = by_wall.get(key)
+ if rec is None or length > rec["length"]:
+ # the wall's LONGEST edge in this room decides where the string
+ # sits - a short stub segment would put it in the wrong place
+ by_wall[key] = {"wall": wall, "axis": axis, "edge": edge,
+ "length": length}
+
+ for key in sorted(by_wall.keys()):
+ rec = by_wall[key]
+ wall = rec["wall"]
+ axis = rec["axis"]
+ edge = rec["edge"]
+ opens = revit_io.get_hosted_openings(wall, doc)
+ if not opens:
+ continue
+
+ a = poly[edge]
+ b = poly[(edge + 1) % n]
+ idx = 0 if axis == "x" else 1
+ pidx = 1 - idx
+ edge_perp = (a[pidx] + b[pidx]) / 2.0
+ edge_mid = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
+ inward = room_inward(poly, edge_mid, pidx, edge_perp)
+
+ # scan just inside the wall to find the room's two PERPENDICULAR
+ # boundary walls - those are the string's end anchors, and they
+ # are what keeps it inside this room
+ span = geometry.polygon_span_at(
+ poly, axis, edge_perp + inward * 0.5)
+ if span is None:
+ continue
+ entries = span_entries(room, span, axis, view_faces, notes)
+ lo_val, hi_val = span[0][0], span[1][0]
+
+ host_faces = revit_io.collect_axis_faces(
+ [wall], axis, view, frame)
+ if not host_faces:
+ notes.append("{0}: wall {1} has no view-visible axis faces - "
+ "its openings are not dimensioned".format(
+ room["name"], wall.Id))
+ continue
+
+ found = 0
+ outside = []
+ for inst in opens:
+ try:
+ center = frame.to_local(
+ revit_io.get_opening_point(inst))[idx]
+ except ValueError as ex:
+ notes.append("Skipped opening: {0}".format(ex))
+ continue
+ if not (lo_val - 0.5 <= center <= hi_val + 0.5):
+ # in this wall, but beyond this room's extent along it -
+ # it belongs to the room next door. Recorded, not silent:
+ # a genuinely missed opening shows up here.
+ outside.append(str(inst.Id))
+ continue
+ found += 1
+ jambs = None
+ if ro_mode:
+ width = revit_io.get_opening_width(inst)
+ jambs = revit_io.jamb_faces_from(
+ host_faces, center, idx, width)
+ if jambs is None:
+ notes.append(
+ "Opening {0}: no view-visible jamb faces - "
+ "centreline used".format(inst.Id))
+ if jambs is not None:
+ entries.append((jambs[0]["origin"][idx], "jamb", jambs[0]))
+ entries.append((jambs[1]["origin"][idx], "jamb", jambs[1]))
+ else:
+ entries.append((center, "open_c", inst))
+ if outside:
+ notes.append(
+ "{0}: opening(s) {1} are in boundary wall {2} but outside "
+ "this room's extent along it - not dimensioned here".format(
+ room["name"], ", ".join(outside[:5]), wall.Id))
+ if not found:
+ continue
+
+ entries = dedupe_entries(entries)
+ if len(entries) < 2:
+ continue
+
+ # hug the wall (half its thickness clears the poche), on the ROOM
+ # side, and never nudge back out through it
+ half_thk = getattr(wall, "Width", 0.5) / 2.0
+ perp_span = geometry.polygon_span_at(
+ poly, "y" if axis == "x" else "x", (lo_val + hi_val) / 2.0)
+ bounds = ((perp_span[0][0], perp_span[1][0])
+ if perp_span is not None else None)
+ pos = nudge(axis, edge_perp + inward * (half_thk + 0.75 * step),
+ occupied, step, inward, bounds)
+ items.append({"axis": axis, "pos": pos, "entries": entries,
+ "run": None, "kind": "openings",
+ "label": "{0} openings, wall {1} [{2}]".format(
+ room["name"], wall.Id, axis.upper())})
+ return items
+
+
+# ---- level 3: fixtures, casework, columns - centre to nearest wall ----
+
+def object_items(room, obj, view_faces, occupied, step, notes, frame):
+ """Dimension an object's CENTRE to the nearest bounding wall (user
+ rule 3). Columns get both axes; plumbing fixtures and casework get one
+ dimension, on the axis whose wall is closest."""
+ poly = room["poly"]
+ inst = obj["inst"]
+
+ # the room's extent through the object, per axis, and which end is
+ # nearer - all from the room polygon, so the wall picked is always one
+ # that really bounds this room
+ reach = {}
+ for axis in ("x", "y"):
+ idx = 0 if axis == "x" else 1
+ span = geometry.polygon_span_at(poly, axis, obj["pt"][1 - idx])
+ if span is None:
+ continue
+ value = obj["pt"][idx]
+ d_lo = value - span[0][0]
+ d_hi = span[1][0] - value
+ if d_lo <= d_hi:
+ reach[axis] = (d_lo, span[0], 1.0)
+ else:
+ reach[axis] = (d_hi, span[1], -1.0)
+ if not reach:
+ return []
+
+ if obj["is_column"]:
+ axes = sorted(reach.keys())
+ else:
+ axes = [min(reach, key=lambda a: reach[a][0])]
+
+ items = []
+ for axis in axes:
+ idx = 0 if axis == "x" else 1
+ _, (value, edge), inward = reach[axis]
+ wall_entry = edge_face_entry(room, edge, axis, value, inward,
+ view_faces, notes)
+ if wall_entry is None:
+ continue
+ center_ref = revit_io.get_instance_center_reference(
+ inst, axis, frame)
+ if center_ref is None:
+ notes.append(
+ "{0}: {1} {2} exposes no centre reference - not "
+ "dimensioned (its family has no Center (Left/Right) or "
+ "Center (Front/Back) reference plane)".format(
+ room["name"], inst.Category.Name if inst.Category
+ else "Object", inst.Id))
+ continue
+ entries = dedupe_entries(
+ [wall_entry, (obj["pt"][idx], "fixture", inst)])
+ if len(entries) < 2:
+ continue
+
+ # the line runs through the object's own centre, offset only if
+ # something is already there. It is nudged along the PERPENDICULAR
+ # axis (not `inward`, which points along the measured axis), toward
+ # the middle of the room, and never past the room's walls.
+ perp_axis = "y" if axis == "x" else "x"
+ perp_span = geometry.polygon_span_at(poly, perp_axis, obj["pt"][idx])
+ obj_perp = obj["pt"][1 - idx]
+ if perp_span is None:
+ away, bounds = 1.0, None
+ else:
+ lo, hi = perp_span[0][0], perp_span[1][0]
+ away = 1.0 if (lo + hi) / 2.0 >= obj_perp else -1.0
+ bounds = (lo, hi)
+ pos = nudge(axis, obj_perp, occupied, step, away, bounds)
+
+ items.append({"axis": axis, "pos": pos, "entries": entries,
+ "run": None, "kind": "object",
+ "label": "{0} object {1} [{2}]".format(
+ room["name"], inst.Id, axis.upper())})
+ return items
+
+
+def show_interior_dry_run(items, notes):
+ output = script.get_output()
+ output.print_md("# Dimension strings — dry run (interior)")
+ output.print_md("{0} string(s) | no model changes".format(len(items)))
+ for n in range(len(items)):
+ item = items[n]
+ frame = item.get("frame")
+ turned = ""
+ if frame is not None and abs(frame.degrees()) > 0.05:
+ # values below are in the room's OWN frame, not world X/Y
+ turned = " — frame {0:.1f}°".format(frame.degrees())
+ output.print_md(
+ "### {0} — measures {1}, placed at {2}={3}{4}".format(
+ item["label"], item["axis"].upper(),
+ "Y" if item["axis"] == "x" else "X",
+ format_ft_in(item["pos"]), turned))
+ parts = ["{0} ({1})".format(format_ft_in(v), KIND_LABEL[k])
+ for v, k, _ in item["entries"]]
+ output.print_md("- " + " | ".join(parts))
+ if notes:
+ output.print_md("### Notes")
+ for note in notes:
+ output.print_md("- {0}".format(note))
+
+
+def run_interior(view, fixtures, ro_mode, dry, measure_core, notes):
+ rooms = revit_io.get_rooms(doc, view)
+ if not rooms:
+ forms.alert(
+ "Interior mode is driven by Rooms, and this view has no "
+ "placed rooms.\n\nPlace Room elements (with area) and run "
+ "again.",
+ title="Dimension Strings — interior")
+ return
+
+ all_walls = revit_io.get_basic_walls(doc, view)
+
+ # Each ROOM is dimensioned in the frame of its OWN walls, so an angled
+ # wing needs no special case: its rooms simply carry a different frame.
+ # A room's frame comes from its boundary edges, length-weighted, so the
+ # long walls decide and a short jog cannot tilt the room.
+ for room in rooms:
+ edges = [(room["poly"][k], room["poly"][(k + 1) % len(room["poly"])])
+ for k in range(len(room["poly"]))]
+ room["frame"] = geometry.direction_frames(edges)[0]
+ room["poly"] = [room["frame"].to_local(p) for p in room["poly"]]
+
+ angles = sorted(set(round(r["frame"].degrees(), 1) for r in rooms))
+ notes.append("Room direction(s): {0}".format(
+ ", ".join("{0} deg".format(a) for a in angles)))
+
+ # VIEW-AWARE faces per (axis, frame): Options.View makes these
+ # references visible in the plan by construction. Model-geometry
+ # references produced dimensions that existed but rendered in no view
+ # at all (sessions 9e-9f) - every interior reference goes through these.
+ # Cached per frame because a face's local origin/normal differ per frame.
+ face_cache = {}
+
+ def faces_for(frame):
+ key = round(frame.angle, 6)
+ if key not in face_cache:
+ face_cache[key] = {}
+ for axis in ("x", "y"):
+ face_cache[key][axis] = revit_io.collect_axis_faces(
+ all_walls, axis, view, frame)
+ return face_cache[key]
+
+ objects = revit_io.get_room_objects(doc, view)
+ step = standards.TIER_SPACING_FT * view.Scale
+
+ items = []
+ counts = {"length": 0, "openings": 0, "objects": 0}
+ placed_objects = 0
+ for room in rooms:
+ frame = room["frame"]
+ view_faces = faces_for(frame)
+
+ # one occupancy list per room: strings nudge off each other here,
+ # not across the whole floor
+ occupied = {"x": [], "y": []}
+ mine = []
+ for obj in objects:
+ local = dict(obj)
+ local["pt"] = frame.to_local(obj["pt"])
+ if geometry.point_in_polygon(local["pt"], room["poly"]):
+ mine.append(local)
+
+ # Openings first: they have a hard constraint (hug their own wall).
+ # The room-length line then picks whichever quarter line is least
+ # obstructed by them and by the fixtures; object dims come last and
+ # avoid both.
+ opening_items = room_opening_items(
+ room, view, view_faces, occupied, ro_mode, notes, frame)
+ length_items = room_length_items(
+ room, view_faces, mine, occupied, notes)
+
+ object_dims = []
+ for obj in mine:
+ got = object_items(room, obj, view_faces, occupied, step, notes,
+ frame)
+ if got:
+ placed_objects += 1
+ object_dims.extend(got)
+
+ # one dimension per distinct measurement, across all three levels
+ room_items = drop_redundant(
+ length_items + opening_items + object_dims, room["name"], notes)
+ for item in room_items:
+ item["frame"] = frame
+
+ counts["length"] += len([i for i in room_items
+ if i["kind"] == "length"])
+ counts["openings"] += len([i for i in room_items
+ if i["kind"] == "openings"])
+ counts["objects"] += len([i for i in room_items
+ if i["kind"] == "object"])
+ items.extend(room_items)
+
+ in_rooms = 0
+ for obj in objects:
+ for r in rooms:
+ if geometry.point_in_polygon(
+ r["frame"].to_local(obj["pt"]), r["poly"]):
+ in_rooms += 1
+ break
+ notes.append(
+ "{0} room(s) -> {1} room-length string(s), {2} opening string(s), "
+ "{3} object dimension(s). {4} fixture/casework/column instance(s) "
+ "sit in rooms; {5} of them produced a dimension before redundant "
+ "ones were dropped (objects sharing a position share a "
+ "dimension).".format(
+ len(rooms), counts["length"], counts["openings"],
+ counts["objects"], in_rooms, placed_objects))
+ notes.append(
+ "Openings measured to: {0}.".format(
+ "the wall's cut faces (jambs)" if ro_mode else "centreline"))
+ if fixtures:
+ notes.append(
+ "{0} selected element(s) ignored: interior mode dimensions "
+ "every fixture/casework/column inside a Room automatically, "
+ "so selection is not used.".format(len(fixtures)))
+
+ if dry:
+ if measure_core:
+ notes.append("Core mode: faces calibrated by measurement "
+ "at placement time.")
+ show_interior_dry_run(items, notes)
+ return
+
+ origin = getattr(view, "Origin", None)
+ base_z = origin.Z if origin is not None else 0.0
+ core_cache = {}
+ placed = 0
+ failed = 0
+ created = [] # (label, dimension id) - verified after commit
+ with revit.Transaction("Automatic Dimensions: Interior"):
+ for item in items:
+ try:
+ refs, kept = resolve_entries(
+ item["entries"], item["axis"],
+ [], item["run"], notes,
+ measure_core, view, core_cache,
+ prefer_exterior=False, frame=item["frame"])
+ if len(refs) < 2:
+ notes.append("{0}: fewer than 2 references - "
+ "skipped".format(item["label"]))
+ continue
+ dim = revit_io.create_dimension_tier(
+ doc, view, refs, item["axis"], (kept[0], kept[-1]),
+ item["pos"], base_z, item["frame"])
+ created.append((item["label"], dim.Id))
+ placed += 1
+ except Exception as ex:
+ failed += 1
+ notes.append("{0} failed: {1}".format(item["label"], ex))
+
+ # Revit's commit-time failure resolution can DELETE dimensions it
+ # considers invalid WITHOUT any exception (live-observed: 16 created,
+ # 0 failures, opening strings absent). Verify survival explicitly.
+ vanished = [label for label, dim_id in created
+ if doc.GetElement(dim_id) is None]
+ if vanished:
+ failed += len(vanished)
+ placed -= len(vanished)
+ notes.insert(0, (
+ "REVIT SILENTLY DELETED {0} string(s) at commit (invalid "
+ "references): {1}".format(len(vanished),
+ ", ".join(vanished[:9]))))
+
+ # A dimension can survive commit and still render in NO view, if its
+ # references are not visible geometry (live-diagnosed, sessions 9e-9f:
+ # the dim exists, owner view is right, and Revit draws nothing). Every
+ # reference here is view-aware, but the fixture CENTRE references are
+ # the one unproven species - so check every dim and name the invisible
+ # ones instead of letting them fail silently.
+ invisible = []
+ for label, dim_id in created:
+ dim = doc.GetElement(dim_id)
+ if dim is None:
+ continue
+ try:
+ shown = (dim.get_BoundingBox(
+ doc.GetElement(dim.OwnerViewId)) is not None)
+ except Exception:
+ shown = True # cannot tell - do not cry wolf
+ if not shown:
+ invisible.append("{0} (id {1})".format(label, dim_id))
+ if invisible:
+ notes.insert(0, (
+ "{0} dimension(s) were created but render in NO view - their "
+ "references are not visible geometry in this plan: {1}".format(
+ len(invisible), ", ".join(invisible[:9]))))
+
+ if measure_core and core_cache:
+ fell_back = [k for k in core_cache if not core_cache[k]]
+ notes.append("Core calibration: {0} wall(s) OK, {1} fell back "
+ "to finish faces (ids: {2})".format(
+ len(core_cache) - len(fell_back),
+ len(fell_back),
+ ", ".join(fell_back[:8]) or "none"))
+
+ summary = "Placed {0} of {1} interior dimension string(s).".format(
+ placed, len(items))
+ if failed:
+ # failures FIRST and verbatim - these lines are exactly what is
+ # needed to diagnose a skipped string, don't bury them
+ failure_notes = [x for x in notes
+ if "failed" in x or "skipped" in x.lower()]
+ summary += ("\n{0} string(s) failed/skipped:\n".format(failed)
+ + "\n".join(failure_notes[:9]))
+ if notes:
+ output = script.get_output()
+ output.print_md("### Interior placement notes")
+ for note in notes:
+ output.print_md("- {0}".format(note))
+ forms.alert(summary, title="Dimension Strings — interior")
+
+
+# -------------------------------------------------------------- reports
+
+KIND_LABEL = {"wallpt": "wall", "open_c": "opening CL",
+ "open_side": "opening side", "cross": "partition CL",
+ "fixture": "fixture", "face": "wall face",
+ "jamb": "R.O. jamb", "vwall": "wall end"}
+
+
+def show_dry_run(runs, plans, mode, notes):
+ output = script.get_output()
+ output.print_md("# Dimension strings — dry run ({0})".format(
+ "exterior" if mode == MODE_EXT else "interior"))
+ output.print_md("{0} run(s) | no model changes".format(len(runs)))
+ for n in range(len(runs)):
+ tiers = runs[n]["tiers"]
+ title = runs[n].get("label") or "Run {0}".format(n + 1)
+ output.print_md("### {0} — axis {1}, {2} wall(s), "
+ "{3} opening(s)".format(
+ title, tiers["axis"].upper(),
+ len(runs[n]["walls"]),
+ len(runs[n]["openings"])))
+ for label, entries in plans[n]:
+ parts = ["{0} ({1})".format(format_ft_in(v), KIND_LABEL[k])
+ for v, k, _ in entries]
+ output.print_md("- {0}: {1}".format(label, " | ".join(parts)))
+ if notes:
+ output.print_md("### Notes")
+ for note in notes:
+ output.print_md("- {0}".format(note))
+
+
+# ----------------------------------------------------------------- main
+
+def main():
+ view = doc.ActiveView
+ notes = []
+
+ walls, fixtures, skipped_sel = gather_selection(view)
+ if skipped_sel:
+ notes.append(
+ "{0} selected wall(s) IGNORED - based below this view's "
+ "level or not cut by its cut plane (the below-level walls "
+ "that previously hijacked witness lines).".format(skipped_sel))
+
+ window = AutoDimWindow(len(walls), len(fixtures))
+ window.show_dialog()
+ if not window.result:
+ return
+ mode = window.result["mode"]
+ dry = window.result["dry_run"]
+ ro_mode = window.result["openings_ro"]
+ measure_core = window.result["measure_core"]
+ face_audit = window.result["face_audit"]
+ first_offset_in = window.result["first_offset_in"] # None = auto
+ spacing_in = window.result["tier_spacing_in"]
+ max_drag_ft = window.result["max_drag_ft"]
+ if window.result.get("settings_warning"):
+ notes.append(window.result["settings_warning"])
+
+ if mode == MODE_INT:
+ # interior is Room-driven scan lines + per-wall opening strings
+ # - selection only supplies fixtures/casework
+ stats = revit_io.get_wall_stats(doc, view)
+ notes.append(
+ "Collection: {0} wall(s) visible, {1} basic, {2} on this "
+ "level (below-level/underlay walls excluded).".format(
+ stats["visible"], stats["basic"], stats["this_level"]))
+ run_interior(view, fixtures, ro_mode, dry, measure_core, notes)
+ return
+
+ # EXTERIOR: manual selection only - wall Function data is not
+ # trusted (user rule: same types get used inside and out)
+ if not walls:
+ forms.alert(
+ "Exterior mode dimensions the walls YOU select.\n\n"
+ "Select the perimeter walls of one level and run again "
+ "(the wall type's Interior/Exterior Function setting is "
+ "deliberately ignored - it is unreliable in real models).",
+ title="Dimension Strings — exterior")
+ return
+
+ # THE BUILDING'S DIRECTIONS. A dimension always measures ALONG its
+ # line, so a rotated wall dimensioned against a world axis returns the
+ # cosine-shortened projection - a wrong NUMBER, not just bad placement.
+ # Walls are clustered by direction (mod 90 deg, length-weighted) and
+ # each cluster is dimensioned inside its own frame. An orthogonal
+ # building yields exactly one frame at 0 deg = the exact identity, so
+ # it behaves as before.
+ segments = []
+ seg_walls = []
+ for wall in walls:
+ try:
+ segments.append(revit_io.get_wall_endpoints(wall))
+ seg_walls.append(wall)
+ except ValueError:
+ continue
+ frames = geometry.direction_frames(segments)
+ notes.append("Building direction(s): {0}".format(
+ ", ".join("{0:.1f} deg".format(f.degrees()) for f in frames)))
+
+ by_frame = {}
+ frame_segments = {}
+ skewed = 0
+ for i in range(len(seg_walls)):
+ idx = geometry.frame_of(segments[i], frames)
+ if idx is None:
+ skewed += 1
+ continue
+ by_frame.setdefault(idx, []).append(seg_walls[i])
+ frame_segments.setdefault(idx, []).append(segments[i])
+ if skewed:
+ notes.append(
+ "{0} wall(s) lie on none of the building's directions and were "
+ "NOT dimensioned - a dimension can only measure along a line, "
+ "and forcing them onto an axis they do not lie on would report "
+ "a shortened length.".format(skewed))
+
+ # Reference faces come from ALL walls in the view regardless of the
+ # selection - with a partial selection, corner planes belong to
+ # UNSELECTED neighbor walls, and missing them made each run end
+ # land on a different random layer (live-observed: 109" vs 116").
+ all_walls = revit_io.get_basic_walls(doc, view)
+
+ # a work item per frame: (frame, runs, plans)
+ work = []
+ for idx in sorted(by_frame.keys()):
+ frame = frames[idx]
+ f_segs = [(frame.to_local(a), frame.to_local(b))
+ for a, b in frame_segments[idx]]
+ f_runs = build_runs(by_frame[idx], notes, frame)
+ if not f_runs:
+ continue
+ f_runs = group_exterior_runs(f_runs, frame, f_segs, max_drag_ft)
+ f_plans = [build_tier_plan(r, ro_mode, notes, frame) for r in f_runs]
+ keep = [n for n in range(len(f_runs))
+ if any(len(e) >= 2 for _, e in f_plans[n])]
+ f_runs = [f_runs[n] for n in keep]
+ f_plans = [f_plans[n] for n in keep]
+ for n in range(len(f_runs)):
+ if len(frames) > 1:
+ f_runs[n]["label"] = "{0} @ {1:.1f} deg".format(
+ f_runs[n].get("label") or "Run", frame.degrees())
+ work.append((frame, f_runs[n], f_plans[n]))
+
+ if not work:
+ forms.alert("Could not build any dimensionable wall run.\n\n"
+ + "\n".join(notes), title="Dimension Strings")
+ return
+
+ runs = [w[1] for w in work]
+ plans = [w[2] for w in work]
+
+ # say what the offset settings resolve to for THIS view, so the
+ # dry run is checkable before anything is placed
+ resolved_first = (first_offset_in if first_offset_in is not None
+ else standards.first_offset_for_scale(view.Scale))
+ notes.append(
+ 'Exterior offsets at 1:{0}: first string {1}" paper = {2:.2f} ft '
+ '({3}), tier spacing {4}" paper = {5:.2f} ft.'.format(
+ view.Scale, resolved_first,
+ resolved_first * standards.INCH * view.Scale,
+ "auto preset" if first_offset_in is None else "manual",
+ spacing_in, spacing_in * standards.INCH * view.Scale))
+ if max_drag_ft > 0.0:
+ notes.append(
+ "Side splitting: crossing rule plus manual distance cap of "
+ "{0:.1f} ft.".format(max_drag_ft))
+
+ if dry:
+ if measure_core:
+ notes.append(
+ "Core mode: core faces are calibrated by measurement "
+ "at placement time (temporary dimensions, auto-deleted) "
+ "- dry-run values are location-line based.")
+ if face_audit:
+ # resolve references WITHOUT placing anything, purely to record
+ # which face won each contest. measure_core is forced off here:
+ # core calibration writes temporary dimensions and needs an open
+ # transaction, which a dry run must not have.
+ audit_faces = {}
+ for frame, run, plan in work:
+ axis = run["tiers"]["axis"]
+ key = (axis, round(frame.angle, 6))
+ if key not in audit_faces:
+ audit_faces[key] = revit_io.collect_axis_faces(
+ all_walls, axis, None, frame)
+ for _, entries in plan:
+ resolve_entries(entries, axis, audit_faces[key],
+ run, notes, False, view, None,
+ True, True, frame)
+ show_dry_run(runs, plans, mode, notes)
+ return
+
+ face_caches = {}
+ core_cache = {}
+ placed = 0
+ failed = 0
+ with revit.Transaction("Automatic Dimensions: Exterior"):
+ for n in range(len(work)):
+ frame, run, plan = work[n]
+ key = round(frame.angle, 6)
+ face_cache = face_caches.setdefault(key, {})
+ try:
+ placed += place_run(
+ view, run, plan, all_walls, face_cache,
+ [], notes, measure_core, core_cache, face_audit, frame,
+ first_offset_in, spacing_in)
+ except Exception as ex:
+ failed += 1
+ notes.append("Run {0} failed: {1}".format(n + 1, ex))
+
+ if measure_core and core_cache:
+ fell_back = [k for k in core_cache if not core_cache[k]]
+ notes.append("Core calibration: {0} wall(s) OK, {1} fell back "
+ "to finish faces (wall ids: {2})".format(
+ len(core_cache) - len(fell_back),
+ len(fell_back),
+ ", ".join(fell_back[:8]) or "none"))
+
+ summary = "Placed {0} dimension string(s) across {1} run(s).".format(
+ placed, len(runs))
+ if failed:
+ summary += "\n{0} run(s) failed.".format(failed)
+ if notes:
+ summary += "\n\nNotes (first 10):\n" + "\n".join(notes[:10])
+ output = script.get_output()
+ output.print_md("### Placement notes")
+ for note in notes:
+ output.print_md("- {0}".format(note))
+ forms.alert(summary, title="Dimension Strings")
+
+
+main()
From 45f5dae2dff4a617d739bb3d0a72e2c05bb58108 Mon Sep 17 00:00:00 2001
From: Roi Hason <42838734+Swichllc@users.noreply.github.com>
Date: Sun, 19 Jul 2026 12:07:35 -0400
Subject: [PATCH 2/2] Potential fix for pull request finding
Removal of text pointing to internal document
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
.../SwichDesign.extension/lib/autodimswichdesign/standards.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py b/extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py
index e35a8c4a22..02980e9639 100644
--- a/extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py
+++ b/extensions/SwichDesign.extension/lib/autodimswichdesign/standards.py
@@ -5,7 +5,7 @@
("United States CAD and Architectural Dimensioning Standards"), which
summarizes NCS/ANSI conventions. They have NOT been verified against a
licensed primary NCS copy (paywalled). Treat as project defaults, not
-certified standard values. See PROJECT_BRIEF.md section 1.
+certified standard values.
UNITS: Revit's internal length unit is always decimal FEET - every
constant here is feet.