From dae48d9037e37119ba1e2cc75e01f02fab55e3b5 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:33:31 +0300 Subject: [PATCH 1/4] feat(arch): the wall engine - L/T/X junctions, opening gaps and material poche engineering/arch/walls.py turns a wall network - axis polylines with a thickness, a justification and a material - into clean outlines. One rule resolves every junction: at each node the segment ends are sorted by angle and the left face of each meets the right face of the next one counter-clockwise. That mitres an L, stops a T's stem on the crossing wall's near face (cut there, while the far face stays one line) and cuts all four faces of an X. A T is an axis end lying inside another wall: it is carried onto the crossing axis and the crossing segment is split. The hub a three- or four-way node leaves is a region of its own, so the poche has no hole. Openings remove their width from both faces and are closed by jambs; a free end is closed by a jamb. Each solid piece is one CCW region and one HatchArea in the wall's material. The two face lines across each opening are kept as thresholds - never drawn - so room_segments hands the face finder closed rooms instead of rooms merged through their doorways. Refused by name before any geometry: a duplicate id, an unknown material or justification, a zero-length segment, a junction below 5 degrees, a corner inside another wall off its axis, an end whose axis meets the other wall past its end, a segment lying wholly inside the wall it joins, and one too short for its junctions (engineering.measure.is_self_intersecting on its outline). An opening that runs into a junction is returned in omitted, never dropped. Every corner in the tests is hand-computed: two 200 mm walls at the origin meet at (100, 100) inside and (-100, -100) outside. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/arch/__init__.py | 16 + engineering/arch/walls.py | 621 +++++++++++++++++++++++++++++++++++ tests/test_arch_walls.py | 359 ++++++++++++++++++++ 3 files changed, 996 insertions(+) create mode 100644 engineering/arch/walls.py create mode 100644 tests/test_arch_walls.py diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 505a99e..2898cff 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -30,6 +30,15 @@ from .model import ( wall_from_dict, wall_length, ) +from .walls import ( + MIN_JOIN_ANGLE_DEG, + WallGeometry, + face_offsets, + room_segments, + wall_geometry, + wall_geometry_by_wall, + wall_segments, +) __all__ = [ "APP_ID", @@ -39,20 +48,24 @@ __all__ = [ "ARCH_ROLE_LAYER", "KINDS", "LANGS", + "MIN_JOIN_ANGLE_DEG", "Opening", "Room", "Stair", "WALL_HATCH_TABLE", "WALL_MATERIALS", "Wall", + "WallGeometry", "axis_segments", "decode", "encode", + "face_offsets", "fmt_area_m2", "fmt_number", "from_payload", "opening_from_dict", "room_from_dict", + "room_segments", "segment_of", "stair_from_dict", "tag_prefix", @@ -61,6 +74,9 @@ __all__ = [ "validate_openings", "vocab", "wall_from_dict", + "wall_geometry", + "wall_geometry_by_wall", "wall_hatch", "wall_length", + "wall_segments", ] diff --git a/engineering/arch/walls.py b/engineering/arch/walls.py new file mode 100644 index 0000000..6a8aedb --- /dev/null +++ b/engineering/arch/walls.py @@ -0,0 +1,621 @@ +"""The wall engine: axis polylines in, clean wall outlines out. + +A wall is an axis polyline with a thickness and a justification - the side of +the axis the wall lies on, looking along it from its first point. This module +offsets every axis segment into its two faces and resolves every place where +segments meet: + +* **L and straight joins** - two segment ends at one point. Each face is met by + the face of its neighbour and both are trimmed or extended to their + intersection, so the corner is a clean mitre. +* **T** - a segment ends *inside* another wall. Its axis is carried to the + crossing axis, the crossing segment is split there, and the same rule stops + the stem's faces on the crossing wall's near face. That face is cut where + the stem meets it; the far face stays one line. +* **X** - two axes cross in their interiors. Both are split and all four + faces are cut. + +All three are one algorithm: at every node the segment ends are taken as +outgoing directions and sorted by angle, and the left face of each end meets +the right face of the next one counter-clockwise. A node of three or more ends +leaves a *hub* - the square an X crosses in, the triangle a centred T leaves - +that no single segment covers; it is a region of its own, so the poche has no +hole in it. + +An opening removes its width from both faces and each edge is closed by a jamb; +a free wall end is closed by a jamb too. Every solid piece - one axis segment's +stretch between junctions, openings and free ends, plus each hub - is one CCW +region and, with ``poche=True``, one ``HatchArea`` in the wall's material. + +Refused by name before anything is computed: two walls with one id, an unknown +material or justification, a zero-length segment, two segments that meet at +less than ``MIN_JOIN_ANGLE_DEG`` (the mitre would run away), a wall corner that +lies inside another wall off its axis, an end inside a wall whose axis would +have to run past that wall's end to meet it, a segment that lies wholly +inside the wall it joins, and one too short for its junctions (its outline +would cross itself - `engineering.measure.is_self_intersecting`). An opening that cannot be cut because it runs into a +junction is returned in ``omitted``, never dropped. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass, field + +from engineering.arch.materials import wall_hatch +from engineering.arch.model import Opening, Wall, segment_of, validate_openings +from engineering.measure import is_self_intersecting, polygon_area_perimeter +from engineering.mech.primitives import HatchArea, Line, Pt + +#: The shallowest angle two wall axes may meet at. Below it the mitre point of +#: the two faces runs more than eleven wall thicknesses away from the node, so +#: the junction is refused rather than drawn as a spike. A drafting limit of +#: this engine, not a building rule. +MIN_JOIN_ANGLE_DEG = 5.0 + +_EPS = 1e-9 +#: Decimals a point is rounded to when face lines are matched end to end. +_KEY_DECIMALS = 6 + + +@dataclass(frozen=True) +class WallGeometry: + faces: tuple[Line, ...] + jambs: tuple[Line, ...] + regions: tuple[tuple[Pt, ...], ...] + hatches: tuple[HatchArea, ...] + omitted: tuple[dict, ...] + #: Each opening's two face lines across its gap. Never drawn: they are what + #: `room_segments` adds so a doorway does not merge two rooms into one face. + thresholds: tuple[Line, ...] = () + + +# -- vectors ------------------------------------------------------------------ + + +def _sub(p: Pt, q: Pt) -> Pt: + return (p[0] - q[0], p[1] - q[1]) + + +def _add(p: Pt, q: Pt) -> Pt: + return (p[0] + q[0], p[1] + q[1]) + + +def _mul(v: Pt, k: float) -> Pt: + return (v[0] * k, v[1] * k) + + +def _dot(u: Pt, v: Pt) -> float: + return u[0] * v[0] + u[1] * v[1] + + +def _cross(u: Pt, v: Pt) -> float: + return u[0] * v[1] - u[1] * v[0] + + +def _left(u: Pt) -> Pt: + return (-u[1], u[0]) + + +def _unit(p: Pt, q: Pt) -> Pt: + length = math.dist(p, q) + return ((q[0] - p[0]) / length, (q[1] - p[1]) / length) + + +def _meet(p: Pt, u: Pt, q: Pt, v: Pt) -> Pt: + """Where the line through ``p`` along ``u`` meets the line through ``q`` along ``v``.""" + t = _cross(_sub(q, p), v) / _cross(u, v) + return _add(p, _mul(u, t)) + + +def _heading(u: Pt) -> float: + return math.degrees(math.atan2(u[1], u[0])) % 360.0 + + +def _line_angle(u: Pt, v: Pt) -> float: + """The angle between two lines (not directions), 0-90 degrees.""" + return math.degrees(math.asin(min(1.0, abs(_cross(u, v))))) + + +def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None: + """Consecutive duplicates removed; None if nothing is enclosed. + + Every caller builds its ring counter-clockwise - a piece as right face out, + left face back; a hub in the angular order of its corners - so the ring is + not turned here. The area is `engineering.measure`'s, not a second shoelace. + """ + kept: list[Pt] = [] + for p in points: + if not kept or math.dist(kept[-1], p) > _EPS: + kept.append(p) + if len(kept) > 1 and math.dist(kept[0], kept[-1]) <= _EPS: + kept.pop() + if len(kept) < 3: + return None + area, _perimeter = polygon_area_perimeter([(x, y, 0.0) for x, y in kept], True) + return tuple(kept) if area > _EPS else None + + +def _key(p: Pt) -> tuple[float, float]: + return (round(p[0], _KEY_DECIMALS) + 0.0, round(p[1], _KEY_DECIMALS) + 0.0) + + +# -- the wall's own numbers ----------------------------------------------------- + + +def face_offsets(wall: Wall) -> tuple[float, float]: + """``(left, right)``: the signed offsets of a wall's two faces along the axis's left normal.""" + thickness = float(wall.thickness) + if wall.justification == "center": + return (thickness / 2.0, -thickness / 2.0) + if wall.justification == "left": + return (thickness, 0.0) + if wall.justification == "right": + return (0.0, -thickness) + raise ValueError( + f"wall {wall.id!r}: justification {wall.justification!r}; " + "justifications are center, left, right" + ) + + +def wall_segments(wall: Wall) -> tuple[tuple[Pt, Pt], ...]: + """The wall's axis segments in order, the closing one last when ``closed``.""" + points = [(float(x), float(y)) for x, y in wall.axis] + pairs = list(zip(points, points[1:], strict=False)) + if wall.closed: + pairs.append((points[-1], points[0])) + return tuple(pairs) + + +# -- the network ---------------------------------------------------------------- + + +@dataclass +class _Seg: + wall: int + index: int + va: tuple[int, int] + vb: tuple[int, int] + left: float + right: float + cuts: list[float] = field(default_factory=list) + + +@dataclass +class _Piece: + seg: _Seg + a: Pt + b: Pt + s0: float + corners: dict = field(default_factory=dict) # (end 0|1, "left"|"right") -> Pt + + +def _validate(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> None: + if not isinstance(join_tol, (int, float)) or not math.isfinite(join_tol) or join_tol <= 0: + raise ValueError(f"join_tol: must be a finite number above zero, got {join_tol!r}") + seen: dict[str, int] = {} + for i, wall in enumerate(walls): + if not isinstance(wall, Wall): + raise ValueError(f"walls[{i}]: expected a Wall, got {type(wall).__name__}") + if wall.id in seen: + raise ValueError( + f"walls[{i}].id: {wall.id!r} is already used by walls[{seen[wall.id]}]" + ) + seen[wall.id] = i + try: + wall_hatch(wall.material) + except ValueError as exc: + # wall_hatch's message already names its field; the path replaces it + reason = str(exc).removeprefix("material: ") + raise ValueError(f"walls[{i}].material: {reason}") from None + try: + face_offsets(wall) + except ValueError as exc: + raise ValueError(f"walls[{i}].justification: {exc}") from None + segments = wall_segments(wall) + if not segments: + raise ValueError(f"walls[{i}].axis: needs at least two points") + for k, (a, b) in enumerate(segments): + if math.dist(a, b) <= _EPS: + raise ValueError(f"walls[{i}].axis[{k}]: zero-length segment at {a}") + validate_openings(list(walls), list(openings)) + + +def _segments(walls: Sequence[Wall]) -> tuple[dict, list[_Seg]]: + pos: dict[tuple[int, int], Pt] = {} + segs: list[_Seg] = [] + for wi, wall in enumerate(walls): + left, right = face_offsets(wall) + count = len(wall.axis) + for k, (x, y) in enumerate(wall.axis): + pos[(wi, k)] = (float(x), float(y)) + for i in range(count if wall.closed else count - 1): + segs.append(_Seg(wi, i, (wi, i), (wi, (i + 1) % count), left, right)) + return pos, segs + + +def _name(walls: Sequence[Wall], index: int) -> str: + return f"walls[{index}] ({walls[index].id!r})" + + +def _refuse_shallow(walls, one: _Seg, other: _Seg, u: Pt, v: Pt) -> None: + angle = _line_angle(u, v) + if angle < MIN_JOIN_ANGLE_DEG: + raise ValueError( + f"{_name(walls, one.wall)} meets {_name(walls, other.wall)} at {angle:.1f}°; " + f"a junction below {MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean " + "outline - end the wall at a steeper angle." + ) + + +def _resolve_tees(walls, pos, segs, tol: float) -> None: + """Carry every axis end that stops inside another wall onto that wall's axis, and split it.""" + touching: dict[tuple[int, int], list[_Seg]] = {} + for seg in segs: + touching.setdefault(seg.va, []).append(seg) + touching.setdefault(seg.vb, []).append(seg) + for vertex, mine in touching.items(): + for other in segs: + if any(other is seg for seg in mine): + continue + p = pos[vertex] + a, b = pos[other.va], pos[other.vb] + if math.dist(p, a) <= tol or math.dist(p, b) <= tol: + continue + length = math.dist(a, b) + u = _unit(a, b) + s = _dot(_sub(p, a), u) + d = _dot(_sub(p, a), _left(u)) + if s < -tol or s > length + tol or d < other.right - tol or d > other.left + tol: + continue + for seg in mine: + _refuse_shallow(walls, seg, other, _unit(pos[seg.va], pos[seg.vb]), u) + if abs(d) <= tol: + x = _add(a, _mul(u, s)) + elif len(mine) == 1: + seg = mine[0] + far = pos[seg.vb] if seg.va == vertex else pos[seg.va] + x = _meet(far, _unit(far, p), a, u) + else: + raise ValueError( + f"the corner of {_name(walls, vertex[0])} at ({p[0]:g}, {p[1]:g}) lies inside " + f"{_name(walls, other.wall)} off its axis; end the wall there or move the corner " + "onto the other wall's axis." + ) + sx = _dot(_sub(x, a), u) + if sx < -tol or sx > length + tol: + raise ValueError( + f"{_name(walls, vertex[0])} ends inside {_name(walls, other.wall)} at " + f"({p[0]:g}, {p[1]:g}), but its axis meets that wall's axis beyond its end; " + "join the two axes at a common point." + ) + if sx <= tol: + pos[vertex] = a + elif sx >= length - tol: + pos[vertex] = b + else: + pos[vertex] = x + other.cuts.append(sx) + break + + +def _resolve_crossings(walls, pos, segs, tol: float) -> None: + """Split both segments wherever two axes cross in their interiors (an X).""" + for i, one in enumerate(segs): + for other in segs[i + 1 :]: + if {one.va, one.vb} & {other.va, other.vb}: + continue + a1, b1 = pos[one.va], pos[one.vb] + a2, b2 = pos[other.va], pos[other.vb] + r = _sub(b1, a1) + q = _sub(b2, a2) + denom = _cross(r, q) + if abs(denom) <= _EPS: + continue + t = _cross(_sub(a2, a1), q) / denom + w = _cross(_sub(a2, a1), r) / denom + l1 = math.hypot(*r) + l2 = math.hypot(*q) + if tol < t * l1 < l1 - tol and tol < w * l2 < l2 - tol: + _refuse_shallow(walls, one, other, _unit(a1, b1), _unit(a2, b2)) + one.cuts.append(t * l1) + other.cuts.append(w * l2) + + +def _pieces(walls, pos, segs, tol: float) -> list[_Piece]: + pieces: list[_Piece] = [] + for seg in segs: + a, b = pos[seg.va], pos[seg.vb] + length = math.dist(a, b) + if length <= tol: + raise ValueError( + f"{_name(walls, seg.wall)}: segment {seg.index} lies inside the wall it joins - " + "nothing of it stands clear of that wall; lengthen it or leave it out." + ) + u = _unit(a, b) + stops: list[float] = [] + for cut in sorted(seg.cuts): + if tol < cut < length - tol and (not stops or cut - stops[-1] > tol): + stops.append(cut) + bounds = [0.0, *stops, length] + for s0, s1 in zip(bounds, bounds[1:], strict=False): + start = a if s0 == 0.0 else _add(a, _mul(u, s0)) + end = b if s1 == length else _add(a, _mul(u, s1)) + pieces.append(_Piece(seg, start, end, s0)) + return pieces + + +def _outgoing(piece: _Piece, end: int): + """(origin, direction, left normal, left offset, right offset) leaving the node at ``end``.""" + u = _unit(piece.a, piece.b) + if end == 0: + return piece.a, u, _left(u), piece.seg.left, piece.seg.right + back = (-u[0], -u[1]) + return piece.b, back, _left(back), -piece.seg.right, -piece.seg.left + + +def _canonical(end: int, side: str) -> str: + """Leaving through its end, a piece's left face is its canonical right one.""" + if end == 0: + return side + return "right" if side == "left" else "left" + + +def _foot(node: Pt, p: Pt, u: Pt) -> Pt: + return _add(p, _mul(u, _dot(_sub(node, p), u))) + + +def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> dict: + walls = list(walls) + openings = list(openings) + _validate(walls, openings, join_tol) + pos, segs = _segments(walls) + _resolve_tees(walls, pos, segs, join_tol) + _resolve_crossings(walls, pos, segs, join_tol) + pieces = _pieces(walls, pos, segs, join_tol) + + faces: list[tuple[Line, str]] = [] + jambs: list[tuple[Line, str]] = [] + thresholds: list[tuple[Line, str]] = [] + regions: list[tuple[tuple[Pt, ...], str]] = [] + omitted: list[tuple[dict, str]] = [] + + # -- the nodes ------------------------------------------------------------ + nodes: list[Pt] = [] + ends: list[list[tuple[_Piece, int]]] = [] + for piece in pieces: + for end, point in ((0, piece.a), (1, piece.b)): + for k, node in enumerate(nodes): + if math.dist(point, node) <= join_tol: + ends[k].append((piece, end)) + break + else: + nodes.append(point) + ends.append([(piece, end)]) + + for node, members in zip(nodes, ends, strict=True): + if len(members) == 1: + piece, end = members[0] + origin, _u, n, left, right = _outgoing(piece, end) + pl = _add(origin, _mul(n, left)) + pr = _add(origin, _mul(n, right)) + piece.corners[(end, _canonical(end, "left"))] = pl + piece.corners[(end, _canonical(end, "right"))] = pr + jambs.append((Line(pl, pr, "wall"), walls[piece.seg.wall].id)) + continue + ordered = sorted(members, key=lambda m: _heading(_outgoing(*m)[1])) + hub: list[Pt] = [] + for idx, (pi, ei) in enumerate(ordered): + pj, ej = ordered[(idx + 1) % len(ordered)] + oi, ui, ni, li, _ri = _outgoing(pi, ei) + oj, uj, nj, _lj, rj = _outgoing(pj, ej) + gap = (_heading(uj) - _heading(ui)) % 360.0 + if gap < MIN_JOIN_ANGLE_DEG: + raise ValueError( + f"{_name(walls, pi.seg.wall)} and {_name(walls, pj.seg.wall)} meet at " + f"({node[0]:g}, {node[1]:g}) {gap:.1f}° apart; a junction below " + f"{MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean outline." + ) + face_i = _add(oi, _mul(ni, li)) + face_j = _add(oj, _mul(nj, rj)) + if abs(_cross(ui, uj)) <= 1e-12: + ci = _foot(node, face_i, ui) + cj = _foot(node, face_j, uj) + if math.dist(ci, cj) <= _EPS: + cj = ci + else: + faces.append((Line(ci, cj, "wall"), walls[pi.seg.wall].id)) + else: + ci = cj = _meet(face_i, ui, face_j, uj) + pi.corners[(ei, _canonical(ei, "left"))] = ci + pj.corners[(ej, _canonical(ej, "right"))] = cj + hub.append(ci) + if cj is not ci: + hub.append(cj) + if len(ordered) >= 3: + polygon = _polygon(hub) + if polygon: + owner = min(piece.seg.wall for piece, _end in ordered) + regions.append((polygon, walls[owner].id)) + + # -- the openings ----------------------------------------------------------- + by_id = {wall.id: i for i, wall in enumerate(walls)} + by_seg = {(seg.wall, seg.index): seg for seg in segs} + cut_in: dict[int, list[tuple[float, float]]] = {} + for opening in openings: + wi = by_id[opening.wall] + wall = walls[wi] + si, along = segment_of(wall, float(opening.offset)) + a0, b0 = wall_segments(wall)[si] + start = _add(a0, _mul(_unit(a0, b0), along)) + seg = by_seg[(wi, si)] + u = _unit(pos[seg.va], pos[seg.vb]) + s0 = _dot(_sub(start, pos[seg.va]), u) + s1 = s0 + float(opening.width) + host = None + for piece in pieces: + length = math.dist(piece.a, piece.b) + if piece.seg is seg and piece.s0 - _EPS <= s0 and s1 <= piece.s0 + length + _EPS: + host = piece + break + if host is None: + omitted.append( + ( + { + "element": opening.id, + "reason": f"it crosses a junction on wall {wall.id!r} segment {si}; " + "an opening must lie inside one stretch of wall", + }, + wall.id, + ) + ) + continue + d0 = s0 - host.s0 + d1 = s1 - host.s0 + clear_from = max( + _dot(_sub(host.corners[(0, side)], host.a), u) for side in ("left", "right") + ) + clear_to = min(_dot(_sub(host.corners[(1, side)], host.a), u) for side in ("left", "right")) + if d0 < clear_from - _EPS or d1 > clear_to + _EPS: + omitted.append( + ( + { + "element": opening.id, + "reason": f"it runs into a junction of wall {wall.id!r}: on segment {si} " + f"the solid wall runs from {host.s0 + clear_from:g} to " + f"{host.s0 + clear_to:g} mm along the axis, the opening from " + f"{s0:g} to {s1:g}", + }, + wall.id, + ) + ) + continue + cut_in.setdefault(id(host), []).append((d0, d1)) + + # -- faces, jambs and regions, piece by piece --------------------------------- + for piece in pieces: + seg = piece.seg + owner = walls[seg.wall].id + u = _unit(piece.a, piece.b) + n = _left(u) + + def at(s: float, offset: float, piece=piece, u=u, n=n) -> Pt: + return _add(_add(piece.a, _mul(u, s)), _mul(n, offset)) + + cur_l = piece.corners[(0, "left")] + cur_r = piece.corners[(0, "right")] + runs: list[tuple[Pt, Pt, Pt, Pt]] = [] + for d0, d1 in sorted(cut_in.get(id(piece), [])): + la, ra = at(d0, seg.left), at(d0, seg.right) + lb, rb = at(d1, seg.left), at(d1, seg.right) + runs.append((cur_l, cur_r, la, ra)) + jambs.append((Line(la, ra, "wall"), owner)) + jambs.append((Line(lb, rb, "wall"), owner)) + thresholds.append((Line(la, lb, "wall"), owner)) + thresholds.append((Line(ra, rb, "wall"), owner)) + cur_l, cur_r = lb, rb + runs.append((cur_l, cur_r, piece.corners[(1, "left")], piece.corners[(1, "right")])) + for sl, sr, el, er in runs: + if math.dist(sl, el) > _EPS: + faces.append((Line(sl, el, "wall"), owner)) + if math.dist(sr, er) > _EPS: + faces.append((Line(sr, er, "wall"), owner)) + polygon = _polygon((sr, er, el, sl)) + if polygon and is_self_intersecting(polygon): + raise ValueError( + f"{_name(walls, seg.wall)}: segment {seg.index} is too short for the " + "junctions at its ends - its outline would cross itself; lengthen it " + "or join it elsewhere." + ) + if polygon: + regions.append((polygon, owner)) + + return { + "walls": walls, + "faces": _merge_collinear(faces, jambs), + "jambs": jambs, + "thresholds": thresholds, + "regions": regions, + "omitted": omitted, + } + + +def _merge_collinear(faces, jambs): + """Join two faces of one wall that continue each other in a straight line. + + That is what keeps a crossing wall's far face one line past a T, and a + straight axis with an intermediate vertex one face. A point touched by a + third line (a jamb, another face) is a real corner and is left alone. + """ + items = list(faces) + while True: + touching: dict[tuple[float, float], list[int]] = {} + for idx, (line, _owner) in enumerate(items): + for p in (line.p1, line.p2): + touching.setdefault(_key(p), []).append(idx) + for line, _owner in jambs: + for p in (line.p1, line.p2): + touching.setdefault(_key(p), []).append(-1) + for key, idxs in touching.items(): + if len(idxs) != 2 or -1 in idxs or idxs[0] == idxs[1]: + continue + i, j = sorted(idxs) + (li, wi), (lj, wj) = items[i], items[j] + if wi != wj: + continue + near_i, far_i = (li.p2, li.p1) if _key(li.p2) == key else (li.p1, li.p2) + far_j = lj.p2 if _key(lj.p1) == key else lj.p1 + d1 = _unit(far_i, near_i) + d2 = _unit(near_i, far_j) + if abs(_cross(d1, d2)) > 1e-9 or _dot(d1, d2) <= 0.0: + continue + items[i] = (Line(far_i, far_j, "wall"), wi) + del items[j] + break + else: + return items + + +def _assemble(parts: dict, poche: bool, owner: str | None) -> WallGeometry: + def mine(rows): + return [item for item, who in rows if owner is None or who == owner] + + material = {wall.id: wall.material for wall in parts["walls"]} + regions = [(polygon, who) for polygon, who in parts["regions"] if owner in (None, who)] + hatches = ( + tuple(HatchArea((polygon,), material[who]) for polygon, who in regions) if poche else () + ) + return WallGeometry( + faces=tuple(mine(parts["faces"])), + jambs=tuple(mine(parts["jambs"])), + regions=tuple(polygon for polygon, _who in regions), + hatches=hatches, + omitted=tuple(mine(parts["omitted"])), + thresholds=tuple(mine(parts["thresholds"])), + ) + + +def wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry: + """The whole network's outlines, jambs, regions and poche - see the module docstring.""" + return _assemble(_build(walls, openings, join_tol), poche, None) + + +def wall_geometry_by_wall( + walls, openings=(), *, poche=True, join_tol=1.0 +) -> dict[str, WallGeometry]: + """The same geometry, split by the wall each element belongs to. + + A hub (the middle of a T or an X) belongs to the first of its walls in the + order given. `engineering.arch.draw` uses the split to redraw only the walls + whose outline a new wall or opening changes. + """ + parts = _build(walls, openings, join_tol) + return {wall.id: _assemble(parts, poche, wall.id) for wall in parts["walls"]} + + +def room_segments(geometry: WallGeometry) -> tuple[tuple[Pt, Pt], ...]: + """Faces, jambs and thresholds as bare segments - the input of the planar-face finder.""" + return tuple( + (line.p1, line.p2) for line in (*geometry.faces, *geometry.jambs, *geometry.thresholds) + ) diff --git a/tests/test_arch_walls.py b/tests/test_arch_walls.py new file mode 100644 index 0000000..0ab3a0b --- /dev/null +++ b/tests/test_arch_walls.py @@ -0,0 +1,359 @@ +"""The wall engine: outlines, L/T/X junctions, opening gaps and poche. + +Every corner below is hand-computed and written out: two 200 mm walls centred +on their axes put their faces 100 mm either side, so an L at the origin has +its inner corner at (100, 100) and its outer one at (-100, -100), and a T or +an X leaves the faces of the crossing wall cut exactly 100 mm either side of +the stem's axis. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.model import Opening, Wall +from engineering.arch.walls import ( + MIN_JOIN_ANGLE_DEG, + WallGeometry, + face_offsets, + room_segments, + wall_geometry, + wall_geometry_by_wall, + wall_segments, +) +from engineering.mech.primitives import HatchArea, Line + + +def seg(line) -> tuple: + """An undirected, rounded segment: comparable as a set member.""" + a = (round(line.p1[0], 9) + 0.0, round(line.p1[1], 9) + 0.0) + b = (round(line.p2[0], 9) + 0.0, round(line.p2[1], 9) + 0.0) + return tuple(sorted((a, b))) + + +def segs(lines) -> set: + return {seg(line) for line in lines} + + +def S(x1, y1, x2, y2) -> tuple: + return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2))))) + + +def ring(points) -> tuple: + """A polygon, rounded and rotated to start at its smallest vertex (order kept).""" + pts = [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in points] + k = pts.index(min(pts)) + return tuple(pts[k:] + pts[:k]) + + +def signed_area(points) -> float: + return ( + sum( + x1 * y2 - x2 * y1 + for (x1, y1), (x2, y2) in zip(points, list(points[1:]) + [points[0]], strict=True) + ) + / 2.0 + ) + + +def wall(id, *axis, thickness=200.0, **kw) -> Wall: + return Wall(id=id, axis=tuple(axis), thickness=thickness, **kw) + + +# -- the wall's own numbers ------------------------------------------------------ + + +def test_face_offsets_follow_the_justification(): + assert face_offsets(wall("c", (0, 0), (1, 0))) == (100.0, -100.0) + assert face_offsets(wall("l", (0, 0), (1, 0), justification="left")) == (200.0, 0.0) + assert face_offsets(wall("r", (0, 0), (1, 0), justification="right")) == (0.0, -200.0) + + +def test_wall_segments_close_a_closed_axis(): + square = wall("s", (0, 0), (4000, 0), (4000, 3000), (0, 3000), closed=True) + assert wall_segments(square) == ( + ((0.0, 0.0), (4000.0, 0.0)), + ((4000.0, 0.0), (4000.0, 3000.0)), + ((4000.0, 3000.0), (0.0, 3000.0)), + ((0.0, 3000.0), (0.0, 0.0)), + ) + + +# -- a free end ------------------------------------------------------------------ + + +def test_a_single_wall_is_two_faces_closed_by_a_jamb_at_each_free_end(): + geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="left")]) + assert isinstance(geo, WallGeometry) + assert segs(geo.faces) == {S(0, 100, 3000, 100), S(0, 0, 3000, 0)} + assert segs(geo.jambs) == {S(0, 0, 0, 100), S(3000, 0, 3000, 100)} + assert [ring(r) for r in geo.regions] == [ring([(0, 0), (3000, 0), (3000, 100), (0, 100)])] + assert all(line.role == "wall" for line in (*geo.faces, *geo.jambs)) + + +def test_right_justification_puts_the_wall_on_the_other_side_of_the_axis(): + geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="right")]) + assert segs(geo.faces) == {S(0, 0, 3000, 0), S(0, -100, 3000, -100)} + + +# -- L ----------------------------------------------------------------------------- + + +L_FACES = { + S(100, 100, 4000, 100), # wall a, inner face + S(-100, -100, 4000, -100), # wall a, outer face + S(100, 100, 100, 3000), # wall b, inner face + S(-100, -100, -100, 3000), # wall b, outer face +} +L_JAMBS = {S(4000, -100, 4000, 100), S(-100, 3000, 100, 3000)} +L_REGIONS = { + ring([(-100, -100), (4000, -100), (4000, 100), (100, 100)]), + ring([(100, 100), (100, 3000), (-100, 3000), (-100, -100)]), +} + + +def test_an_l_between_two_walls_mitres_both_faces_to_their_intersections(): + geo = wall_geometry([wall("a", (0, 0), (4000, 0)), wall("b", (0, 0), (0, 3000))]) + assert segs(geo.faces) == L_FACES + assert segs(geo.jambs) == L_JAMBS + assert {ring(r) for r in geo.regions} == L_REGIONS + assert len(geo.hatches) == 2 + + +def test_an_l_inside_one_wall_is_the_same_corner(): + geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))]) + assert segs(geo.faces) == L_FACES + assert segs(geo.jambs) == L_JAMBS + assert {ring(r) for r in geo.regions} == L_REGIONS + + +def test_every_region_is_counter_clockwise(): + geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))]) + assert all(signed_area(r) > 0 for r in geo.regions) + + +# -- T ----------------------------------------------------------------------------- + + +def test_a_t_stops_the_stem_on_the_near_face_and_keeps_the_far_face_whole(): + geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))]) + assert segs(geo.faces) == { + S(-3000, -100, 3000, -100), # the far face: one line past the stem + S(-3000, 100, -100, 100), # the near face, cut where the stem meets it + S(100, 100, 3000, 100), + S(-100, 100, -100, 2500), # the stem, stopped on the near face + S(100, 100, 100, 2500), + } + assert segs(geo.jambs) == { + S(-3000, -100, -3000, 100), + S(3000, -100, 3000, 100), + S(-100, 2500, 100, 2500), + } + assert {ring(r) for r in geo.regions} == { + ring([(-3000, -100), (0, -100), (-100, 100), (-3000, 100)]), + ring([(0, -100), (3000, -100), (3000, 100), (100, 100)]), + ring([(100, 100), (100, 2500), (-100, 2500), (-100, 100)]), + ring([(100, 100), (-100, 100), (0, -100)]), # the hub the three pieces leave + } + + +def test_a_stem_drawn_to_the_face_is_carried_to_the_axis(): + touching = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 100), (0, 2500))]) + on_axis = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))]) + assert segs(touching.faces) == segs(on_axis.faces) + + +# -- X ----------------------------------------------------------------------------- + + +def test_an_x_cuts_all_four_faces(): + geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, -2500), (0, 2500))]) + assert segs(geo.faces) == { + S(-3000, 100, -100, 100), + S(100, 100, 3000, 100), + S(-3000, -100, -100, -100), + S(100, -100, 3000, -100), + S(-100, -2500, -100, -100), + S(-100, 100, -100, 2500), + S(100, -2500, 100, -100), + S(100, 100, 100, 2500), + } + assert len(geo.jambs) == 4 + assert ring([(100, 100), (-100, 100), (-100, -100), (100, -100)]) in { + ring(r) for r in geo.regions + } + assert len(geo.regions) == 5 # four arms and the hub + + +# -- openings ------------------------------------------------------------------------ + + +def test_a_door_removes_its_width_from_both_faces_and_is_closed_by_two_jambs(): + geo = wall_geometry( + [wall("a", (0, 0), (5000, 0))], + [Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)], + ) + assert segs(geo.faces) == { + S(0, 100, 1000, 100), + S(1900, 100, 5000, 100), + S(0, -100, 1000, -100), + S(1900, -100, 5000, -100), + } + assert segs(geo.jambs) == { + S(0, -100, 0, 100), + S(5000, -100, 5000, 100), + S(1000, -100, 1000, 100), + S(1900, -100, 1900, 100), + } + assert segs(geo.thresholds) == {S(1000, 100, 1900, 100), S(1000, -100, 1900, -100)} + assert {ring(r) for r in geo.regions} == { + ring([(0, -100), (1000, -100), (1000, 100), (0, 100)]), + ring([(1900, -100), (5000, -100), (5000, 100), (1900, 100)]), + } + assert [h.material for h in geo.hatches] == ["brick", "brick"] + assert geo.omitted == () + + +def test_an_opening_is_placed_along_the_whole_axis_not_its_first_segment(): + geo = wall_geometry( + [wall("a", (0, 0), (4000, 0), (4000, 3000))], + [Opening(id="w1", wall="a", kind="window", offset=4500.0, width=1200.0)], + ) + assert {S(3900, 100, 3900, 500), S(3900, 1700, 3900, 3000)} <= segs(geo.faces) + assert {S(3900, 500, 4100, 500), S(3900, 1700, 4100, 1700)} <= segs(geo.jambs) + + +def test_an_opening_that_runs_into_a_junction_is_omitted_by_name(): + geo = wall_geometry( + [wall("a", (0, 0), (4000, 0), (4000, 3000))], + [Opening(id="d9", wall="a", kind="door", offset=3500.0, width=450.0)], + ) + ((entry,),) = [geo.omitted] + assert entry["element"] == "d9" + assert "junction" in entry["reason"] + assert segs(geo.faces) == segs( + wall_geometry([wall("a", (0, 0), (4000, 0), (4000, 3000))]).faces + ) + + +# -- poche ----------------------------------------------------------------------------- + + +def test_one_hatch_per_region_in_the_wall_material_and_none_without_poche(): + walls = [wall("a", (-3000, 0), (3000, 0), material="concrete"), wall("b", (0, 0), (0, 2500))] + geo = wall_geometry(walls) + assert len(geo.hatches) == len(geo.regions) == 4 + assert all(isinstance(h, HatchArea) and len(h.loops) == 1 for h in geo.hatches) + assert sorted(h.material for h in geo.hatches) == ["brick", "concrete", "concrete", "concrete"] + bare = wall_geometry(walls, poche=False) + assert bare.hatches == () + assert len(bare.regions) == 4 + + +def test_by_wall_splits_every_element_to_its_owner(): + walls = [wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))] + split = wall_geometry_by_wall(walls) + whole = wall_geometry(walls) + assert set(split) == {"a", "b"} + assert segs(split["a"].faces) | segs(split["b"].faces) == segs(whole.faces) + assert segs(split["b"].faces) == {S(-100, 100, -100, 2500), S(100, 100, 100, 2500)} + assert len(split["a"].regions) == 3 # two arms and the hub (a is first) + assert len(split["b"].regions) == 1 + + +def test_room_segments_are_faces_jambs_and_thresholds(): + geo = wall_geometry( + [wall("a", (0, 0), (5000, 0))], + [Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)], + ) + segments = room_segments(geo) + assert len(segments) == len(geo.faces) + len(geo.jambs) + len(geo.thresholds) == 10 + assert {S(*p1, *p2) for p1, p2 in segments} == segs(geo.faces) | segs(geo.jambs) | segs( + geo.thresholds + ) + + +# -- refusals ------------------------------------------------------------------------- + + +def test_a_stem_meeting_a_wall_below_five_degrees_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (-2000, -150), (2000, 0))]) + message = str(excinfo.value) + assert "'a'" in message and "'b'" in message + assert f"{MIN_JOIN_ANGLE_DEG:g}°" in message + + +def test_two_ends_meeting_below_five_degrees_are_refused(): + with pytest.raises(ValueError, match="apart"): + wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (0, 0), (5000, 200))]) + + +def test_duplicate_wall_ids_are_refused_with_both_paths(): + with pytest.raises(ValueError, match=r"walls\[1\]\.id: 'a' is already used by walls\[0\]"): + wall_geometry([wall("a", (0, 0), (1000, 0)), wall("a", (0, 500), (1000, 500))]) + + +def test_an_unknown_material_is_refused_by_path(): + with pytest.raises(ValueError, match=r"walls\[0\]\.material") as excinfo: + wall_geometry([wall("a", (0, 0), (1000, 0), material="cheese")]) + assert "material: material" not in str(excinfo.value) + + +def test_a_corner_inside_another_wall_is_refused(): + with pytest.raises(ValueError, match="corner"): + wall_geometry( + [ + wall("a", (-3000, 0), (3000, 0)), + wall("b", (-500, 2000), (0, 50), (500, 2000)), + ] + ) + + +def test_an_end_whose_axis_misses_the_other_wall_is_refused(): + with pytest.raises(ValueError, match="beyond its end"): + wall_geometry([wall("a", (0, 0), (3000, 0)), wall("b", (2990, 80), (2000, 2000))]) + + +def test_a_stub_lying_inside_the_wall_it_joins_is_refused(): + with pytest.raises(ValueError, match="lies inside the wall it joins"): + wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 80))]) + + +def test_a_segment_too_short_for_its_corner_is_refused(): + # a 30 degree corner puts the inner mitre 100 / tan(15 deg) = 373.2 along the + # second segment, which is only 300 long: its outline would cross itself + with pytest.raises(ValueError, match="too short"): + wall_geometry([wall("a", (1000, 0), (0, 0), (259.8, 150.0))]) + + +def test_a_stub_ending_inside_its_own_corner_is_refused(): + # 60 long past a 200 mm L: its end lies inside the first segment's body + with pytest.raises(ValueError, match="lies inside the wall it joins"): + wall_geometry([wall("a", (0, 0), (1000, 0), (1000, 60))]) + + +def test_a_bad_join_tolerance_is_refused(): + with pytest.raises(ValueError, match="join_tol"): + wall_geometry([wall("a", (0, 0), (1000, 0))], join_tol=0.0) + + +def test_line_primitives_are_the_mech_line_type(): + geo = wall_geometry([wall("a", (0, 0), (1000, 0))]) + assert all(isinstance(line, Line) for line in (*geo.faces, *geo.jambs)) + + +def test_a_closed_axis_is_a_ring_with_no_jamb_and_a_4_by_5_m_inner_face(): + geo = wall_geometry([wall("r", (0, 0), (4200, 0), (4200, 5200), (0, 5200), closed=True)]) + assert geo.jambs == () + assert segs(geo.faces) == { + S(100, 100, 4100, 100), + S(4100, 100, 4100, 5100), + S(4100, 5100, 100, 5100), + S(100, 5100, 100, 100), + S(-100, -100, 4300, -100), + S(4300, -100, 4300, 5300), + S(4300, 5300, -100, 5300), + S(-100, 5300, -100, -100), + } + assert len(geo.regions) == 4 From 9e9138f22d96203921a743805307956f3c358bbf Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:46:39 +0300 Subject: [PATCH 2/4] fix(task-2): order-independent T cuts and bounded near-straight joins A T recorded its cut on the crossing segment as a distance from that segment's start; when the start was carried onto a third wall later in the same pass the cut went stale, so [c,b,a] drew a free-end jamb inside wall a, an uncut face and a double-hatched overlap where [c,a,b] was right. Cuts are now stored as points and measured against the final axis. A near-straight join whose faces are offset (a thickness or justification change, or a rounding kink between such walls) mitred at offset/sin(kink): faces 1e8 mm long and a clockwise region for a 0.01 mm rounding. A straight-through pair whose mitre lies more than _MITRE_REACH (2) thicknesses from the node now steps on the line bisecting the join - both sides of a two-end node together, so the pieces tile without a sliver - and every piece and hub ring is refused unless counter-clockwise, not only when it crosses itself. Pure geometry: no ActiveX member is touched, so no live probe applies. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/arch/walls.py | 79 +++++++++++++++---- tests/test_arch_walls.py | 155 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 16 deletions(-) diff --git a/engineering/arch/walls.py b/engineering/arch/walls.py index 6a8aedb..1627608 100644 --- a/engineering/arch/walls.py +++ b/engineering/arch/walls.py @@ -7,7 +7,11 @@ segments meet: * **L and straight joins** - two segment ends at one point. Each face is met by the face of its neighbour and both are trimmed or extended to their - intersection, so the corner is a clean mitre. + intersection, so the corner is a clean mitre. A near-straight join whose + faces are offset from each other (a thickness or justification change, or a + rounding kink between two such walls) would mitre kilometres away; past + ``_MITRE_REACH`` thicknesses it steps instead: both faces stop on the line + bisecting the join and a short face closes the step. * **T** - a segment ends *inside* another wall. Its axis is carried to the crossing axis, the crossing segment is split there, and the same rule stops the stem's faces on the crossing wall's near face. That face is cut where @@ -54,6 +58,14 @@ from engineering.mech.primitives import HatchArea, Line, Pt #: this engine, not a building rule. MIN_JOIN_ANGLE_DEG = 5.0 +#: How far from its node, in thicknesses of the thicker wall, the face mitre of +#: a near-straight join may lie. Two faces at equal offsets meet within 1.42 +#: thicknesses of the node at any kink up to 90 degrees; faces at different +#: offsets (a thickness or justification change) meet at offset / sin(kink), +#: kilometres away for a rounding kink, so past this reach the join steps +#: instead of flaring. +_MITRE_REACH = 2.0 + _EPS = 1e-9 #: Decimals a point is rounded to when face lines are matched end to end. _KEY_DECIMALS = 6 @@ -123,7 +135,9 @@ def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None: Every caller builds its ring counter-clockwise - a piece as right face out, left face back; a hub in the angular order of its corners - so the ring is - not turned here. The area is `engineering.measure`'s, not a second shoelace. + not turned here; a ring that comes out clockwise is inside out, and the + callers refuse it (`_counter_clockwise`). The area is + `engineering.measure`'s, not a second shoelace. """ kept: list[Pt] = [] for p in points: @@ -137,6 +151,12 @@ def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None: return tuple(kept) if area > _EPS else None +def _counter_clockwise(ring: Sequence[Pt]) -> bool: + """The ring's turning sense - the sign `polygon_area_perimeter` folds away.""" + twice = sum(_cross(p, q) for p, q in zip(ring, (*ring[1:], ring[0]), strict=True)) + return twice > 0.0 + + def _key(p: Pt) -> tuple[float, float]: return (round(p[0], _KEY_DECIMALS) + 0.0, round(p[1], _KEY_DECIMALS) + 0.0) @@ -179,7 +199,10 @@ class _Seg: vb: tuple[int, int] left: float right: float - cuts: list[float] = field(default_factory=list) + #: Where other axes stop on or cross this one, as points, never as distances: + #: a distance is measured from the segment's start, and that start may still + #: be carried onto a third wall later in the same pass. + cuts: list[Pt] = field(default_factory=list) @dataclass @@ -296,7 +319,7 @@ def _resolve_tees(walls, pos, segs, tol: float) -> None: pos[vertex] = b else: pos[vertex] = x - other.cuts.append(sx) + other.cuts.append(x) break @@ -319,8 +342,9 @@ def _resolve_crossings(walls, pos, segs, tol: float) -> None: l2 = math.hypot(*q) if tol < t * l1 < l1 - tol and tol < w * l2 < l2 - tol: _refuse_shallow(walls, one, other, _unit(a1, b1), _unit(a2, b2)) - one.cuts.append(t * l1) - other.cuts.append(w * l2) + crossing = _add(a1, _mul(r, t)) + one.cuts.append(crossing) + other.cuts.append(crossing) def _pieces(walls, pos, segs, tol: float) -> list[_Piece]: @@ -335,7 +359,7 @@ def _pieces(walls, pos, segs, tol: float) -> list[_Piece]: ) u = _unit(a, b) stops: list[float] = [] - for cut in sorted(seg.cuts): + for cut in sorted(_dot(_sub(point, a), u) for point in seg.cuts): if tol < cut < length - tol and (not stops or cut - stops[-1] > tol): stops.append(cut) bounds = [0.0, *stops, length] @@ -362,8 +386,8 @@ def _canonical(end: int, side: str) -> str: return "right" if side == "left" else "left" -def _foot(node: Pt, p: Pt, u: Pt) -> Pt: - return _add(p, _mul(u, _dot(_sub(node, p), u))) +def _thickness(piece: _Piece) -> float: + return piece.seg.left - piece.seg.right def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> dict: @@ -405,7 +429,7 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) jambs.append((Line(pl, pr, "wall"), walls[piece.seg.wall].id)) continue ordered = sorted(members, key=lambda m: _heading(_outgoing(*m)[1])) - hub: list[Pt] = [] + pairs: list[list] = [] for idx, (pi, ei) in enumerate(ordered): pj, ej = ordered[(idx + 1) % len(ordered)] oi, ui, ni, li, _ri = _outgoing(pi, ei) @@ -419,15 +443,33 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) ) face_i = _add(oi, _mul(ni, li)) face_j = _add(oj, _mul(nj, rj)) - if abs(_cross(ui, uj)) <= 1e-12: - ci = _foot(node, face_i, ui) - cj = _foot(node, face_j, uj) + mitre = None + if abs(_cross(ui, uj)) > 1e-12: + mitre = _meet(face_i, ui, face_j, uj) + reach = _MITRE_REACH * max(_thickness(pi), _thickness(pj)) + if _dot(ui, uj) < 0.0 and math.dist(mitre, node) > reach: + mitre = None # a near-straight join whose faces are offset: a step + pairs.append([pi, ei, pj, ej, face_i, ui, face_j, uj, mitre]) + if len(pairs) == 2 and any(pair[8] is None for pair in pairs): + # both sides of one straight-through join step on the same cut line, + # or the two pieces would leave a sliver between their ends + for pair in pairs: + pair[8] = None + hub: list[Pt] = [] + for pi, ei, pj, ej, face_i, ui, face_j, uj, mitre in pairs: + if mitre is not None: + ci = cj = mitre + else: + # the step: both faces stop on the line through the node that + # bisects the join - square across a straight join, and the line + # every equal-offset mitre of this node lies on anyway + cut = _left(_sub(ui, uj)) + ci = _meet(face_i, ui, node, cut) + cj = _meet(face_j, uj, node, cut) if math.dist(ci, cj) <= _EPS: cj = ci else: faces.append((Line(ci, cj, "wall"), walls[pi.seg.wall].id)) - else: - ci = cj = _meet(face_i, ui, face_j, uj) pi.corners[(ei, _canonical(ei, "left"))] = ci pj.corners[(ej, _canonical(ej, "right"))] = cj hub.append(ci) @@ -435,6 +477,11 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) hub.append(cj) if len(ordered) >= 3: polygon = _polygon(hub) + if polygon and not _counter_clockwise(polygon): + raise ValueError( + f"the junction at ({node[0]:g}, {node[1]:g}) cannot be resolved into a " + "clean outline - its middle turns inside out; move the walls' ends apart." + ) if polygon: owner = min(piece.seg.wall for piece, _end in ordered) regions.append((polygon, walls[owner].id)) @@ -522,7 +569,7 @@ def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) if math.dist(sr, er) > _EPS: faces.append((Line(sr, er, "wall"), owner)) polygon = _polygon((sr, er, el, sl)) - if polygon and is_self_intersecting(polygon): + if polygon and (is_self_intersecting(polygon) or not _counter_clockwise(polygon)): raise ValueError( f"{_name(walls, seg.wall)}: segment {seg.index} is too short for the " "junctions at its ends - its outline would cross itself; lengthen it " diff --git a/tests/test_arch_walls.py b/tests/test_arch_walls.py index 0ab3a0b..eb2ee3c 100644 --- a/tests/test_arch_walls.py +++ b/tests/test_arch_walls.py @@ -9,6 +9,9 @@ the stem's axis. from __future__ import annotations +import itertools +import math + import pytest from engineering.arch.model import Opening, Wall @@ -132,6 +135,117 @@ def test_every_region_is_counter_clockwise(): assert all(signed_area(r) > 0 for r in geo.regions) +# -- straight joins whose faces do not line up --------------------------------------- + + +def test_a_justification_change_on_a_straight_axis_steps_across_the_node(): + geo = wall_geometry( + [ + wall("a", (0, 0), (3000, 0), justification="left"), + wall("b", (3000, 0), (6000, 0), justification="right"), + ] + ) + assert segs(geo.faces) == { + S(0, 0, 3000, 0), + S(0, 200, 3000, 200), + S(3000, 200, 3000, 0), # the step: each face of a stops on the square cut + S(3000, 0, 3000, -200), # through the node, and so does b's + S(3000, 0, 6000, 0), + S(3000, -200, 6000, -200), + } + assert {ring(r) for r in geo.regions} == { + ring([(0, 0), (3000, 0), (3000, 200), (0, 200)]), + ring([(3000, -200), (6000, -200), (6000, 0), (3000, 0)]), + } + + +def test_a_rounding_kink_steps_like_the_straight_join_instead_of_mitring_kilometres_away(): + # 0.5 mm over 3 m: the faces 200 mm apart would meet at x = 1 203 000 + geo = wall_geometry( + [ + wall("a", (0, 0), (3000, 0), justification="left"), + wall("b", (3000, 0), (6000, 0.5), justification="right"), + ] + ) + points = [p for line in geo.faces for p in (line.p1, line.p2)] + # b's free end is square to its own axis: 200 * sin(0.5 / 3000) past x = 6000 + assert max(abs(x) for x, _y in points) <= 6000.0 + 0.04 + assert max(abs(y) for _x, y in points) <= 200.5 + 1e-6 + assert all(signed_area(r) > 0 for r in geo.regions) + assert sorted(signed_area(r) for r in geo.regions) == pytest.approx([600000, 600000], abs=50) + + +def test_a_thickness_change_at_a_small_kink_steps_at_the_node_and_does_not_flare(): + kink = math.radians(2.0) + geo = wall_geometry( + [ + wall("a", (0, 0), (3000, 0), thickness=100.0), + wall( + "b", + (3000, 0), + (3000 + 3000 * math.cos(kink), 3000 * math.sin(kink)), + thickness=300.0, + ), + ] + ) + a_faces = [line for line in geo.faces if abs(line.p1[1] - line.p2[1]) < 1e-9] + # a's two 100 mm faces stop at the node (the bisector of a 2 degree join), not + # 2865 mm into b where they would meet b's 300 mm faces + assert {round(y, 6) for line in a_faces for y in (line.p1[1], line.p2[1])} == {50.0, -50.0} + assert max(x for line in a_faces for x in (line.p1[0], line.p2[0])) < 3002.0 + assert all(signed_area(r) > 0 for r in geo.regions) + assert sorted(signed_area(r) for r in geo.regions) == pytest.approx([300000, 900000], abs=1) + + +def test_a_rounded_rotated_thickness_change_draws_the_same_step_as_the_exact_one(): + c, s = math.cos(math.radians(30)), math.sin(math.radians(30)) + + def along(d, digits=None): + x, y = d * c, d * s + return (round(x, digits), round(y, digits)) if digits is not None else (x, y) + + exact = wall_geometry( + [ + wall("a", along(0), along(3000), thickness=100.0), + wall("b", along(3000), along(6000), thickness=300.0), + ] + ) + rounded = wall_geometry( + [ + wall("a", along(0, 2), along(3000, 2), thickness=100.0), + wall("b", along(3000, 2), along(6000, 2), thickness=300.0), + ] + ) + assert len(rounded.faces) == len(exact.faces) == 6 + for mine, theirs in zip( + sorted(seg(line) for line in rounded.faces), + sorted(seg(line) for line in exact.faces), + strict=True, + ): + assert math.dist(mine[0], theirs[0]) < 0.05 and math.dist(mine[1], theirs[1]) < 0.05 + + +@pytest.mark.parametrize("kink", [0.0, 0.01, 0.5, 2.0, 8.0, 20.0, 45.0, -2.0, -30.0]) +@pytest.mark.parametrize("justs", list(itertools.product(("center", "left", "right"), repeat=2))) +@pytest.mark.parametrize("thicknesses", [(200.0, 200.0), (100.0, 300.0)]) +def test_a_straight_or_kinked_join_stays_near_its_node_and_every_region_is_ccw( + kink, justs, thicknesses +): + turn = math.radians(30.0 + kink) + p1 = (round(3000 * math.cos(math.radians(30)), 2), round(3000 * math.sin(math.radians(30)), 2)) + p2 = (round(p1[0] + 3000 * math.cos(turn), 2), round(p1[1] + 3000 * math.sin(turn), 2)) + geo = wall_geometry( + [ + wall("a", (0, 0), p1, thickness=thicknesses[0], justification=justs[0]), + wall("b", p1, p2, thickness=thicknesses[1], justification=justs[1]), + ] + ) + reach = 6000 + 2 * max(thicknesses) + assert all(math.hypot(*p) < reach for line in geo.faces for p in (line.p1, line.p2)) + assert len(geo.regions) == 2 + assert all(signed_area(r) > 0 for r in geo.regions) + + # -- T ----------------------------------------------------------------------------- @@ -163,6 +277,47 @@ def test_a_stem_drawn_to_the_face_is_carried_to_the_axis(): assert segs(touching.faces) == segs(on_axis.faces) +# c runs along y = 0; a is drawn to c's face (its start is carried from y = 100 +# down to c's axis) and b is a T into a's middle at y = 2000. The T on a must not +# depend on whether a's start has been carried yet when b is resolved. +T_ON_A_CARRIED_WALL = { + S(-1000, -100, 5000, -100), # c's far face, whole + S(-1000, 100, 1900, 100), # c's near face, cut by a + S(2100, 100, 5000, 100), + S(1900, 100, 1900, 1900), # a's near face to b, cut by b at 1900 / 2100 + S(1900, 2100, 1900, 4000), + S(2100, 100, 2100, 4000), # a's far face to b, whole + S(0, 1900, 1900, 1900), # b stops on a's near face + S(0, 2100, 1900, 2100), +} + + +@pytest.mark.parametrize("order", list(itertools.permutations("cab"))) +def test_a_t_into_a_carried_wall_does_not_depend_on_the_order_of_the_walls(order): + walls = { + "c": wall("c", (-1000, 0), (5000, 0)), + "a": wall("a", (2000, 100), (2000, 4000)), + "b": wall("b", (0, 2000), (2000, 2000)), + } + geo = wall_geometry([walls[name] for name in order]) + assert segs(geo.faces) == T_ON_A_CARRIED_WALL + assert segs(geo.jambs) == { + S(-1000, -100, -1000, 100), + S(5000, -100, 5000, 100), + S(1900, 4000, 2100, 4000), + S(0, 1900, 0, 2100), # b's free end - and no jamb inside wall a + } + assert {ring(r) for r in geo.regions} == { + ring([(-1000, -100), (2000, -100), (1900, 100), (-1000, 100)]), + ring([(2000, -100), (5000, -100), (5000, 100), (2100, 100)]), + ring([(2100, 100), (1900, 100), (2000, -100)]), # the hub on c + ring([(2100, 100), (2100, 2000), (1900, 1900), (1900, 100)]), # a, split at y = 2000 + ring([(2100, 2000), (2100, 4000), (1900, 4000), (1900, 2100)]), + ring([(1900, 2100), (1900, 1900), (2100, 2000)]), # the hub on a + ring([(0, 1900), (1900, 1900), (1900, 2100), (0, 2100)]), + } + + # -- X ----------------------------------------------------------------------------- From b68fe7ef66cd8632c645c5a34c7ea4b4993aa0f9 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:51:41 +0300 Subject: [PATCH 3/4] feat(arch): door and window symbols, straight/L/U stairs and the Blondel check openings.py draws what stands in the gap the wall engine cuts, from the same numbers - the wall axis, its face offsets and model.segment_of - so the symbol cannot drift off its gap. A door is drawn open at 90 degrees: its leaf as long as the opening is wide, square to the wall from the hinge on the swing-side face, and a quarter arc from the closed position to the open one. swing=in is the left of the axis (the inside of a CCW perimeter); hand names the hinge jamb seen from the swing side facing the wall. All four swing/hand pairs are tested against hand-computed hinges and arc angles. A window is a frame line on each face and two glazing lines at the thirds of the thickness. stairs.py draws every riser once as a line across its flight, the outline, a walking line up the middle with an open arrow at the top, and the language's 'up' word on the first tread at 2.5 mm times the plot scale. An L or a U turns on a square landing; its first flight carries (risers + 1) // 2 risers. The riser count and every dimension are refused by name before any geometry. blondel() reports 2R + G against 600-650 mm - the step-length rule attributed to Francois Blondel (1675), reported as a design rule and never as a building code: no national stair regulation is checked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/arch/__init__.py | 18 ++++ engineering/arch/openings.py | 87 +++++++++++++++++ engineering/arch/stairs.py | 164 +++++++++++++++++++++++++++++++ tests/test_arch_openings.py | 94 ++++++++++++++++++ tests/test_arch_stairs.py | 180 +++++++++++++++++++++++++++++++++++ 5 files changed, 543 insertions(+) create mode 100644 engineering/arch/openings.py create mode 100644 engineering/arch/stairs.py create mode 100644 tests/test_arch_openings.py create mode 100644 tests/test_arch_stairs.py diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 2898cff..2c2bc00 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -30,6 +30,16 @@ from .model import ( wall_from_dict, wall_length, ) +from .openings import opening_prims +from .stairs import ( + ARROW_PAPER_MM, + BLONDEL_RANGE, + STAIR_KINDS, + TEXT_PAPER_MM, + TURNS, + blondel, + stair_prims, +) from .walls import ( MIN_JOIN_ANGLE_DEG, WallGeometry, @@ -46,17 +56,23 @@ __all__ = [ "ARCH_LAYER_DEFS", "ARCH_LAYER_ROLES", "ARCH_ROLE_LAYER", + "ARROW_PAPER_MM", + "BLONDEL_RANGE", "KINDS", "LANGS", "MIN_JOIN_ANGLE_DEG", "Opening", "Room", + "STAIR_KINDS", "Stair", + "TEXT_PAPER_MM", + "TURNS", "WALL_HATCH_TABLE", "WALL_MATERIALS", "Wall", "WallGeometry", "axis_segments", + "blondel", "decode", "encode", "face_offsets", @@ -64,10 +80,12 @@ __all__ = [ "fmt_number", "from_payload", "opening_from_dict", + "opening_prims", "room_from_dict", "room_segments", "segment_of", "stair_from_dict", + "stair_prims", "tag_prefix", "to_payload", "to_values", diff --git a/engineering/arch/openings.py b/engineering/arch/openings.py new file mode 100644 index 0000000..f72467b --- /dev/null +++ b/engineering/arch/openings.py @@ -0,0 +1,87 @@ +"""Door and window symbols in plan, hosted in their wall. + +The wall engine (`walls.py`) cuts the gap; this module draws what stands in it. +Both read the opening's place from the same numbers - the wall's axis, its face +offsets and ``model.segment_of`` - so the symbol cannot drift off its gap. + +Conventions (this repository's, stated because plan symbols vary by office): + +* ``swing="in"`` opens to the **left** of the wall's axis, looking along it + from its first point; ``"out"`` to the right. A perimeter drawn + counter-clockwise has its inside on the left, so "in" means into the building. +* ``hand`` names the jamb that carries the hinges, seen by a person standing on + the side the door swings towards and facing the wall: ``"left"`` hinges on + their left-hand jamb. +* A door is drawn **open at 90°**: the leaf is a line as long as the opening is + wide, square to the wall from the hinge, on the swing-side face; the swing is + a quarter arc about the hinge from the closed position to the open one. +* A window is a frame line on each wall face across the opening and two + glazing lines between them at the thirds of the wall thickness (a drafting + convention, not a standard). + +The first primitive is the one `draw.py` writes the opening record on: the +door leaf, or the window's frame line on the left face. +""" + +from __future__ import annotations + +import math + +from engineering.arch.model import Opening, Wall, segment_of +from engineering.arch.walls import face_offsets, wall_segments +from engineering.mech.primitives import Arc, Line, Prim, Pt + + +def _deg(v: Pt) -> float: + return math.degrees(math.atan2(v[1], v[0])) % 360.0 + + +def opening_prims(wall: Wall, opening: Opening) -> tuple[Prim, ...]: + """The door leaf and swing, or the window frame and glazing, in WCS.""" + if opening.wall != wall.id: + raise ValueError( + f"opening {opening.id!r} is hosted in wall {opening.wall!r}, not in {wall.id!r}" + ) + width = float(opening.width) + index, along = segment_of(wall, float(opening.offset)) + (ax, ay), (bx, by) = wall_segments(wall)[index] + length = math.hypot(bx - ax, by - ay) + u = ((bx - ax) / length, (by - ay) / length) + n = (-u[1], u[0]) + left, right = face_offsets(wall) + + def at(s: float, offset: float) -> Pt: + return (ax + u[0] * s + n[0] * offset, ay + u[1] * s + n[1] * offset) + + s0, s1 = along, along + width + if opening.kind == "window": + third = (left - right) / 3.0 + return ( + Line(at(s0, left), at(s1, left), "window"), + Line(at(s0, right), at(s1, right), "window"), + Line(at(s0, right + third), at(s1, right + third), "window"), + Line(at(s0, right + 2.0 * third), at(s1, right + 2.0 * third), "window"), + ) + if opening.kind != "door": + raise ValueError(f"opening {opening.id!r}: kind {opening.kind!r}; kinds are door, window") + if opening.swing not in ("in", "out"): + raise ValueError(f"opening {opening.id!r}: swing {opening.swing!r}; swings are in, out") + if opening.hand not in ("left", "right"): + raise ValueError(f"opening {opening.id!r}: hand {opening.hand!r}; hands are left, right") + + side = 1.0 if opening.swing == "in" else -1.0 + face = left if side > 0 else right + # Facing the wall from the swing side, the viewer's left hand points along + # +u when they stand on the axis's left, and along -u when on its right. + toward = side if opening.hand == "left" else -side + hinge_s, free_s = (s1, s0) if toward > 0 else (s0, s1) + hinge = at(hinge_s, face) + closed = at(free_s, face) + opened = (hinge[0] + n[0] * side * width, hinge[1] + n[1] * side * width) + a_closed = _deg((closed[0] - hinge[0], closed[1] - hinge[1])) + a_open = _deg((opened[0] - hinge[0], opened[1] - hinge[1])) + if abs(((a_open - a_closed) % 360.0) - 90.0) < 1e-6: + swing = Arc(hinge, width, a_closed, a_open, "door") + else: + swing = Arc(hinge, width, a_open, a_closed, "door") + return (Line(hinge, opened, "door"), swing) diff --git a/engineering/arch/stairs.py b/engineering/arch/stairs.py new file mode 100644 index 0000000..ecdfc7f --- /dev/null +++ b/engineering/arch/stairs.py @@ -0,0 +1,164 @@ +"""Stairs in plan: straight, L and U flights, the walking line and the Blondel check. + +Geometry, in the stair's own frame (x up the first flight, y to its left): + +* ``start`` is the midpoint of the bottom riser; ``direction_deg`` turns the + frame into WCS. +* ``risers`` counts every rise from the lower floor to the upper one. A flight + of ``m`` risers has ``m - 1`` treads of depth ``going``; every riser is drawn + once as a line across the flight, so a straight stair of 17 risers shows 17 + lines, the first at the start and the last at the top. +* An L or a U splits the risers into two flights, ``(risers + 1) // 2`` in the + first, and turns ``turn`` on a square landing ``width`` deep. The U's two + flights lie side by side with no stairwell between them. +* The walking line runs up the middle of every flight and across the landing, + with an open arrow at the top; the "up" word of the chosen language sits on + the first tread. + +`blondel` reports 2R + G against 600-650 mm. That band is the step-length rule +attributed to François Blondel (Cours d'architecture, 1675), reported as a +design rule: it is not a building code, and no national stair regulation is +checked here. +""" + +from __future__ import annotations + +import math + +from engineering.arch.lang import vocab +from engineering.arch.model import Stair +from engineering.mech.primitives import Line, Poly, Prim, Pt, Text, rotate, translate + +BLONDEL_RANGE: tuple[float, float] = (600.0, 650.0) +STAIR_KINDS: tuple[str, ...] = ("straight", "l", "u") +TURNS: tuple[str, ...] = ("left", "right") +#: Paper sizes, multiplied by the plot-scale denominator. Drafting choices of +#: this repository, not standard values. +TEXT_PAPER_MM = 2.5 +ARROW_PAPER_MM = 3.0 + + +def _positive(value, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where}: expected a number, got {value!r}") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{where}: must be a finite number above zero, got {value!r}") + return number + + +def blondel(riser_height: float, going: float) -> dict: + """``{"value": 2R + G, "ok": 600 <= value <= 650, "range": [600.0, 650.0]}`` in mm.""" + rise = _positive(riser_height, "riser_height") + tread = _positive(going, "going") + value = 2.0 * rise + tread + low, high = BLONDEL_RANGE + return {"value": value, "ok": low <= value <= high, "range": [low, high]} + + +def _validate(stair: Stair, scale) -> tuple[float, float, int, float]: + width = _positive(stair.width, f"stair {stair.id!r}: width") + going = _positive(stair.going, f"stair {stair.id!r}: going") + _positive(stair.riser_height, f"stair {stair.id!r}: riser_height") + factor = _positive(scale, "scale") + if stair.kind not in STAIR_KINDS: + raise ValueError( + f"stair {stair.id!r}: kind {stair.kind!r}; kinds are {', '.join(STAIR_KINDS)}" + ) + if stair.turn not in TURNS: + raise ValueError(f"stair {stair.id!r}: turn {stair.turn!r}; turns are {', '.join(TURNS)}") + risers = stair.risers + if isinstance(risers, bool) or not isinstance(risers, int): + raise ValueError(f"stair {stair.id!r}: risers must be a whole number, got {risers!r}") + least = 2 if stair.kind == "straight" else 4 + if risers < least: + raise ValueError( + f"stair {stair.id!r}: risers {risers}; a {stair.kind} stair needs at least {least} " + "(two per flight)" + ) + for where, value in ( + ("direction_deg", stair.direction_deg), + ("start[0]", stair.start[0]), + ("start[1]", stair.start[1]), + ): + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + ): + raise ValueError(f"stair {stair.id!r}: {where} must be finite, got {value!r}") + return width, going, risers, factor + + +def _flights(kind: str, width: float, going: float, risers: int): + """Riser lines, outline lines and the walking-line path, turning left.""" + half = width / 2.0 + if kind == "straight": + run = (risers - 1) * going + treads = [Line((k * going, -half), (k * going, half), "stair") for k in range(risers)] + outline = [ + Line((0.0, -half), (run, -half), "stair"), + Line((0.0, half), (run, half), "stair"), + ] + return treads, outline, [(0.0, 0.0), (run, 0.0)] + first = (risers + 1) // 2 + second = risers - first + landing = (first - 1) * going + treads = [Line((k * going, -half), (k * going, half), "stair") for k in range(first)] + if kind == "l": + top = half + (second - 1) * going + treads += [ + Line((landing, half + k * going), (landing + width, half + k * going), "stair") + for k in range(second) + ] + outline = [ + Line((0.0, -half), (landing + width, -half), "stair"), + Line((landing + width, -half), (landing + width, top), "stair"), + Line((0.0, half), (landing, half), "stair"), + Line((landing, half), (landing, top), "stair"), + ] + return treads, outline, [(0.0, 0.0), (landing + half, 0.0), (landing + half, top)] + back = landing - (second - 1) * going + treads += [ + Line((landing - k * going, half), (landing - k * going, 3.0 * half), "stair") + for k in range(second) + ] + outline = [ + Line((0.0, -half), (landing + width, -half), "stair"), + Line((landing + width, -half), (landing + width, 3.0 * half), "stair"), + Line((landing + width, 3.0 * half), (back, 3.0 * half), "stair"), + Line((min(0.0, back), half), (landing, half), "stair"), + ] + path = [(0.0, 0.0), (landing + half, 0.0), (landing + half, width), (back, width)] + return treads, outline, path + + +def _mirror(p: Pt) -> Pt: + return (p[0], -p[1]) + + +def stair_prims(stair: Stair, *, lang: str = "en", scale=50) -> tuple[Prim, ...]: + """Treads, outline, walking line with its arrow, and the "up" label - in WCS, role "stair".""" + width, going, risers, factor = _validate(stair, scale) + label = vocab(lang)["up"] + treads, outline, path = _flights(stair.kind, width, going, risers) + if stair.turn == "right": + treads = [line._replace(p1=_mirror(line.p1), p2=_mirror(line.p2)) for line in treads] + outline = [line._replace(p1=_mirror(line.p1), p2=_mirror(line.p2)) for line in outline] + path = [_mirror(p) for p in path] + + (px, py), (ex, ey) = path[-2], path[-1] + length = math.hypot(ex - px, ey - py) + d = ((ex - px) / length, (ey - py) / length) + p = (-d[1], d[0]) + arrow = ARROW_PAPER_MM * factor + tail = (ex - d[0] * arrow, ey - d[1] * arrow) + head = [ + Line((tail[0] + p[0] * arrow / 3.0, tail[1] + p[1] * arrow / 3.0), (ex, ey), "stair"), + Line((tail[0] - p[0] * arrow / 3.0, tail[1] - p[1] * arrow / 3.0), (ex, ey), "stair"), + ] + height = TEXT_PAPER_MM * factor + text = Text((0.15 * going, -0.25 * width - 0.5 * height), label, height, 0.0, "stair") + local = (*treads, *outline, Poly(tuple(path), False, "stair"), *head, text) + turned = rotate(local, float(stair.direction_deg), (0.0, 0.0)) + return translate(turned, float(stair.start[0]), float(stair.start[1])) diff --git a/tests/test_arch_openings.py b/tests/test_arch_openings.py new file mode 100644 index 0000000..8cee80b --- /dev/null +++ b/tests/test_arch_openings.py @@ -0,0 +1,94 @@ +"""Door and window symbols: hinge, leaf, swing arc, frame and glazing. + +The wall runs along +X from the origin, 200 mm thick and centred, so its left +face is y = +100 and its right face y = -100. The door is 900 wide at offset +1000: its jambs are x = 1000 and x = 1900. For each swing/hand pair the hinge, +the open leaf and the arc angles are written out below by hand. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.model import Opening, Wall +from engineering.arch.openings import opening_prims +from engineering.mech.primitives import Arc, Line + +WALL = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0) + + +def door(swing: str, hand: str) -> Opening: + return Opening( + id="d1", wall="a", kind="door", offset=1000.0, width=900.0, swing=swing, hand=hand + ) + + +def pt(p) -> tuple[float, float]: + return (round(p[0], 9) + 0.0, round(p[1], 9) + 0.0) + + +@pytest.mark.parametrize( + ("swing", "hand", "hinge", "leaf_end", "start_deg", "end_deg"), + [ + # in = the axis's left (+Y). Facing the wall from +Y, the viewer's left is +X. + ("in", "left", (1900.0, 100.0), (1900.0, 1000.0), 90.0, 180.0), + ("in", "right", (1000.0, 100.0), (1000.0, 1000.0), 0.0, 90.0), + # out = the axis's right (-Y). Facing the wall from -Y, the viewer's left is -X. + ("out", "left", (1000.0, -100.0), (1000.0, -1000.0), 270.0, 0.0), + ("out", "right", (1900.0, -100.0), (1900.0, -1000.0), 180.0, 270.0), + ], +) +def test_a_door_hinges_on_its_hand_and_swings_a_quarter_arc_to_its_side( + swing, hand, hinge, leaf_end, start_deg, end_deg +): + leaf, arc = opening_prims(WALL, door(swing, hand)) + assert isinstance(leaf, Line) and leaf.role == "door" + assert (pt(leaf.p1), pt(leaf.p2)) == (hinge, leaf_end) + assert isinstance(arc, Arc) and arc.role == "door" + assert pt(arc.center) == hinge + assert arc.radius == 900.0 + assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (start_deg, end_deg) + + +def test_the_leaf_is_as_long_as_the_opening_is_wide_and_square_to_the_wall(): + leaf, _arc = opening_prims(WALL, door("in", "left")) + dx = leaf.p2[0] - leaf.p1[0] + dy = leaf.p2[1] - leaf.p1[1] + assert (dx, dy) == (0.0, 900.0) + + +def test_a_door_on_a_second_segment_is_placed_along_the_whole_axis(): + bent = Wall(id="b", axis=((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0)), thickness=200.0) + op = Opening( + id="d2", wall="b", kind="door", offset=4500.0, width=800.0, swing="in", hand="right" + ) + leaf, arc = opening_prims(bent, op) + # segment 1 runs +Y from (4000, 0); its left normal is -X, so "in" is x < 4000 + # and its left face is x = 3900. Facing +X from the left side, the viewer's + # left is +Y, so a right hand hinges at the near jamb, y = 500. + assert pt(leaf.p1) == (3900.0, 500.0) + assert pt(leaf.p2) == (3100.0, 500.0) + assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (90.0, 180.0) + + +def test_a_window_is_two_frame_lines_and_two_glazing_lines(): + window = Opening(id="w1", wall="a", kind="window", offset=2500.0, width=1200.0) + prims = opening_prims(WALL, window) + assert len(prims) == 4 + assert all(isinstance(p, Line) and p.role == "window" for p in prims) + assert [(pt(p.p1), pt(p.p2)) for p in prims] == [ + ((2500.0, 100.0), (3700.0, 100.0)), # frame, left face + ((2500.0, -100.0), (3700.0, -100.0)), # frame, right face + ((2500.0, round(-100.0 + 200.0 / 3.0, 9)), (3700.0, round(-100.0 + 200.0 / 3.0, 9))), + ((2500.0, round(-100.0 + 400.0 / 3.0, 9)), (3700.0, round(-100.0 + 400.0 / 3.0, 9))), + ] + + +def test_an_opening_of_another_wall_is_refused(): + with pytest.raises(ValueError, match="hosted in wall 'z'"): + opening_prims(WALL, Opening(id="d1", wall="z", kind="door", offset=0.0, width=900.0)) + + +def test_an_unknown_swing_is_refused_with_the_list(): + with pytest.raises(ValueError, match="swings are in, out"): + opening_prims(WALL, door("sideways", "left")) diff --git a/tests/test_arch_stairs.py b/tests/test_arch_stairs.py new file mode 100644 index 0000000..2b701b6 --- /dev/null +++ b/tests/test_arch_stairs.py @@ -0,0 +1,180 @@ +"""Stairs in plan: tread lines, outline, walking line, label, and the Blondel rule. + +Every expected coordinate is hand-computed from the stair's numbers: a flight +of m risers at going g runs (m - 1) * g, a riser line spans the full width, and +the first flight of an L or U carries (risers + 1) // 2 risers. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.lang import vocab +from engineering.arch.model import Stair +from engineering.arch.stairs import BLONDEL_RANGE, blondel, stair_prims +from engineering.mech.primitives import Line, Poly, Text + + +def S(x1, y1, x2, y2) -> tuple: + return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2))))) + + +def segs(prims) -> list: + out = [] + for p in prims: + if isinstance(p, Line): + a = (round(p.p1[0], 9) + 0.0, round(p.p1[1], 9) + 0.0) + b = (round(p.p2[0], 9) + 0.0, round(p.p2[1], 9) + 0.0) + out.append(tuple(sorted((a, b)))) + return out + + +def path(prims) -> list: + (walk,) = [p for p in prims if isinstance(p, Poly)] + return [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in walk.points] + + +def stair(**kw) -> Stair: + base = { + "id": "s1", + "start": (1000.0, 2000.0), + "direction_deg": 0.0, + "width": 1000.0, + "risers": 17, + "riser_height": 170.0, + "going": 290.0, + } + return Stair(**{**base, **kw}) + + +# -- Blondel ----------------------------------------------------------------------- + + +def test_blondel_reports_two_risers_plus_a_going_against_600_to_650(): + assert BLONDEL_RANGE == (600.0, 650.0) + assert blondel(170.0, 290.0) == {"value": 630.0, "ok": True, "range": [600.0, 650.0]} + assert blondel(200.0, 300.0) == {"value": 700.0, "ok": False, "range": [600.0, 650.0]} + assert blondel(150.0, 280.0)["ok"] is False # 580 + assert blondel(175.0, 250.0)["ok"] is True # 600, the lower edge is inside + + +def test_blondel_refuses_a_non_positive_dimension_by_name(): + with pytest.raises(ValueError, match="riser_height"): + blondel(0.0, 290.0) + with pytest.raises(ValueError, match="going"): + blondel(170.0, -1.0) + + +# -- straight ----------------------------------------------------------------------- + + +def test_a_straight_stair_draws_every_riser_once_and_both_stringers(): + prims = stair_prims(stair()) + lines = segs(prims) + risers = [S(1000 + 290 * k, 1500, 1000 + 290 * k, 2500) for k in range(17)] + assert set(risers) <= set(lines) + assert S(1000, 1500, 5640, 1500) in lines # (17 - 1) * 290 = 4640 + assert S(1000, 2500, 5640, 2500) in lines + assert len(lines) == 17 + 2 + 2 # risers, stringers, the two arrow strokes + assert all(p.role == "stair" for p in prims) + + +def test_the_walking_line_runs_up_the_middle_with_an_arrow_at_the_top(): + prims = stair_prims(stair(), scale=50) + assert path(prims) == [(1000.0, 2000.0), (5640.0, 2000.0)] + lines = segs(prims) + # arrow 3 mm x 50 = 150 long, its strokes 50 either side of the line + assert S(5490, 2050, 5640, 2000) in lines + assert S(5490, 1950, 5640, 2000) in lines + + +def test_the_up_label_sits_on_the_first_tread_at_the_plot_scale_height(): + (text,) = [p for p in stair_prims(stair(), scale=50) if isinstance(p, Text)] + assert text.text == vocab("en")["up"] + assert text.height == 125.0 # 2.5 mm x 50 + # (0.15 * 290, -0.25 * 1000 - 0.5 * 125) from the start + assert (round(text.at[0], 9), round(text.at[1], 9)) == (1043.5, 1687.5) + (tr,) = [p for p in stair_prims(stair(), lang="tr") if isinstance(p, Text)] + assert tr.text == vocab("tr")["up"] + + +def test_direction_turns_the_whole_stair_about_its_start(): + prims = stair_prims(stair(direction_deg=90.0)) + lines = segs(prims) + risers = [S(500, 2000 + 290 * k, 1500, 2000 + 290 * k) for k in range(17)] + assert set(risers) <= set(lines) + assert path(prims) == [(1000.0, 2000.0), (1000.0, 6640.0)] + (text,) = [p for p in prims if isinstance(p, Text)] + assert text.rotation == 90.0 + + +# -- L and U ------------------------------------------------------------------------ + + +def test_an_l_stair_turns_left_on_a_square_landing(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="l", risers=16, going=280.0)) + lines = segs(prims) + # 8 + 8 risers; the landing starts at 7 * 280 = 1960, the top riser at 500 + 7 * 280 + first = [S(280 * k, -500, 280 * k, 500) for k in range(8)] + second = [S(1960, 500 + 280 * k, 2960, 500 + 280 * k) for k in range(8)] + assert set(first + second) <= set(lines) + assert { + S(0, -500, 2960, -500), + S(2960, -500, 2960, 2460), + S(0, 500, 1960, 500), + S(1960, 500, 1960, 2460), + } <= set(lines) + assert len(lines) == 16 + 4 + 2 + assert path(prims) == [(0.0, 0.0), (2460.0, 0.0), (2460.0, 2460.0)] + + +def test_an_l_stair_turning_right_is_the_mirror_image(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="l", turn="right", risers=16, going=280.0)) + lines = segs(prims) + assert S(1960, -500, 2960, -500) in lines + assert S(2960, 500, 2960, -2460) in lines + assert path(prims)[-1] == (2460.0, -2460.0) + + +def test_a_u_stair_returns_beside_the_first_flight(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="u", risers=18, going=270.0, width=1100.0)) + lines = segs(prims) + # 9 + 9 risers; landing at 8 * 270 = 2160; the second flight comes back to x = 0 + first = [S(270 * k, -550, 270 * k, 550) for k in range(9)] + second = [S(2160 - 270 * k, 550, 2160 - 270 * k, 1650) for k in range(9)] + assert set(first + second) <= set(lines) + assert { + S(0, -550, 3260, -550), + S(3260, -550, 3260, 1650), + S(3260, 1650, 0, 1650), + S(0, 550, 2160, 550), + } <= set(lines) + assert len(lines) == 18 + 4 + 2 + assert path(prims) == [(0.0, 0.0), (2710.0, 0.0), (2710.0, 1100.0), (0.0, 1100.0)] + + +# -- refusals --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kw", "match"), + [ + ({"risers": 1}, "at least 2"), + ({"kind": "l", "risers": 3}, "at least 4"), + ({"risers": True}, "whole number"), + ({"kind": "spiral"}, "kinds are straight, l, u"), + ({"turn": "up"}, "turns are left, right"), + ({"width": 0.0}, "width"), + ({"going": float("nan")}, "going"), + ({"direction_deg": "north"}, "direction_deg must be finite"), + ({"start": (0.0, float("inf"))}, r"start\[1\] must be finite"), + ], +) +def test_a_bad_stair_is_refused_before_any_geometry(kw, match): + with pytest.raises(ValueError, match=match): + stair_prims(stair(**kw)) + + +def test_an_unknown_language_is_refused(): + with pytest.raises(ValueError): + stair_prims(stair(), lang="de") From cd1ae3529c47ca37e558f194a41ef7177698f747 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:59:58 +0300 Subject: [PATCH 4/4] feat(arch): SECTION 25 opens - walls, openings, stairs and exterior chains as tools engineering/arch/draw.py is the only module of the track that touches a backend. It draws through draw_prims(role_layer=ARCH_ROLE_LAYER) and the generic contract members, so it runs on both engines as written, and it writes the plan onto the drawing: each element's ACADMCP_ARCH record on one of its own entities (a wall's first face line, a door's leaf, a window's frame, a stair's first riser) and, beside it under ACADMCP_ARCH_H, the handles that element owns. read_plan reads the records back and never modifies the drawing. arch_wall computes the whole network before and after the new walls and redraws only the drawn walls whose outline changes - erasing exactly the entities they own - and says which under redrawn. arch_opening hosts a door or a window in a drawn wall found by id and redraws that wall cut; its tag defaults to the next D1/W1 (K1/P1 in Turkish). A new wall that would leave a drawn opening uncuttable, and an opening that runs into a junction, are refused before anything is erased. arch_stair reports the Blondel value; it does not enforce it. exterior_chains gives each side up to three rows, nearest first: every jamb in that facade, both faces of every wall meeting it, and the overall - from the wall engine's own faces. A row that would only restate a farther one is not emitted (ISO 129-1: each measurement once). The 10 mm / 8 mm row spacing is a declared drafting default times the plot scale, not a standard value. The arch tag is ranked after mech so the four tools file under a new architecture group; alias records carry WALLADD/MLINE, DOORADD/WINDOWADD and STAIRADD with English and Turkish shop words. Inventory regenerated; README snapshot 233 tools (228 advertised) in 26 groups; registry snapshot architecture: 4. No new ActiveX member is called. Measured on AutoCAD 2026 in a scratch document through ComBackend: shell + teed partition, D1 + W1, a straight 17-riser stair and the bottom chains gave the same 20 wall LINEs (identical endpoints), 9 poche HATCHes, 1+1 door, 4 window lines, 11 dimensions and a read_plan round trip as the headless engine. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- discovery/aliases.py | 74 ++++++ docs/tool-inventory.json | 61 ++++- engineering/arch/__init__.py | 12 + engineering/arch/dimension.py | 208 +++++++++++++++++ engineering/arch/draw.py | 416 ++++++++++++++++++++++++++++++++++ server.py | 291 ++++++++++++++++++++++++ tests/test_arch_dimension.py | 107 +++++++++ tests/test_arch_draw.py | 207 +++++++++++++++++ tests/test_arch_tools.py | 124 ++++++++++ tests/test_tool_packs.py | 2 +- tests/test_tool_registry.py | 11 +- 12 files changed, 1511 insertions(+), 8 deletions(-) create mode 100644 engineering/arch/dimension.py create mode 100644 engineering/arch/draw.py create mode 100644 tests/test_arch_dimension.py create mode 100644 tests/test_arch_draw.py create mode 100644 tests/test_arch_tools.py diff --git a/README.md b/README.md index 6320f44..2020760 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests. -> 229 is the **registered** count; a default install advertises 224 over `tools/list`, +> **v1.5 release snapshot:** 233 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 233 is the **registered** count; a default install advertises 228 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -120,7 +120,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -229 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +233 tools in 26 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index 5099993..e07703f 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -2230,6 +2230,80 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "punta deligi", ), ), + # ── Architecture (SECTION 25, track F group W) ────────────────────────── + "arch_wall": ToolAliases( + acad=("WALLADD", "MLINE"), + synonyms=( + "draw a wall", + "wall with a thickness", + "exterior wall", + "partition wall", + "floor plan walls", + "clean wall corners", + "t junction of walls", + "wall poche", + "duvar", + "duvar çiz", + "duvar ciz", + "bölme duvar", + "bolme duvar", + "kat planı", + "kat plani", + ), + ), + "arch_opening": ToolAliases( + acad=("DOORADD", "WINDOWADD"), + synonyms=( + "add a door", + "door in a wall", + "door swing", + "add a window", + "window in a wall", + "cut an opening in the wall", + "kapı", + "kapi", + "kapı ekle", + "pencere", + "pencere ekle", + "doğrama", + "dograma", + ), + ), + "arch_stair": ToolAliases( + acad=("STAIRADD",), + synonyms=( + "draw a stair", + "staircase", + "flight of stairs", + "l-shaped stair", + "u-shaped stair", + "walking line", + "blondel rule", + "risers and treads", + "merdiven", + "merdiven çiz", + "merdiven ciz", + "basamak", + "rıht", + "riht", + ), + ), + "arch_dimension_chains": ToolAliases( + acad=(), + synonyms=( + "dimension the facade", + "exterior dimensions", + "dimension chains", + "overall building dimensions", + "opening dimensions on the plan", + "ölçülendir", + "olculendir plan", + "dış ölçü", + "dis olcu", + "aks ölçüsü", + "aks olcusu", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 50d7a81..47a999b 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,13 +3,14 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 229, + "tools": 233, "resources": 8, "prompts": 5, - "groups": 25 + "groups": 26 }, "groups": { "analysis": 12, + "architecture": 4, "batch": 3, "blocks": 9, "corner_ops": 4, @@ -180,6 +181,62 @@ "readOnlyHint": true } }, + { + "name": "arch_dimension_chains", + "group": "architecture", + "cost": "mutate", + "summary": "Dimension the plan's facades: openings, walls and overall chains outside each side.", + "tags": [ + "arch", + "dimension" + ], + "annotations": { + "title": "Architecture: Dimension Chains", + "destructiveHint": false + } + }, + { + "name": "arch_opening", + "group": "architecture", + "cost": "mutate", + "summary": "Host a door or a window in a drawn wall by distance along it; the wall is cut.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Door / Window", + "destructiveHint": false + } + }, + { + "name": "arch_stair", + "group": "architecture", + "cost": "mutate", + "summary": "Draw a straight, L or U stair in plan and report its Blondel step rule.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Stair", + "destructiveHint": false + } + }, + { + "name": "arch_wall", + "group": "architecture", + "cost": "mutate", + "summary": "Add walls to the plan: clean L/T/X junctions, cut openings, material poché.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Wall", + "destructiveHint": false + } + }, { "name": "balloon_add", "group": "sheet", diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 2c2bc00..54238a8 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -6,6 +6,13 @@ and alphabetical - keep every side's names. from __future__ import annotations +from .dimension import ( + FIRST_OFFSET_PAPER_MM, + ROWS, + SIDES, + STEP_PAPER_MM, + exterior_chains, +) from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch @@ -58,12 +65,16 @@ __all__ = [ "ARCH_ROLE_LAYER", "ARROW_PAPER_MM", "BLONDEL_RANGE", + "FIRST_OFFSET_PAPER_MM", "KINDS", "LANGS", "MIN_JOIN_ANGLE_DEG", "Opening", + "ROWS", "Room", + "SIDES", "STAIR_KINDS", + "STEP_PAPER_MM", "Stair", "TEXT_PAPER_MM", "TURNS", @@ -75,6 +86,7 @@ __all__ = [ "blondel", "decode", "encode", + "exterior_chains", "face_offsets", "fmt_area_m2", "fmt_number", diff --git a/engineering/arch/dimension.py b/engineering/arch/dimension.py new file mode 100644 index 0000000..fbd8b05 --- /dev/null +++ b/engineering/arch/dimension.py @@ -0,0 +1,208 @@ +"""Exterior dimension chains: openings, walls, overall - three rows per side. + +An architectural plan carries its dimensions outside the building, in rows +parallel to each facade, nearest first: + +1. **openings** - the facade's extremes and both jambs of every opening in the + exterior wall on that side; +2. **walls** - the extremes and both faces of every wall that meets that + exterior wall, so the chain reads wall thickness, clear room width, wall + thickness, ...; +3. **overall** - the building's extreme faces. + +The "exterior wall on a side" is every axis segment parallel to that side +whose outer face no other parallel segment overlapping it lies beyond. Points +come from the wall engine's own faces, so a chain can never disagree with the +outline it measures. + +ISO 129-1: each measurement appears once. A row whose points are exactly those +of a farther row (a facade with no opening, a side no wall meets) is not +emitted; the rows that are keep their places, so the overall row is always at +the same distance. + +Offsets are paper millimetres times the plot-scale denominator: the first row +10 mm from the building and 8 mm between rows. Those two numbers are this +repository's drafting defaults, not standard values; pass ``first_offset`` / +``step`` in drawing units to override them. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +from engineering.arch.model import Wall, segment_of +from engineering.arch.walls import face_offsets, wall_geometry, wall_segments +from engineering.mech.primitives import DimIntent + +SIDES: tuple[str, ...] = ("bottom", "top", "left", "right") +ROWS: tuple[str, ...] = ("openings", "walls", "overall") +FIRST_OFFSET_PAPER_MM = 10.0 +STEP_PAPER_MM = 8.0 +_TOL = 1.0 + + +def _positive(value, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where}: expected a number, got {value!r}") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{where}: must be a finite number above zero, got {value!r}") + return number + + +def _sides(sides) -> tuple[str, ...]: + if isinstance(sides, str) or not isinstance(sides, (list, tuple)) or not sides: + raise ValueError(f"sides: name at least one of {', '.join(SIDES)}, got {sides!r}") + chosen: list[str] = [] + for i, side in enumerate(sides): + if side not in SIDES: + raise ValueError(f"sides[{i}]: {side!r}; sides are {', '.join(SIDES)}") + if side in chosen: + raise ValueError(f"sides[{i}]: {side!r} is named twice") + chosen.append(side) + return tuple(chosen) + + +def _unit(a, b) -> tuple[float, float]: + length = math.dist(a, b) + return ((b[0] - a[0]) / length, (b[1] - a[1]) / length) + + +def _exterior(walls: Sequence[Wall], ai: int, sign: float) -> list[dict]: + """The segments parallel to a side whose outer face nothing parallel lies beyond.""" + ci = 1 - ai + parallel: list[dict] = [] + for wall in walls: + left, right = face_offsets(wall) + for index, (a, b) in enumerate(wall_segments(wall)): + u = _unit(a, b) + if abs(u[ci]) > 1e-9: + continue + n = (-u[1], u[0]) + faces = (a[ci] + n[ci] * left, a[ci] + n[ci] * right) + parallel.append( + { + "wall": wall, + "index": index, + "a": a, + "b": b, + "span": (min(a[ai], b[ai]), max(a[ai], b[ai])), + "outer": min(faces) if sign < 0 else max(faces), + } + ) + exterior = [] + for item in parallel: + lo, hi = item["span"] + covered = any( + other is not item + and min(hi, other["span"][1]) - max(lo, other["span"][0]) > _TOL + and ( + other["outer"] < item["outer"] - _TOL + if sign < 0 + else other["outer"] > item["outer"] + _TOL + ) + for other in parallel + ) + if not covered: + exterior.append(item) + return exterior + + +def _inside(point, item: dict) -> bool: + a, b = item["a"], item["b"] + left, right = face_offsets(item["wall"]) + u = _unit(a, b) + s = (point[0] - a[0]) * u[0] + (point[1] - a[1]) * u[1] + d = (point[0] - a[0]) * -u[1] + (point[1] - a[1]) * u[0] + return -_TOL <= s <= math.dist(a, b) + _TOL and right - _TOL <= d <= left + _TOL + + +def _points(values: set[float]) -> list[float]: + out: list[float] = [] + for value in sorted(values): + if not out or value - out[-1] > 1e-6: + out.append(value) + return out + + +def exterior_chains( + walls, + openings=(), + *, + sides=("bottom", "left"), + first_offset=None, + step=None, + scale=50, +) -> tuple[DimIntent, ...]: + """Linear ``DimIntent`` rows outside each named side, nearest first. + + ``feature`` names the row as ``":"`` (``"bottom:openings"``); + ``text_at`` is the point the dimension line passes through. + """ + walls = list(walls) + openings = list(openings) + chosen = _sides(sides) + factor = _positive(scale, "scale") + first = ( + FIRST_OFFSET_PAPER_MM * factor + if first_offset is None + else _positive(first_offset, "first_offset") + ) + gap = STEP_PAPER_MM * factor if step is None else _positive(step, "step") + geometry = wall_geometry(walls, openings, poche=False) + if not geometry.faces: + raise ValueError("walls: there is no wall to dimension") + xs = [p[0] for line in geometry.faces for p in (line.p1, line.p2)] + ys = [p[1] for line in geometry.faces for p in (line.p1, line.p2)] + box = {"bottom": min(ys), "top": max(ys), "left": min(xs), "right": max(xs)} + skipped = {entry["element"] for entry in geometry.omitted} + by_id = {wall.id: wall for wall in walls} + + intents: list[DimIntent] = [] + for side in chosen: + ai = 0 if side in ("bottom", "top") else 1 + sign = -1.0 if side in ("bottom", "left") else 1.0 + edge = box[side] + lo, hi = (box["left"], box["right"]) if ai == 0 else (box["bottom"], box["top"]) + exterior = _exterior(walls, ai, sign) + hosts = {(item["wall"].id, item["index"]) for item in exterior} + + jambs = {lo, hi} + for opening in openings: + if opening.id in skipped: + continue + wall = by_id[opening.wall] + index, along = segment_of(wall, float(opening.offset)) + if (wall.id, index) not in hosts: + continue + a, b = wall_segments(wall)[index] + u = _unit(a, b) + jambs.add(a[ai] + u[ai] * along) + jambs.add(a[ai] + u[ai] * (along + float(opening.width))) + + faces = {lo, hi} + for wall in walls: + left, right = face_offsets(wall) + for a, b in wall_segments(wall): + u = _unit(a, b) + if abs(u[1 - ai]) <= 1e-9: + continue + n = (-u[1], u[0]) + for end in (a, b): + if any(_inside(end, item) for item in exterior): + faces.add(end[ai] + n[ai] * left) + faces.add(end[ai] + n[ai] * right) + + rows = [_points(jambs), _points(faces), _points({lo, hi})] + for r, points in enumerate(rows): + if any(points == later for later in rows[r + 1 :]): + continue + offset = edge + sign * (first + r * gap) + for p, q in zip(points, points[1:], strict=False): + if ai == 0: + p1, p2, at = (p, edge), (q, edge), ((p + q) / 2.0, offset) + else: + p1, p2, at = (edge, p), (edge, q), (offset, (p + q) / 2.0) + intents.append(DimIntent("linear", p1, p2, at, feature=f"{side}:{ROWS[r]}")) + return tuple(intents) diff --git a/engineering/arch/draw.py b/engineering/arch/draw.py new file mode 100644 index 0000000..cbf273a --- /dev/null +++ b/engineering/arch/draw.py @@ -0,0 +1,416 @@ +"""The async layer of the architectural track - the only module here that touches a backend. + +Everything else under `engineering/arch/` is pure; this module turns its +primitives into entities through the generic contract members (so it runs on +both engines as written) and writes the plan onto the drawing: + +* each wall's ``ACADMCP_ARCH`` record on its first face line, each opening's + on its door leaf or its window frame, each stair's on its first riser line; +* next to each record, under ``ACADMCP_ARCH_H``, the handles of every entity + that element owns - faces, jambs, poche, the symbol - so a later call can + take exactly those entities back out and redraw them. + +`read_plan` reads the records back. `add_walls` and `add_opening` build on +it: they compute the whole network before and after the change, redraw only +the walls whose outline the change touches, and say which. + +Every refusal fires before the first entity is created or erased. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import replace + +from engineering.arch.dimension import exterior_chains +from engineering.arch.lang import tag_prefix +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.arch.model import ( + APP_ID, + Opening, + Room, + Stair, + Wall, + decode, + from_payload, + opening_from_dict, + stair_from_dict, + to_payload, + to_values, + wall_from_dict, +) +from engineering.arch.openings import opening_prims +from engineering.arch.stairs import blondel, stair_prims +from engineering.arch.walls import WallGeometry, wall_geometry_by_wall +from engineering.mech.draw import draw_prims + +log = logging.getLogger(__name__) + +#: The application that carries an element's owned handles beside its record. +HANDLES_APP = "ACADMCP_ARCH_H" + +#: The layers `read_plan` scans for records - never the poche or the dimensions. +RECORD_ROLES: tuple[str, ...] = ("wall", "door", "window", "stair", "room") + +#: Model class -> the record kind, read off the object rather than the payload, +#: so the reader does not depend on how a payload spells its own kind. +_KIND = {Wall: "wall", Opening: "opening", Stair: "stair", Room: "room"} + + +# -- layers ----------------------------------------------------------------------- + + +async def ensure_arch_layers(backend, names) -> tuple[str, ...]: + """Create the named layers the drawing lacks, with their ``ARCH_LAYERS`` row. + + A live seat refuses ``entity.Layer = name`` on a missing layer (measured + by the mechanical track), so every draw path ensures its layers first. + """ + rows = {row[0]: row for row in ARCH_LAYERS} + existing = {layer.name.lower() for layer in await backend.layer_list()} + created: list[str] = [] + for name in dict.fromkeys(names): + if name.lower() in existing: + continue + _name, color, linetype, lineweight, _description = rows.get( + name, (name, 7, "Continuous", 0.25, "") + ) + await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight) + created.append(name) + return tuple(created) + + +# -- records -------------------------------------------------------------------- + + +async def _write_record(backend, handle: str, element, owned: list[str]) -> None: + await backend.entity_set_xdata(handle, APP_ID, to_values(to_payload(element))) + await backend.entity_set_xdata(handle, HANDLES_APP, list(owned)) + + +async def read_plan(backend) -> dict: + """Every ``ACADMCP_ARCH`` record on the drawing, rebuilt into model objects. + + ``carriers`` maps ``":"`` to the entity that carries the record. + Reads only; the drawing is never modified. + """ + plan: dict = {"walls": [], "openings": [], "stairs": [], "rooms": [], "carriers": {}} + for role in RECORD_ROLES: + for info in await backend.entity_list(layer_filter=ARCH_ROLE_LAYER[role], limit=100000): + raw = await backend.entity_get_xdata(info.handle, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + continue + element = from_payload(decode(values)) + kind = _KIND[type(element)] + plan[f"{kind}s"].append(element) + plan["carriers"][f"{kind}:{element.id}"] = info.handle + return plan + + +async def _owned(backend, carrier: str) -> list[str]: + raw = await backend.entity_get_xdata(carrier, HANDLES_APP) + return [str(h) for h in ((raw.get("xdata") or {}).get(HANDLES_APP) or [])] + + +async def _erase(backend, plan: dict, wall_id: str) -> list[str]: + """Take a wall, its openings and their poche back out. Returns handles already gone.""" + carriers = [plan["carriers"][f"wall:{wall_id}"]] + carriers += [ + plan["carriers"][f"opening:{op.id}"] for op in plan["openings"] if op.wall == wall_id + ] + missing: list[str] = [] + for carrier in carriers: + for handle in await _owned(backend, carrier) or [carrier]: + try: + await backend.entity_delete(handle) + except Exception as exc: # erased by hand since it was drawn: nothing to take back + log.info("arch: %s was already gone (%s)", handle, exc) + missing.append(handle) + return missing + + +# -- walls ------------------------------------------------------------------------ + + +def _signature(geometry: WallGeometry) -> tuple: + """What a wall looks like, rounded - equal signatures mean nothing to redraw.""" + + def seg(line): + a = (round(line.p1[0], 6), round(line.p1[1], 6)) + b = (round(line.p2[0], 6), round(line.p2[1], 6)) + return tuple(sorted((a, b))) + + def ring(points): + pts = [(round(x, 6), round(y, 6)) for x, y in points] + k = pts.index(min(pts)) + return tuple(pts[k:] + pts[:k]) + + return ( + frozenset(seg(line) for line in geometry.faces), + frozenset(seg(line) for line in geometry.jambs), + frozenset(ring(region) for region in geometry.regions), + frozenset(entry["element"] for entry in geometry.omitted), + ) + + +async def _draw_wall(backend, wall, geometry: WallGeometry, openings) -> dict: + """One wall's faces, jambs, poche and hosted symbols, with their records.""" + skipped = {entry["element"] for entry in geometry.omitted} + symbols = [(op, opening_prims(wall, op)) for op in openings if op.id not in skipped] + # Faces first, so owned[0] - the record carrier - is the wall's first face line; + # the poche follows on the wall-hatch layer, in each material's pattern. + drawn = await draw_prims( + backend, + [*geometry.faces, *geometry.jambs, *geometry.hatches], + role_layer=ARCH_ROLE_LAYER, + ) + owned = list(drawn["handles"]) + done: list[dict] = [] + for op, prims in symbols: + placed = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + await _write_record(backend, placed["handles"][0], op, placed["handles"]) + done.append( + { + "id": op.id, + "tag": op.tag, + "record": placed["handles"][0], + "handles": placed["handles"], + } + ) + await _write_record(backend, owned[0], wall, owned) + return { + "wall": {"id": wall.id, "record": owned[0], "handles": owned}, + "openings": done, + "omitted": list(geometry.omitted), + } + + +def _collect(results: list[dict]) -> dict: + return { + "walls": [r["wall"] for r in results], + "openings": [op for r in results for op in r["openings"]], + "handles": [ + h + for r in results + for h in (*r["wall"]["handles"], *(x for op in r["openings"] for x in op["handles"])) + ], + "omitted": [entry for r in results for entry in r["omitted"]], + } + + +async def draw_walls(backend, walls, openings=(), *, poche=True) -> dict: + """Draw a wall network and its openings, writing every record. Refusals first.""" + walls = list(walls) + openings = list(openings) + geometry = wall_geometry_by_wall(walls, openings, poche=poche) + for wall in walls: # every symbol is validated before the first entity + for op in openings: + if op.wall == wall.id: + opening_prims(wall, op) + results = [ + await _draw_wall( + backend, wall, geometry[wall.id], [op for op in openings if op.wall == wall.id] + ) + for wall in walls + ] + return {**_collect(results), "backend": backend.name} + + +async def add_walls(backend, walls, *, poche=True) -> dict: + """Add walls to the plan on the drawing, redrawing the drawn walls they join.""" + if isinstance(walls, dict) or not isinstance(walls, (list, tuple)) or not walls: + raise ValueError("walls: give a list of at least one wall {id, axis, thickness, ...}") + new = [wall_from_dict(item, f"walls[{i}]") for i, item in enumerate(walls)] + plan = await read_plan(backend) + drawn = {wall.id for wall in plan["walls"]} + for i, wall in enumerate(new): + if wall.id in drawn: + raise ValueError( + f"walls[{i}].id: {wall.id!r} is already drawn; choose a new id " + f"(drawn: {', '.join(sorted(drawn))})" + ) + before = ( + wall_geometry_by_wall(plan["walls"], plan["openings"], poche=poche) if plan["walls"] else {} + ) + network = [*plan["walls"], *new] + after = wall_geometry_by_wall(network, plan["openings"], poche=poche) + changed = [ + wall.id + for wall in plan["walls"] + if _signature(before[wall.id]) != _signature(after[wall.id]) + ] + for wall_id in changed: + was = {entry["element"] for entry in before[wall_id].omitted} + for entry in after[wall_id].omitted: + if entry["element"] not in was: + raise ValueError( + f"the new walls would leave opening {entry['element']!r} of wall " + f"{wall_id!r} uncuttable ({entry['reason']}); move the opening or the wall" + ) + by_id = {wall.id: wall for wall in network} + targets = [*changed, *(wall.id for wall in new)] + for wall_id in targets: + for op in plan["openings"]: + if op.wall == wall_id: + opening_prims(by_id[wall_id], op) + + missing: list[str] = [] + for wall_id in changed: + missing += await _erase(backend, plan, wall_id) + results = [ + await _draw_wall( + backend, + by_id[wall_id], + after[wall_id], + [op for op in plan["openings"] if op.wall == wall_id], + ) + for wall_id in targets + ] + return { + **_collect(results), + "added": [wall.id for wall in new], + "redrawn": changed, + "already_erased": missing, + "backend": backend.name, + } + + +# -- openings --------------------------------------------------------------------- + + +def assign_tags(openings, existing=(), *, lang: str = "en") -> tuple[Opening, ...]: + """Give every untagged opening the next free tag of its kind: D1, W2 (EN) / K1, P2 (TR).""" + used = {op.tag for op in (*existing, *openings) if op.tag} + out: list[Opening] = [] + for op in openings: + if op.tag: + out.append(op) + continue + prefix = tag_prefix(op.kind, lang) + number = 1 + while f"{prefix}{number}" in used: + number += 1 + used.add(f"{prefix}{number}") + out.append(replace(op, tag=f"{prefix}{number}")) + return tuple(out) + + +async def add_opening(backend, opening: dict, *, lang: str = "en", poche: bool = True) -> dict: + """Host a door or a window in a drawn wall, found by id, and redraw that wall cut.""" + if not isinstance(opening, dict): + raise ValueError(f"opening: expected a dict, got {type(opening).__name__}") + raw = dict(opening) + auto_id = not raw.get("id") + if auto_id: + raw["id"] = "__new__" + op = opening_from_dict(raw, "opening") + plan = await read_plan(backend) + walls = {wall.id: wall for wall in plan["walls"]} + if op.wall not in walls: + names = ", ".join(sorted(walls)) or "none - draw one with arch_wall" + raise ValueError(f"opening.wall: {op.wall!r} is not a wall on this drawing; walls: {names}") + (op,) = assign_tags([op], plan["openings"], lang=lang) + if auto_id: + op = replace(op, id=op.tag) + if op.id in {other.id for other in plan["openings"]}: + raise ValueError(f"opening.id: {op.id!r} is already drawn; choose a new id") + hosted = [other for other in plan["openings"] if other.wall == op.wall] + [op] + geometry = wall_geometry_by_wall(plan["walls"], [*plan["openings"], op], poche=poche) + for entry in geometry[op.wall].omitted: + if entry["element"] == op.id: + raise ValueError(f"opening {op.id!r} cannot be cut: {entry['reason']}") + wall = walls[op.wall] + for other in hosted: + opening_prims(wall, other) + missing = await _erase(backend, plan, op.wall) + result = await _draw_wall(backend, wall, geometry[op.wall], hosted) + return { + **_collect([result]), + "opening": op.id, + "tag": op.tag, + "redrawn": [op.wall], + "already_erased": missing, + "backend": backend.name, + } + + +# -- stairs ----------------------------------------------------------------------- + + +async def draw_stair(backend, stair: Stair, *, lang: str = "en", scale=50) -> dict: + """A stair in plan with its record; the Blondel value is reported, never enforced.""" + prims = stair_prims(stair, lang=lang, scale=scale) + check = blondel(stair.riser_height, stair.going) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + await _write_record(backend, drawn["handles"][0], stair, drawn["handles"]) + flights = 1 if stair.kind == "straight" else 2 + return { + "stair": stair.id, + "record": drawn["handles"][0], + "handles": drawn["handles"], + "risers": stair.risers, + "treads": stair.risers - flights, + "blondel": check, + "backend": backend.name, + } + + +async def add_stair(backend, stair: dict, *, lang: str = "en", scale=50) -> dict: + """`draw_stair` for a stair given as a dict, refusing an id already on the drawing.""" + if not isinstance(stair, dict): + raise ValueError(f"stair: expected a dict, got {type(stair).__name__}") + model = stair_from_dict(stair, "stair") + stair_prims(model, lang=lang, scale=scale) + plan = await read_plan(backend) + if model.id in {other.id for other in plan["stairs"]}: + raise ValueError(f"stair.id: {model.id!r} is already drawn; choose a new id") + return await draw_stair(backend, model, lang=lang, scale=scale) + + +# -- dimensions --------------------------------------------------------------------- + + +async def draw_chains( + backend, walls, openings, *, sides=("bottom", "left"), scale=50, first_offset=None, step=None +) -> dict: + """The exterior chains as real linear DIMENSIONs on the dimension layer.""" + intents = exterior_chains( + walls, openings, sides=sides, first_offset=first_offset, step=step, scale=scale + ) + layer = ARCH_ROLE_LAYER["dim"] + await ensure_arch_layers(backend, [layer]) + handles: list[str] = [] + rows: list[dict] = [] + for intent in intents: + horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0]) + info = await backend.dimension_linear( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + intent.text_at[0], + intent.text_at[1], + 0.0 if horizontal else 90.0, + layer, + ) + handles.append(info.handle) + rows.append( + { + "row": intent.feature, + "from": list(intent.p1), + "to": list(intent.p2), + "value": math.dist(intent.p1, intent.p2), + } + ) + return { + "count": len(handles), + "handles": handles, + "dimensions": rows, + "text_height": ( + "set by the current dimension style; at 1:{0:g} set DIMSCALE={0:g} with " + "dimstyle_modify so the text plots at its paper height".format(float(scale)) + ), + "backend": backend.name, + } diff --git a/server.py b/server.py index d6f0808..a1ac54e 100644 --- a/server.py +++ b/server.py @@ -691,6 +691,7 @@ async def _registered_tool_count() -> int | None: _GROUP_TAG_PRIORITY = ( "pid", "mech", + "arch", "sheet", "style", "pagesetup", @@ -721,6 +722,7 @@ _GROUP_TAG_PRIORITY = ( _GROUP_TAG_LABELS = { "pid": "pid", "mech": "mechanical", + "arch": "architecture", "sheet": "sheet", "style": "styles", "pagesetup": "page_setup", @@ -9780,6 +9782,295 @@ async def drawing_export_dwg( return await _backend(ctx).drawing_export_dwg(str(validated), version) +# --------------------------------------------------------------------------- +# ── SECTION 25: Architecture (4 tools) ────────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Add walls to the plan: clean L/T/X junctions, cut openings, material poché.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Wall", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_wall( + walls: Annotated[ + list[dict], + Field( + description=( + "Walls to add: [{id, axis:[[x,y],...] (2+ points, straight segments, WCS mm), " + "thickness, justification:'center'|'left'|'right' (side of the axis looking " + "along it), material:'brick'|'aac'|'concrete'|'reinforced_concrete'|" + "'gypsum_board'|'stone'|'timber'|'generic', closed:bool}]" + ) + ), + ], + poche: Annotated[ + bool, + Field(default=True, description="Hatch the cut wall faces in their material (ISO 128-50)"), + ] = True, + ctx: Context = None, +) -> dict: + """Add walls to the plan network already on the drawing and resolve every junction. + + Two ends at one point mitre into a clean L; an end inside another wall is a + T (the stem stops on the crossing wall's near face, which is cut there); two + axes crossing are an X (all four faces cut). A drawn wall whose outline the + new walls change is taken back out and redrawn with its openings - the + result lists it under `redrawn` - and a wall they do not touch is left alone. + Every wall carries an `ACADMCP_ARCH` record, so later calls find it by id. + + Refused before anything is drawn or erased, by path: a wall id already on + the drawing or used twice, an axis of fewer than two points or with a + zero-length segment, a non-positive thickness, an unknown justification or + material (the list is named), two walls meeting at less than 5° (the mitre + would run away), a wall corner lying inside another wall off its axis, an + end inside a wall whose axis only meets it past that wall's end, and a new + wall that would leave a drawn opening uncuttable. A curved wall is not + supported: an axis is straight segments only. + """ + from engineering.arch.draw import add_walls + + await ctx.info(f"arch_wall: {len(walls)} wall(s)") + try: + return await add_walls(_backend(ctx), walls, poche=poche) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Host a door or a window in a drawn wall by distance along it; the wall is cut.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Door / Window", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_opening( + wall: Annotated[str, Field(description="Id of the drawn wall that hosts the opening")], + offset: Annotated[ + float, + Field( + description="Distance along the wall's axis from its first point to the near jamb (mm)" + ), + ], + width: Annotated[float, Field(description="Clear width of the opening (mm)")], + kind: Annotated[str, Field(default="door", description="door | window")] = "door", + swing: Annotated[ + str, + Field( + default="in", + description="Doors: in = to the left of the axis (inside a CCW perimeter) | out", + ), + ] = "in", + hand: Annotated[ + str, + Field( + default="left", + description="Doors: the hinge jamb, seen from the swing side facing the wall: left | right", + ), + ] = "left", + sill: Annotated[ + float | None, Field(default=None, description="Windows: sill height (schedule only, mm)") + ] = None, + height: Annotated[ + float | None, Field(default=None, description="Opening height (schedule only, mm)") + ] = None, + tag: Annotated[ + str | None, + Field(default=None, description="Schedule tag; default the next D1/W1 (en) or K1/P1 (tr)"), + ] = None, + opening_id: Annotated[ + str | None, Field(default=None, description="Opening id; default its tag") + ] = None, + lang: Annotated[str, Field(default="en", description="Tag language: en | tr")] = "en", + poche: Annotated[ + bool, Field(default=True, description="Hatch the redrawn wall's cut faces") + ] = True, + ctx: Context = None, +) -> dict: + """A door or a window in a wall already drawn, found by its id; the wall is redrawn cut. + + The opening removes its width from both wall faces and each edge is closed + by a jamb. A door is drawn open at 90° - its leaf as long as the opening is + wide, hinged on `hand`, with a quarter-arc swing on the `swing` side; a + window is a frame line on each face and two glazing lines between them. The + opening's `ACADMCP_ARCH` record sits on the leaf or the frame, so a schedule + reads it back. + + Refused before anything is drawn or erased: a wall id not on the drawing + (the drawn ids are named), an opening id already drawn, an opening that + runs past its wall segment or straddles an axis vertex, one overlapping + another opening on the same wall (both ids named), one that runs into a + junction (the solid stretch it must fit in is named), an unknown kind, + swing, hand or language. + """ + from engineering.arch.draw import add_opening + + spec = { + "id": opening_id, + "wall": wall, + "kind": kind, + "offset": offset, + "width": width, + "swing": swing, + "hand": hand, + "sill": sill, + "height": height, + "tag": tag, + } + await ctx.info(f"arch_opening: {kind} in wall {wall}") + try: + return await add_opening( + _backend(ctx), {k: v for k, v in spec.items() if v is not None}, lang=lang, poche=poche + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a straight, L or U stair in plan and report its Blondel step rule.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Stair", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_stair( + stair_id: Annotated[str, Field(description="Stair id, unique on the drawing")], + start_x: Annotated[float, Field(description="WCS X of the midpoint of the bottom riser")], + start_y: Annotated[float, Field(description="WCS Y of the midpoint of the bottom riser")], + width: Annotated[float, Field(description="Flight width (mm)")], + risers: Annotated[int, Field(description="Number of risers from floor to floor")], + riser_height: Annotated[float, Field(description="Riser height R (mm)")], + going: Annotated[float, Field(description="Tread going G (mm)")], + direction_deg: Annotated[ + float, Field(default=0.0, description="Direction up the first flight, degrees CCW from +X") + ] = 0.0, + kind: Annotated[str, Field(default="straight", description="straight | l | u")] = "straight", + turn: Annotated[ + str, Field(default="left", description="L / U stairs: which way the second flight turns") + ] = "left", + lang: Annotated[str, Field(default="en", description="Label language: en | tr")] = "en", + scale: Annotated[ + float, + Field( + default=50.0, description="Plot-scale denominator for the label and arrow (50 = 1:50)" + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """A stair in plan: every riser as a line, the outline, the walking line with its arrow. + + The label ("UP" / "ÇIKIŞ") sits on the first tread at 2.5 mm × `scale`. The + result reports `blondel` - 2R + G against 600-650 mm, the step-length rule + attributed to François Blondel (1675). It is a design rule reported for the + drafter, not a building code: no national stair regulation is checked and + a value outside the band is reported (`ok: false`), not refused. + + Refused before anything is drawn: a stair id already on the drawing, a + non-positive width, riser height or going, fewer than 2 risers (4 for an L + or a U - two per flight), a non-integer riser count, an unknown kind, turn + or language. + """ + from engineering.arch.draw import add_stair + + spec = { + "id": stair_id, + "start": [start_x, start_y], + "direction_deg": direction_deg, + "width": width, + "risers": risers, + "riser_height": riser_height, + "going": going, + "kind": kind, + "turn": turn, + } + await ctx.info(f"arch_stair: {kind} stair {stair_id}") + try: + return await add_stair(_backend(ctx), spec, lang=lang, scale=scale) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Dimension the plan's facades: openings, walls and overall chains outside each side.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Dimension Chains", "destructiveHint": False}, + tags={"arch", "dimension"}, +) +async def arch_dimension_chains( + sides: Annotated[ + list[str] | None, + Field( + default=None, + description="Sides to dimension: bottom | top | left | right (default bottom, left)", + ), + ] = None, + scale: Annotated[ + float, + Field( + default=50.0, + description="Plot-scale denominator: rows sit 10 mm and 8 mm apart on paper", + ), + ] = 50.0, + first_offset: Annotated[ + float | None, + Field( + default=None, description="Distance of the first row from the building (drawing units)" + ), + ] = None, + step: Annotated[ + float | None, Field(default=None, description="Distance between rows (drawing units)") + ] = None, + ctx: Context = None, +) -> dict: + """The three exterior chains an architectural plan carries, as real DIMENSIONs. + + Nearest first: the openings row (every jamb in that facade), the walls row + (both faces of every wall meeting it - wall thickness, clear width, wall + thickness, ...) and the overall row. The points come from the wall engine's + own faces, read back from the plan on the drawing. A row that would only + restate a farther one is not drawn (ISO 129-1: each measurement once). + The row spacing defaults (10 mm, then 8 mm on paper) are this server's + drafting choices, not standard values. Text height follows the current + dimension style: at 1:50 set DIMSCALE=50 with `dimstyle_modify`. + + Refused by name: a drawing with no walls, an unknown or repeated side, a + non-positive scale, offset or step. + """ + from engineering.arch.draw import draw_chains, read_plan + + backend = _backend(ctx) + try: + plan = await read_plan(backend) + if not plan["walls"]: + raise ValueError( + "this drawing carries no architectural walls; draw them with arch_wall" + ) + await ctx.info(f"arch_dimension_chains: {len(plan['walls'])} wall(s)") + return await draw_chains( + backend, + plan["walls"], + plan["openings"], + sides=tuple(sides or ("bottom", "left")), + scale=scale, + first_offset=first_offset, + step=step, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +# ── arch: rooms (group R) ── + +# ── arch: catalogue (group C) ── + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_arch_dimension.py b/tests/test_arch_dimension.py new file mode 100644 index 0000000..3075507 --- /dev/null +++ b/tests/test_arch_dimension.py @@ -0,0 +1,107 @@ +"""Exterior dimension chains: three rows per side, from the wall engine's own faces. + +The plan: a closed 8200 x 6200 axis rectangle of 200 mm walls (outer faces +-100..8300 x -100..6300), a 100 mm partition on x = 4100 teed into the bottom +and top walls (faces x = 4050 / 4150), a 1200 window at 1500 and a 1000 door +at 5500 in the bottom wall. Every point below is hand-computed from those +numbers; at 1:50 the rows sit 500, 900 and 1300 mm outside the building. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.dimension import ( + FIRST_OFFSET_PAPER_MM, + ROWS, + SIDES, + STEP_PAPER_MM, + exterior_chains, +) +from engineering.arch.model import Opening, Wall +from engineering.mech.primitives import DimIntent + +SHELL = Wall( + id="shell", + axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)), + thickness=200.0, + closed=True, +) +PARTITION = Wall(id="p", axis=((4100.0, 0.0), (4100.0, 6200.0)), thickness=100.0) +WINDOW = Opening(id="w1", wall="shell", kind="window", offset=1500.0, width=1200.0) +DOOR = Opening(id="d1", wall="shell", kind="door", offset=5500.0, width=1000.0) + + +def row(intents, feature) -> list[tuple]: + out = [] + for intent in intents: + if intent.feature == feature: + out.append( + ( + tuple(round(v, 9) + 0.0 for v in intent.p1), + tuple(round(v, 9) + 0.0 for v in intent.p2), + tuple(round(v, 9) + 0.0 for v in intent.text_at), + ) + ) + return out + + +def test_the_rows_and_the_offsets_are_declared(): + assert SIDES == ("bottom", "top", "left", "right") + assert ROWS == ("openings", "walls", "overall") + assert (FIRST_OFFSET_PAPER_MM, STEP_PAPER_MM) == (10.0, 8.0) + + +def test_the_bottom_side_carries_openings_walls_and_overall(): + intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("bottom",), scale=50) + assert all(isinstance(i, DimIntent) and i.kind == "linear" for i in intents) + assert row(intents, "bottom:openings") == [ + ((-100.0, -100.0), (1500.0, -100.0), (700.0, -600.0)), + ((1500.0, -100.0), (2700.0, -100.0), (2100.0, -600.0)), + ((2700.0, -100.0), (5500.0, -100.0), (4100.0, -600.0)), + ((5500.0, -100.0), (6500.0, -100.0), (6000.0, -600.0)), + ((6500.0, -100.0), (8300.0, -100.0), (7400.0, -600.0)), + ] + assert row(intents, "bottom:walls") == [ + ((-100.0, -100.0), (100.0, -100.0), (0.0, -1000.0)), + ((100.0, -100.0), (4050.0, -100.0), (2075.0, -1000.0)), + ((4050.0, -100.0), (4150.0, -100.0), (4100.0, -1000.0)), + ((4150.0, -100.0), (8100.0, -100.0), (6125.0, -1000.0)), + ((8100.0, -100.0), (8300.0, -100.0), (8200.0, -1000.0)), + ] + assert row(intents, "bottom:overall") == [ + ((-100.0, -100.0), (8300.0, -100.0), (4100.0, -1400.0)), + ] + assert len(intents) == 11 + + +def test_a_row_that_repeats_a_farther_one_is_not_emitted(): + intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("left",), scale=50) + # no opening in the left wall: that row would only restate the overall one + assert row(intents, "left:openings") == [] + assert row(intents, "left:walls") == [ + ((-100.0, -100.0), (-100.0, 100.0), (-1000.0, 0.0)), + ((-100.0, 100.0), (-100.0, 6100.0), (-1000.0, 3100.0)), + ((-100.0, 6100.0), (-100.0, 6300.0), (-1000.0, 6200.0)), + ] + assert row(intents, "left:overall") == [((-100.0, -100.0), (-100.0, 6300.0), (-1400.0, 3100.0))] + + +def test_explicit_offsets_override_the_plot_scale_defaults(): + intents = exterior_chains( + [SHELL], [], sides=("top",), first_offset=300.0, step=250.0, scale=100 + ) + # the top: the extremes only, so the walls row is the corner walls' faces + assert row(intents, "top:overall") == [((-100.0, 6300.0), (8300.0, 6300.0), (4100.0, 7100.0))] + assert {i.text_at[1] for i in intents if i.feature == "top:walls"} == {6850.0} + + +def test_bad_sides_and_scales_are_refused_by_name(): + with pytest.raises(ValueError, match=r"sides\[0\]: 'north'"): + exterior_chains([SHELL], [], sides=("north",)) + with pytest.raises(ValueError, match="named twice"): + exterior_chains([SHELL], [], sides=("left", "left")) + with pytest.raises(ValueError, match="scale"): + exterior_chains([SHELL], [], scale=0) + with pytest.raises(ValueError, match="sides"): + exterior_chains([SHELL], [], sides="bottom") diff --git a/tests/test_arch_draw.py b/tests/test_arch_draw.py new file mode 100644 index 0000000..c687135 --- /dev/null +++ b/tests/test_arch_draw.py @@ -0,0 +1,207 @@ +"""The async layer: walls, openings, stairs and chains drawn through the ezdxf backend. + +The same code runs on the live engine: `engineering/arch/draw.py` composes only +generic contract members (entity_create_*, dimension_linear, entity_set_xdata, +entity_list, entity_delete), like the mechanical track's draw layer. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.draw import ( + HANDLES_APP, + add_opening, + add_stair, + add_walls, + assign_tags, + draw_chains, + draw_stair, + draw_walls, + read_plan, +) +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.arch.model import APP_ID, Opening, Stair, Wall + +pytestmark = pytest.mark.asyncio + +WALL_A = {"id": "a", "axis": [[0.0, 0.0], [5000.0, 0.0]], "thickness": 200.0} +WALL_B = {"id": "b", "axis": [[2500.0, 0.0], [2500.0, 3000.0]], "thickness": 100.0} + + +async def on(backend, role, type_filter=None): + return await backend.entity_list( + type_filter=type_filter, layer_filter=ARCH_ROLE_LAYER[role], limit=10000 + ) + + +def ends(info) -> tuple: + a = tuple(round(v, 6) + 0.0 for v in info.properties["start"][:2]) + b = tuple(round(v, 6) + 0.0 for v in info.properties["end"][:2]) + return tuple(sorted((a, b))) + + +async def test_draw_walls_puts_faces_jambs_and_poche_on_their_layers(backend): + wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0) + door = Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0, tag="D1") + result = await draw_walls(backend, [wall], [door]) + lines = await on(backend, "wall", "LINE") + assert {ends(e) for e in lines} == { + ((0.0, 100.0), (1000.0, 100.0)), + ((1900.0, 100.0), (5000.0, 100.0)), + ((0.0, -100.0), (1000.0, -100.0)), + ((1900.0, -100.0), (5000.0, -100.0)), + ((0.0, -100.0), (0.0, 100.0)), + ((5000.0, -100.0), (5000.0, 100.0)), + ((1000.0, -100.0), (1000.0, 100.0)), + ((1900.0, -100.0), (1900.0, 100.0)), + } + assert len(await on(backend, "wall_hatch", "HATCH")) == 2 + assert len(await on(backend, "door", "LINE")) == 1 + assert len(await on(backend, "door", "ARC")) == 1 + assert [w["id"] for w in result["walls"]] == ["a"] + assert [o["id"] for o in result["openings"]] == ["d1"] + assert result["omitted"] == [] + + +async def test_the_records_round_trip_through_xdata(backend): + wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0, material="aac") + window = Opening(id="w1", wall="a", kind="window", offset=2000.0, width=1200.0, tag="W1") + drawn = await draw_walls(backend, [wall], [window]) + plan = await read_plan(backend) + assert plan["walls"] == [wall] + assert plan["openings"] == [window] + assert plan["carriers"]["wall:a"] == drawn["walls"][0]["record"] + carrier = drawn["openings"][0]["record"] + assert plan["carriers"]["opening:w1"] == carrier + # the window's record sits on its left-face frame line, with its four lines owned + raw = await backend.entity_get_xdata(carrier, HANDLES_APP) + assert len(raw["xdata"][HANDLES_APP]) == 4 + assert (await backend.entity_get_xdata(carrier, APP_ID))["xdata"][APP_ID] + + +async def test_add_walls_redraws_only_the_wall_the_new_one_joins(backend): + await add_walls( + backend, + [WALL_A, {"id": "c", "axis": [[0.0, 2000.0], [5000.0, 2000.0]], "thickness": 200.0}], + ) + result = await add_walls( + backend, [{"id": "b", "axis": [[2500.0, 0.0], [2500.0, 1000.0]], "thickness": 100.0}] + ) + assert result["added"] == ["b"] + assert result["redrawn"] == ["a"] # c is not touched by b + faces = {ends(e) for e in await on(backend, "wall", "LINE")} + # a's near face is now cut where b meets it; its far face is still one line + assert ((0.0, 100.0), (2450.0, 100.0)) in faces + assert ((2550.0, 100.0), (5000.0, 100.0)) in faces + assert ((0.0, -100.0), (5000.0, -100.0)) in faces + assert ((0.0, 100.0), (5000.0, 100.0)) not in faces # the old, uncut face was erased + plan = await read_plan(backend) + assert sorted(w.id for w in plan["walls"]) == ["a", "b", "c"] + + +async def test_an_opening_added_later_cuts_its_wall(backend): + await add_walls(backend, [WALL_A]) + before = {ends(e) for e in await on(backend, "wall", "LINE")} + assert ((0.0, 100.0), (5000.0, 100.0)) in before + result = await add_opening( + backend, {"wall": "a", "kind": "door", "offset": 1000.0, "width": 900.0} + ) + assert result["opening"] == "D1" and result["tag"] == "D1" + assert result["redrawn"] == ["a"] + after = {ends(e) for e in await on(backend, "wall", "LINE")} + assert ((0.0, 100.0), (5000.0, 100.0)) not in after + assert {((0.0, 100.0), (1000.0, 100.0)), ((1900.0, 100.0), (5000.0, 100.0))} <= after + assert len(await on(backend, "door", "ARC")) == 1 + second = await add_opening( + backend, {"wall": "a", "kind": "door", "offset": 3000.0, "width": 800.0} + ) + assert second["tag"] == "D2" + assert len(await on(backend, "door", "ARC")) == 2 # the first door was redrawn, not lost + plan = await read_plan(backend) + assert sorted(op.tag for op in plan["openings"]) == ["D1", "D2"] + + +async def test_an_opening_in_an_unknown_wall_or_overlapping_is_refused_untouched(backend): + await add_walls(backend, [WALL_A]) + count = len(await backend.entity_list(limit=10000)) + with pytest.raises(ValueError, match="'zz' is not a wall"): + await add_opening(backend, {"wall": "zz", "kind": "door", "offset": 0.0, "width": 900.0}) + await add_opening(backend, {"wall": "a", "kind": "window", "offset": 1000.0, "width": 1200.0}) + count = len(await backend.entity_list(limit=10000)) + with pytest.raises(ValueError): + await add_opening(backend, {"wall": "a", "kind": "door", "offset": 1500.0, "width": 900.0}) + assert len(await backend.entity_list(limit=10000)) == count + + +async def test_a_wall_id_already_drawn_is_refused(backend): + await add_walls(backend, [WALL_A]) + with pytest.raises(ValueError, match=r"walls\[0\]\.id: 'a' is already drawn"): + await add_walls(backend, [WALL_A]) + + +async def test_a_new_wall_that_would_swallow_an_opening_is_refused(backend): + await add_walls(backend, [WALL_A]) + await add_opening(backend, {"wall": "a", "kind": "door", "offset": 2200.0, "width": 900.0}) + count = len(await backend.entity_list(limit=10000)) + with pytest.raises(ValueError, match="uncuttable"): + await add_walls(backend, [WALL_B]) + assert len(await backend.entity_list(limit=10000)) == count + + +async def test_draw_stair_writes_its_record_and_reports_blondel(backend): + stair = Stair( + id="s1", + start=(0.0, 0.0), + direction_deg=0.0, + width=1000.0, + risers=17, + riser_height=170.0, + going=290.0, + ) + result = await draw_stair(backend, stair) + assert result["blondel"] == {"value": 630.0, "ok": True, "range": [600.0, 650.0]} + assert result["treads"] == 16 + assert len(await on(backend, "stair", "LINE")) == 17 + 2 + 2 + assert (await read_plan(backend))["stairs"] == [stair] + with pytest.raises(ValueError, match="'s1' is already drawn"): + await add_stair( + backend, + { + "id": "s1", + "start": [0, 0], + "direction_deg": 0.0, # required by Task 1's stair_from_dict + "width": 1000.0, + "risers": 17, + "riser_height": 170.0, + "going": 290.0, + }, + ) + + +async def test_draw_chains_puts_real_dimensions_on_the_dimension_layer(backend): + shell = Wall( + id="shell", + axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)), + thickness=200.0, + closed=True, + ) + result = await draw_chains(backend, [shell], [], sides=("bottom", "left"), scale=50) + dims = await on(backend, "dim", "DIMENSION") + assert len(dims) == result["count"] == 8 # per side: walls (3) + overall (1) + assert {row["row"] for row in result["dimensions"]} == { + "bottom:walls", + "bottom:overall", + "left:walls", + "left:overall", + } + + +async def test_assign_tags_numbers_each_kind_in_the_chosen_language(): + ops = [ + Opening(id="1", wall="a", kind="door", offset=0.0, width=900.0), + Opening(id="2", wall="a", kind="window", offset=2000.0, width=900.0), + Opening(id="3", wall="a", kind="door", offset=4000.0, width=900.0, tag="D1"), + ] + assert [op.tag for op in assign_tags(ops)] == ["D2", "W1", "D1"] + assert [op.tag for op in assign_tags(ops[:2], lang="tr")] == ["K1", "P1"] diff --git a/tests/test_arch_tools.py b/tests/test_arch_tools.py new file mode 100644 index 0000000..042a0d3 --- /dev/null +++ b/tests/test_arch_tools.py @@ -0,0 +1,124 @@ +"""SECTION 25's first four tools, called the way a client calls them. + +In-memory client on a fresh headless document; the env var pins ezdxf so no +test here can reach an operator's live drawing (tests/conftest.py). +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server + +pytestmark = pytest.mark.asyncio + +ARCH_TOOLS = {"arch_wall", "arch_opening", "arch_stair", "arch_dimension_chains"} + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +async def call(client, name, args) -> dict: + return (await client.call_tool(name, args)).structured_content + + +async def test_the_four_tools_are_registered_under_the_architecture_group(): + tools = {tool.name: tool for tool in await server._registered_tools()} + assert ARCH_TOOLS <= set(tools) + groups = await server._tool_groups() + assert set(groups["architecture"]) == ARCH_TOOLS + for name in ARCH_TOOLS: + card = (tools[name].meta or {})["cad"] + assert card["cost"] == "mutate" + assert card["synonyms"], f"{name} needs an alias record" + + +async def test_a_plan_built_tool_by_tool(client): + walls = await call( + client, + "arch_wall", + { + "walls": [ + { + "id": "shell", + "axis": [[0, 0], [8200, 0], [8200, 6200], [0, 6200]], + "thickness": 200, + "closed": True, + } + ] + }, + ) + assert walls["added"] == ["shell"] and walls["redrawn"] == [] + partition = await call( + client, + "arch_wall", + {"walls": [{"id": "p", "axis": [[4100, 0], [4100, 6200]], "thickness": 100}]}, + ) + assert partition["redrawn"] == ["shell"] + door = await call( + client, "arch_opening", {"wall": "shell", "offset": 5500, "width": 1000, "swing": "in"} + ) + assert door["tag"] == "D1" + window = await call( + client, "arch_opening", {"wall": "shell", "kind": "window", "offset": 1500, "width": 1200} + ) + assert window["tag"] == "W1" + stair = await call( + client, + "arch_stair", + { + "stair_id": "s1", + "start_x": 1000, + "start_y": 1500, + "width": 1000, + "risers": 17, + "riser_height": 170, + "going": 290, + "direction_deg": 90, + }, + ) + assert stair["blondel"]["ok"] is True + chains = await call(client, "arch_dimension_chains", {"sides": ["bottom"]}) + # 5 openings + 5 walls + 1 overall, hand-counted in tests/test_arch_dimension.py + assert chains["count"] == 11 + + +async def test_refusals_come_back_as_tool_errors_that_name_the_cause(client): + with pytest.raises(ToolError, match="no architectural walls"): + await call(client, "arch_dimension_chains", {}) + with pytest.raises(ToolError, match="'nowhere' is not a wall"): + await call(client, "arch_opening", {"wall": "nowhere", "offset": 0, "width": 900}) + await call( + client, "arch_wall", {"walls": [{"id": "a", "axis": [[0, 0], [5000, 0]], "thickness": 200}]} + ) + with pytest.raises(ToolError, match="5°"): + await call( + client, + "arch_wall", + {"walls": [{"id": "b", "axis": [[-2000, -150], [2000, 0]], "thickness": 200}]}, + ) + with pytest.raises(ToolError, match="at least 4"): + await call( + client, + "arch_stair", + { + "stair_id": "s", + "start_x": 0, + "start_y": 0, + "width": 900, + "risers": 3, + "riser_height": 170, + "going": 290, + "kind": "u", + }, + ) diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py index 601afae..42769cb 100644 --- a/tests/test_tool_packs.py +++ b/tests/test_tool_packs.py @@ -246,7 +246,7 @@ async def test_the_three_track_e_sections_file_under_their_own_groups(): assert set(groups["environment"]) == SETTINGS_TOOLS assert set(groups["mechanical"]) == MECH_TOOLS assert set(groups["sheet"]) == SHEET_TOOLS - assert len(groups) == 25 + assert len(groups) == 26 # + architecture (SECTION 25, track F) async def test_system_about_reports_packs(): diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index aa37309..c6aad53 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (229 tools, 25 groups). + """Frozen snapshot of the surface (233 tools, 26 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -581,10 +581,17 @@ async def test_tool_group_sizes_are_unchanged(): `sheet`, so pack, group and SECTION are one list again: `engineering` returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value. + + `architecture` appeared with v1.6's track F: SECTION 25 opened with the + plan model's wall engine (`arch_wall` / `arch_opening` / `arch_stair` / + `arch_dimension_chains`); the `arch` tag is ranked directly after `mech` in + `_GROUP_TAG_PRIORITY`, so those tools file under `architecture` rather + than under their secondary `create` / `dimension` tags. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { "analysis": 12, + "architecture": 4, "batch": 3, "blocks": 9, "corner_ops": 4, @@ -610,4 +617,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 229 + assert sum(sizes.values()) == 233