Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 lines
9.7 KiB
Python
318 lines
9.7 KiB
Python
"""ISO 7200 / A3 (420 x 297 mm) title block generator.
|
|
|
|
Deterministic — the title text is written verbatim from metadata.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from backends.base import AutoCADBackend
|
|
|
|
|
|
# A3 paper, ISO 7200 layout constants (mm)
|
|
SHEET_W = 420.0
|
|
SHEET_H = 297.0
|
|
LEFT_MARGIN = 20.0
|
|
RIGHT_MARGIN = 10.0
|
|
TOP_MARGIN = 10.0
|
|
BOTTOM_MARGIN = 10.0
|
|
|
|
TB_WIDTH = 180.0
|
|
TB_HEIGHT = 60.0
|
|
|
|
ROW1_H = 20.0 # title
|
|
ROW2_H = 15.0 # drawing_no | revision | sheet
|
|
ROW3_H = 15.0 # part_no | material | scale
|
|
ROW4_H = 10.0 # drawn_by | checked_by | date | company
|
|
|
|
LABEL_HEIGHT = 2.0
|
|
VALUE_HEIGHT = 3.5
|
|
TITLE_HEIGHT = 5.0
|
|
|
|
LAYER_BORDER = "TITLEBLOCK"
|
|
LAYER_TEXT = "TEXT"
|
|
|
|
|
|
@dataclass
|
|
class TitleBlockMetadata:
|
|
"""ISO 7200 title block fields (all strings — no LLM transformation)."""
|
|
|
|
title: str
|
|
drawing_no: str
|
|
part_no: str = ""
|
|
material: str = ""
|
|
scale: str = "1:1"
|
|
units: str = "mm"
|
|
drawn_by: str = ""
|
|
checked_by: str = ""
|
|
date: str = ""
|
|
sheet: str = "1/1"
|
|
revision: str = "A"
|
|
company: str = "Anka-Makine"
|
|
|
|
|
|
async def apply_iso_a3_titleblock(
|
|
backend: AutoCADBackend,
|
|
*,
|
|
metadata: TitleBlockMetadata,
|
|
origin: tuple[float, float] = (0.0, 0.0),
|
|
layout: str | None = None,
|
|
) -> dict:
|
|
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet).
|
|
|
|
With `layout`, the sheet is drawn on that paper-space layout — which is
|
|
where a title block belongs, since the border frames the printed sheet
|
|
rather than the model. The caller's current space is restored afterwards, so
|
|
asking for a sheet border does not silently move them onto the sheet.
|
|
Without `layout` it draws in whatever space is current, as it always did.
|
|
"""
|
|
|
|
ox, oy = float(origin[0]), float(origin[1])
|
|
|
|
previous_space = "Model"
|
|
if layout is not None:
|
|
listing = await backend.layout_list()
|
|
names = {str(entry) for entry in (listing.get("layouts") or [])}
|
|
if layout not in names:
|
|
raise RuntimeError(
|
|
f"apply_iso_a3_titleblock: layout {layout!r} does not exist "
|
|
f"(have: {', '.join(sorted(str(n) for n in names))}). Create it with "
|
|
"layout_create first."
|
|
)
|
|
previous_space = getattr(backend, "_current_space", "Model")
|
|
switched = await backend.layout_set_current(layout)
|
|
if not switched.get("ok", True):
|
|
raise RuntimeError(f"apply_iso_a3_titleblock: {switched.get('error')}")
|
|
|
|
try:
|
|
return await _draw_titleblock(backend, metadata=metadata, ox=ox, oy=oy, layout=layout)
|
|
finally:
|
|
if layout is not None:
|
|
await backend.layout_set_current(previous_space)
|
|
|
|
|
|
async def _draw_titleblock(
|
|
backend: AutoCADBackend,
|
|
*,
|
|
metadata: TitleBlockMetadata,
|
|
ox: float,
|
|
oy: float,
|
|
layout: str | None,
|
|
) -> dict:
|
|
|
|
# ---- outer + inner borders -------------------------------------------------
|
|
outer_pts = [
|
|
[ox, oy],
|
|
[ox + SHEET_W, oy],
|
|
[ox + SHEET_W, oy + SHEET_H],
|
|
[ox, oy + SHEET_H],
|
|
]
|
|
outer = await backend.entity_create_polyline(
|
|
points=outer_pts,
|
|
closed=True,
|
|
layer=LAYER_BORDER,
|
|
)
|
|
|
|
inner_x0 = ox + LEFT_MARGIN
|
|
inner_y0 = oy + BOTTOM_MARGIN
|
|
inner_x1 = ox + SHEET_W - RIGHT_MARGIN
|
|
inner_y1 = oy + SHEET_H - TOP_MARGIN
|
|
inner_pts = [
|
|
[inner_x0, inner_y0],
|
|
[inner_x1, inner_y0],
|
|
[inner_x1, inner_y1],
|
|
[inner_x0, inner_y1],
|
|
]
|
|
inner = await backend.entity_create_polyline(
|
|
points=inner_pts,
|
|
closed=True,
|
|
layer=LAYER_BORDER,
|
|
)
|
|
|
|
# ---- title block (lower-right corner of inner border) ----------------------
|
|
tb_x0 = inner_x1 - TB_WIDTH
|
|
tb_y0 = inner_y0
|
|
tb_x1 = inner_x1
|
|
tb_y1 = inner_y0 + TB_HEIGHT
|
|
|
|
# Title block frame (closed polyline)
|
|
tb_frame_pts = [
|
|
[tb_x0, tb_y0],
|
|
[tb_x1, tb_y0],
|
|
[tb_x1, tb_y1],
|
|
[tb_x0, tb_y1],
|
|
]
|
|
tb_frame = await backend.entity_create_polyline(
|
|
points=tb_frame_pts,
|
|
closed=True,
|
|
layer=LAYER_BORDER,
|
|
)
|
|
|
|
# Row baselines (from bottom up): row4_top, row3_top, row2_top
|
|
row4_top = tb_y0 + ROW4_H
|
|
row3_top = row4_top + ROW3_H
|
|
row2_top = row3_top + ROW2_H # == tb_y1 - ROW1_H
|
|
|
|
titleblock_lines: list[str] = [tb_frame.handle]
|
|
|
|
async def hline(y: float) -> str:
|
|
ent = await backend.entity_create_line(
|
|
tb_x0,
|
|
y,
|
|
tb_x1,
|
|
y,
|
|
layer=LAYER_BORDER,
|
|
)
|
|
return ent.handle
|
|
|
|
async def vline(x: float, y0: float, y1: float) -> str:
|
|
ent = await backend.entity_create_line(
|
|
x,
|
|
y0,
|
|
x,
|
|
y1,
|
|
layer=LAYER_BORDER,
|
|
)
|
|
return ent.handle
|
|
|
|
titleblock_lines.append(await hline(row4_top))
|
|
titleblock_lines.append(await hline(row3_top))
|
|
titleblock_lines.append(await hline(row2_top))
|
|
|
|
# Row 2 splits: left half = drawing_no, then quarter rev, quarter sheet
|
|
r2_split1 = tb_x0 + TB_WIDTH * 0.5
|
|
r2_split2 = tb_x0 + TB_WIDTH * 0.75
|
|
titleblock_lines.append(await vline(r2_split1, row3_top, row2_top))
|
|
titleblock_lines.append(await vline(r2_split2, row3_top, row2_top))
|
|
|
|
# Row 3 splits: thirds — part_no | material | scale
|
|
r3_split1 = tb_x0 + TB_WIDTH / 3.0
|
|
r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0
|
|
titleblock_lines.append(await vline(r3_split1, row4_top, row3_top))
|
|
titleblock_lines.append(await vline(r3_split2, row4_top, row3_top))
|
|
|
|
# Row 4 splits: quarters — drawn_by | checked_by | date | company
|
|
r4_split1 = tb_x0 + TB_WIDTH * 0.25
|
|
r4_split2 = tb_x0 + TB_WIDTH * 0.50
|
|
r4_split3 = tb_x0 + TB_WIDTH * 0.75
|
|
titleblock_lines.append(await vline(r4_split1, tb_y0, row4_top))
|
|
titleblock_lines.append(await vline(r4_split2, tb_y0, row4_top))
|
|
titleblock_lines.append(await vline(r4_split3, tb_y0, row4_top))
|
|
|
|
# ---- text content ----------------------------------------------------------
|
|
label_pad_x = 1.5
|
|
label_pad_y = 1.5
|
|
value_pad_x = 3.0
|
|
value_pad_y = 5.0
|
|
|
|
async def label(text: str, x: float, y: float) -> str:
|
|
ent = await backend.entity_create_text(
|
|
text=text,
|
|
x=x,
|
|
y=y,
|
|
height=LABEL_HEIGHT,
|
|
layer=LAYER_TEXT,
|
|
)
|
|
return ent.handle
|
|
|
|
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
|
|
ent = await backend.entity_create_text(
|
|
text=text,
|
|
x=x,
|
|
y=y,
|
|
height=height,
|
|
layer=LAYER_TEXT,
|
|
)
|
|
return ent.handle
|
|
|
|
# Row 1 — TITLE (verbatim)
|
|
title_x = tb_x0 + TB_WIDTH * 0.5 - (len(metadata.title) * TITLE_HEIGHT * 0.3)
|
|
title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0
|
|
title_text_handle = await value(metadata.title, title_x, title_y, height=TITLE_HEIGHT)
|
|
|
|
value_texts: dict[str, str] = {}
|
|
|
|
# Row 2: drawing_no (left half) | revision (3rd qtr) | sheet (4th qtr)
|
|
await label("DWG NO", tb_x0 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["drawing_no"] = await value(
|
|
metadata.drawing_no,
|
|
tb_x0 + value_pad_x,
|
|
row3_top + value_pad_y,
|
|
)
|
|
await label("REV", r2_split1 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["revision"] = await value(
|
|
metadata.revision,
|
|
r2_split1 + value_pad_x,
|
|
row3_top + value_pad_y,
|
|
)
|
|
await label("SHEET", r2_split2 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["sheet"] = await value(
|
|
metadata.sheet,
|
|
r2_split2 + value_pad_x,
|
|
row3_top + value_pad_y,
|
|
)
|
|
|
|
# Row 3: part_no | material | scale
|
|
await label("PART NO", tb_x0 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["part_no"] = await value(
|
|
metadata.part_no,
|
|
tb_x0 + value_pad_x,
|
|
row4_top + value_pad_y,
|
|
)
|
|
await label("MATERIAL", r3_split1 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["material"] = await value(
|
|
metadata.material,
|
|
r3_split1 + value_pad_x,
|
|
row4_top + value_pad_y,
|
|
)
|
|
await label("SCALE", r3_split2 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["scale"] = await value(
|
|
metadata.scale,
|
|
r3_split2 + value_pad_x,
|
|
row4_top + value_pad_y,
|
|
)
|
|
|
|
# Row 4: drawn_by | checked_by | date | company
|
|
await label("DRAWN", tb_x0 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["drawn_by"] = await value(
|
|
metadata.drawn_by,
|
|
tb_x0 + value_pad_x,
|
|
tb_y0 + 1.5,
|
|
)
|
|
await label("CHECKED", r4_split1 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["checked_by"] = await value(
|
|
metadata.checked_by,
|
|
r4_split1 + value_pad_x,
|
|
tb_y0 + 1.5,
|
|
)
|
|
await label("DATE", r4_split2 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["date"] = await value(
|
|
metadata.date,
|
|
r4_split2 + value_pad_x,
|
|
tb_y0 + 1.5,
|
|
)
|
|
await label("COMPANY", r4_split3 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
|
|
value_texts["company"] = await value(
|
|
metadata.company,
|
|
r4_split3 + value_pad_x,
|
|
tb_y0 + 1.5,
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"layout": layout or "Model",
|
|
"outer_border": outer.handle,
|
|
"inner_border": inner.handle,
|
|
"titleblock_lines": titleblock_lines,
|
|
"title_text": title_text_handle,
|
|
"value_texts": value_texts,
|
|
"metadata": asdict(metadata),
|
|
"bbox": {
|
|
"min": [ox, oy],
|
|
"max": [ox + SHEET_W, oy + SHEET_H],
|
|
},
|
|
}
|