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