diff --git a/extensions/SwichDesign.extension/lib/autodimswichdesign/__init__.py b/extensions/SwichDesign.extension/lib/autodimswichdesign/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/SwichDesign.extension/lib/autodimswichdesign/geometry.py b/extensions/SwichDesign.extension/lib/autodimswichdesign/geometry.py new file mode 100644 index 000000000..b76f9e6da --- /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 000000000..8cefcae2d --- /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 000000000..02980e963 --- /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. + +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 000000000..aad5388c6 --- /dev/null +++ b/extensions/SwichDesign.extension/pyRevit.tab/Drawing Set.panel/Automatic Dimensions.pushbutton/AutoDimWindow.xaml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +