release: v1.5.0 — discovery layer, honesty debt closed, Wave A
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5a0ff5109
commit
b03a7615b1
+230
-12
@@ -10,14 +10,25 @@ Scope (deliberate, documented):
|
||||
|
||||
- Nominal sizes over 1 mm up to and including 500 mm.
|
||||
- IT grades 4-11 (IT4 is carried for the shaft-k rule and future hole mirrors).
|
||||
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p.
|
||||
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS).
|
||||
- Transition/interference HOLE letters (K, M, N, P) require the ISO 286
|
||||
delta-rule and sub-stepped r/s tables; they are intentionally excluded and
|
||||
raise a ValueError naming the supported set.
|
||||
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p, and the interference
|
||||
shafts r, s, t, u (t from 24 mm, where ISO 286 begins defining it).
|
||||
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS), and
|
||||
K, M, N, P via the ISO 286 delta rule ``ES = -ei + Δ``, ``Δ = IT(n) −
|
||||
IT(n−1)``. That is a rule over tables already here, not new table data, which
|
||||
is why it can be pinned against published values rather than trusted.
|
||||
|
||||
r/s/t/u *are* new table data, sub-stepped more finely than the main size steps,
|
||||
so they are cross-checked in the tests against ISO 286-1's own derivation
|
||||
formulae — an independent source. r reproduces as the geometric mean of p and s
|
||||
on 24/24 steps (with p pinned before r existed); s, t and u track their
|
||||
IT7-based formulae within 1 µm on every step from 18 mm up, and the five places
|
||||
where the standard's rounding puts a published value 2-3 µm off its own formula
|
||||
are listed by name in the tests rather than hidden.
|
||||
|
||||
This covers the preferred general-mechanical fits (H7/g6, H7/h6, H7/k6,
|
||||
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...).
|
||||
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...), the hole-basis
|
||||
transition and interference pairs (K7/h6, M7/h6, N7/h6, P7/h6), and the heavy
|
||||
press/drive fits (H7/r6, H7/s6, H7/t6, H7/u6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -78,10 +89,206 @@ _SHAFT_EI: dict[str, tuple[int, ...]] = {
|
||||
"p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68),
|
||||
}
|
||||
|
||||
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p")
|
||||
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS")
|
||||
# Interference shafts r/s/t/u are sub-stepped more finely than the main steps:
|
||||
# ISO 286 splits >18-30 into 18-24/24-30, >30-50 into 30-40/40-50, and every step
|
||||
# above 50 mm in two or three. Flattening them onto the main steps is a silent
|
||||
# error of tens of microns at the sizes these fits are actually used at.
|
||||
_FINE_SIZE_STEPS: tuple[tuple[float, float], ...] = (
|
||||
(1, 3),
|
||||
(3, 6),
|
||||
(6, 10),
|
||||
(10, 18),
|
||||
(18, 24),
|
||||
(24, 30),
|
||||
(30, 40),
|
||||
(40, 50),
|
||||
(50, 65),
|
||||
(65, 80),
|
||||
(80, 100),
|
||||
(100, 120),
|
||||
(120, 140),
|
||||
(140, 160),
|
||||
(160, 180),
|
||||
(180, 200),
|
||||
(200, 225),
|
||||
(225, 250),
|
||||
(250, 280),
|
||||
(280, 315),
|
||||
(315, 355),
|
||||
(355, 400),
|
||||
(400, 450),
|
||||
(450, 500),
|
||||
)
|
||||
|
||||
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$")
|
||||
# Fundamental deviations ei (µm) for the interference shafts, one per fine step.
|
||||
#
|
||||
# PROVENANCE — this is table data, not a rule, so it is cross-checked in
|
||||
# tests/test_fits.py against ISO 286-1's own derivation formulae rather than
|
||||
# trusted on its own:
|
||||
# r = geometric mean of p and s -> 24/24 steps agree within 1 µm, and p
|
||||
# was already pinned before r existed
|
||||
# s (>50 mm) = IT7 + 0.40*D -> agrees within 1 µm
|
||||
# s (<=50 mm) = IT8 + 0..4 -> monotone inside the standard's band
|
||||
# t = IT7 + 0.63*D -> 17/19 within 1 µm
|
||||
# u = IT7 + 1.00*D -> 17/20 within 1 µm
|
||||
# D is the geometric mean of the step bounds. The handful of steps where the
|
||||
# standard's rounding puts the published value 2-3 µm off its own formula are
|
||||
# listed by name in KNOWN_FORMULA_DIVERGENCES, so a reader holding ISO 286 has
|
||||
# five numbers to check rather than ninety-five.
|
||||
#
|
||||
# t is undefined below 24 mm in the standard and is None there — refused, not
|
||||
# extrapolated.
|
||||
_SHAFT_EI_FINE: dict[str, tuple[int | None, ...]] = {
|
||||
"r": (
|
||||
10,
|
||||
15,
|
||||
19,
|
||||
23,
|
||||
28,
|
||||
28,
|
||||
34,
|
||||
34,
|
||||
41,
|
||||
43,
|
||||
51,
|
||||
54,
|
||||
63,
|
||||
65,
|
||||
68,
|
||||
77,
|
||||
80,
|
||||
84,
|
||||
94,
|
||||
98,
|
||||
108,
|
||||
114,
|
||||
126,
|
||||
132,
|
||||
),
|
||||
"s": (
|
||||
14,
|
||||
19,
|
||||
23,
|
||||
28,
|
||||
35,
|
||||
35,
|
||||
43,
|
||||
43,
|
||||
53,
|
||||
59,
|
||||
71,
|
||||
79,
|
||||
92,
|
||||
100,
|
||||
108,
|
||||
122,
|
||||
130,
|
||||
140,
|
||||
158,
|
||||
170,
|
||||
190,
|
||||
208,
|
||||
232,
|
||||
252,
|
||||
),
|
||||
"t": (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
41,
|
||||
48,
|
||||
54,
|
||||
66,
|
||||
75,
|
||||
91,
|
||||
104,
|
||||
122,
|
||||
134,
|
||||
146,
|
||||
166,
|
||||
180,
|
||||
196,
|
||||
218,
|
||||
240,
|
||||
268,
|
||||
294,
|
||||
330,
|
||||
360,
|
||||
),
|
||||
"u": (
|
||||
18,
|
||||
23,
|
||||
28,
|
||||
33,
|
||||
41,
|
||||
48,
|
||||
60,
|
||||
70,
|
||||
87,
|
||||
102,
|
||||
124,
|
||||
144,
|
||||
170,
|
||||
190,
|
||||
210,
|
||||
236,
|
||||
258,
|
||||
284,
|
||||
315,
|
||||
350,
|
||||
390,
|
||||
435,
|
||||
490,
|
||||
540,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _fine_size_index(nominal_mm: float) -> int:
|
||||
for index, (over, up_to) in enumerate(_FINE_SIZE_STEPS):
|
||||
if over < nominal_mm <= up_to:
|
||||
return index
|
||||
raise ValueError( # pragma: no cover - _size_index rejects out-of-range first
|
||||
f"No ISO 286 sub-step covers {nominal_mm} mm"
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p", "r", "s", "t", "u")
|
||||
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS", "K", "M", "N", "P")
|
||||
|
||||
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[K-Pk-p]|[r-ur-u])(\d{1,2})\s*$")
|
||||
|
||||
#: Holes derived from the same-letter shaft by the ISO 286 delta rule. No new
|
||||
#: table data: ES = -ei + Δ, with Δ = IT(n) − IT(n−1) taken from _IT_TABLE.
|
||||
_DELTA_RULE_HOLES = ("K", "M", "N", "P")
|
||||
|
||||
#: The highest IT grade the delta still applies at. Above it ISO 286 sets Δ = 0,
|
||||
#: so the hole is the exact mirror of the shaft.
|
||||
_DELTA_MAX_GRADE = {"K": 8, "M": 8, "N": 8, "P": 7}
|
||||
|
||||
|
||||
def _delta_rule_es(letter: str, grade: int, size_index: int, it_um: int) -> float:
|
||||
"""Upper deviation of a transition/interference hole (ISO 286 delta rule).
|
||||
|
||||
``ES = -ei + Δ`` where ``ei`` is the same-letter shaft's fundamental
|
||||
deviation and ``Δ = IT(n) − IT(n−1)``. Both inputs are already authored in
|
||||
this module, so this adds a *rule* rather than a table — and the rule is
|
||||
pinned against published values in the tests, which is what makes it
|
||||
checkable at all.
|
||||
"""
|
||||
if grade <= _IT_GRADES[0]:
|
||||
raise ValueError(
|
||||
f"Fit {letter}{grade}: the ISO 286 delta rule needs IT{grade - 1}, which is "
|
||||
f"below the authored IT{_IT_GRADES[0]}-IT{_IT_GRADES[-1]} range. Use IT5 or "
|
||||
"coarser for a transition/interference hole."
|
||||
)
|
||||
ei = float(_SHAFT_EI[letter.lower()][size_index])
|
||||
if grade > _DELTA_MAX_GRADE[letter]:
|
||||
return -ei # ISO 286: above this grade the delta is zero
|
||||
previous_it = _IT_TABLE[size_index][_IT_GRADES.index(grade) - 1]
|
||||
return -ei + float(it_um - previous_it)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -163,12 +370,23 @@ def fit_lookup(code: str, nominal_mm: float) -> FitDeviation:
|
||||
else:
|
||||
ei = float(_SHAFT_EI[letter][index])
|
||||
upper_um, lower_um = ei + it_um, ei
|
||||
elif letter in _DELTA_RULE_HOLES: # K, M, N, P — transition/interference holes
|
||||
es = _delta_rule_es(letter, grade, index, it_um)
|
||||
upper_um, lower_um = es, es - it_um
|
||||
elif letter in _SHAFT_EI_FINE: # r, s, t, u — interference shafts
|
||||
ei_um = _SHAFT_EI_FINE[letter][_fine_size_index(float(nominal_mm))]
|
||||
if ei_um is None:
|
||||
raise ValueError(
|
||||
f"Fit {letter}{grade}: ISO 286 does not define shaft {letter!r} below "
|
||||
f"24 mm, and {nominal_mm} mm is under it. Use s (defined from 1 mm) "
|
||||
"for a lighter interference fit, or explicit tol_upper/tol_lower."
|
||||
)
|
||||
ei = float(ei_um)
|
||||
upper_um, lower_um = ei + it_um, ei
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Fit letter {letter!r} is outside the authored scope. Supported: "
|
||||
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. "
|
||||
"Transition/interference hole letters (K, M, N, P) need the ISO 286 "
|
||||
"delta-rule and are not authored yet."
|
||||
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}."
|
||||
)
|
||||
|
||||
return FitDeviation(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Exact area and perimeter for DXF boundary geometry.
|
||||
|
||||
Pure arithmetic: no ezdxf, no COM, no I/O. Both backends call the same functions,
|
||||
so the headless and live engines cannot drift apart on the numbers, and the tests
|
||||
exercise the real implementation rather than a stand-in.
|
||||
|
||||
The thing that makes this exact rather than approximate is the **bulge**. A DXF
|
||||
polyline edge carries a bulge factor, and a non-zero bulge means that edge is a
|
||||
circular arc — always circular, never a spline — so the enclosed area is the
|
||||
shoelace of the vertices plus each arc's signed circular segment, and the walked
|
||||
length is arc length rather than chord length. Both are closed-form.
|
||||
|
||||
Getting this wrong is not a rounding matter. Measured on a 100x100 square with
|
||||
one edge bulged into an outward semicircle: ignoring bulge reports 10000 against
|
||||
a true 13926.9908, i.e. **28.2% low**, and a perimeter 12.5% low.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
#: Max chord deviation (drawing units) when geometry has to be flattened because
|
||||
#: it is not closed-form — splines, partial ellipses.
|
||||
DEFAULT_FLATTEN_TOLERANCE = 0.001
|
||||
|
||||
#: Above this vertex count the O(n^2) self-intersection test is skipped and the
|
||||
#: answer is reported as unknown. A slow honest "I did not check" beats both a
|
||||
#: hang and a confident False.
|
||||
MAX_SELF_INTERSECT_VERTS = 2000
|
||||
|
||||
_EPS = 1e-12
|
||||
|
||||
|
||||
def polygon_area_perimeter(
|
||||
vertices: Sequence[tuple[float, float, float]],
|
||||
closed: bool,
|
||||
) -> tuple[float, float]:
|
||||
"""Exact area and perimeter of a bulge-carrying polygon.
|
||||
|
||||
``vertices`` are ``(x, y, bulge)``; the bulge belongs to the edge *leaving*
|
||||
that vertex, which is the DXF convention.
|
||||
|
||||
``closed=False`` still returns the area of the implicitly closed region —
|
||||
that is what AutoCAD's AREA command does — but the perimeter is the length
|
||||
actually walked, without the closing segment. Callers must report the
|
||||
assumption rather than let it pass silently.
|
||||
|
||||
Two vertices are not degenerate when they carry bulges: that is how DXF
|
||||
stores a circle built from two semicircular arcs, and the shoelace over its
|
||||
two points is genuinely 0 while the two circular segments are the entire
|
||||
shape. Only zero or one vertex bounds nothing.
|
||||
"""
|
||||
count = len(vertices)
|
||||
if count < 2:
|
||||
return 0.0, _open_length(vertices)
|
||||
|
||||
shoelace = 0.0
|
||||
for i in range(count):
|
||||
x1, y1, _ = vertices[i]
|
||||
x2, y2, _ = vertices[(i + 1) % count]
|
||||
shoelace += x1 * y2 - x2 * y1
|
||||
shoelace *= 0.5
|
||||
|
||||
segments = 0.0
|
||||
perimeter = 0.0
|
||||
limit = count if closed else count - 1
|
||||
for i in range(count):
|
||||
x1, y1, bulge = vertices[i]
|
||||
x2, y2, _ = vertices[(i + 1) % count]
|
||||
chord = math.hypot(x2 - x1, y2 - y1)
|
||||
if abs(bulge) < _EPS or chord < _EPS:
|
||||
if i < limit:
|
||||
perimeter += chord
|
||||
continue
|
||||
theta = 4.0 * math.atan(bulge) # signed sweep, radians
|
||||
radius = chord / (2.0 * math.sin(abs(theta) / 2.0))
|
||||
# The circular segment cut off by the chord. Signed: a positive bulge
|
||||
# bows the edge outward and adds material, a negative one removes it.
|
||||
segments += math.copysign(
|
||||
radius * radius / 2.0 * (abs(theta) - math.sin(abs(theta))), theta
|
||||
)
|
||||
if i < limit:
|
||||
perimeter += radius * abs(theta)
|
||||
|
||||
return abs(shoelace + segments), perimeter
|
||||
|
||||
|
||||
def _open_length(vertices: Sequence[tuple[float, float, float]]) -> float:
|
||||
return sum(
|
||||
math.dist(vertices[i][:2], vertices[i + 1][:2]) for i in range(max(0, len(vertices) - 1))
|
||||
)
|
||||
|
||||
|
||||
def is_self_intersecting(vertices: Sequence[tuple[float, float]]) -> bool | None:
|
||||
"""True when non-adjacent edges of the closed loop cross; None if not checked.
|
||||
|
||||
Worth reporting because the shoelace *cancels* crossed lobes rather than
|
||||
failing: a bowtie made of two 2500-unit triangles measures 0.0, which is a
|
||||
confident wrong number of exactly the kind this release removes.
|
||||
"""
|
||||
count = len(vertices)
|
||||
if count < 4:
|
||||
return False
|
||||
if count > MAX_SELF_INTERSECT_VERTS:
|
||||
return None # unknown beats a guess
|
||||
|
||||
for i in range(count):
|
||||
a1, a2 = vertices[i], vertices[(i + 1) % count]
|
||||
for j in range(i + 1, count):
|
||||
if j == i or (j + 1) % count == i or j == (i + 1) % count:
|
||||
continue # adjacent edges share an endpoint by construction
|
||||
b1, b2 = vertices[j], vertices[(j + 1) % count]
|
||||
if _segments_cross(a1, a2, b1, b2):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _orientation(p, q, r) -> float:
|
||||
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
||||
|
||||
|
||||
def _segments_cross(p1, p2, q1, q2) -> bool:
|
||||
"""Proper crossing only — touching at an endpoint is not an intersection."""
|
||||
d1 = _orientation(q1, q2, p1)
|
||||
d2 = _orientation(q1, q2, p2)
|
||||
d3 = _orientation(p1, p2, q1)
|
||||
d4 = _orientation(p1, p2, q2)
|
||||
return ((d1 > _EPS) != (d2 > _EPS)) and ((d3 > _EPS) != (d4 > _EPS))
|
||||
|
||||
|
||||
def ellipse_area(major: float, ratio: float) -> float:
|
||||
"""pi*a*b — closed form, exact."""
|
||||
return math.pi * abs(float(major)) * abs(float(major) * float(ratio))
|
||||
|
||||
|
||||
def ellipse_perimeter_ramanujan(major: float, ratio: float) -> float:
|
||||
"""Ramanujan's second approximation.
|
||||
|
||||
Relative error is around 1e-10 for ratio >= 0.1, but it is an approximation
|
||||
and not closed form, so anything reporting it must set ``perimeter_exact``
|
||||
to False. Exact for a circle (ratio == 1), which is the useful sanity check.
|
||||
"""
|
||||
a = abs(float(major))
|
||||
b = abs(float(major) * float(ratio))
|
||||
if a < _EPS or b < _EPS:
|
||||
return 4.0 * max(a, b)
|
||||
h = ((a - b) ** 2) / ((a + b) ** 2)
|
||||
return math.pi * (a + b) * (1.0 + (3.0 * h) / (10.0 + math.sqrt(4.0 - 3.0 * h)))
|
||||
@@ -108,13 +108,18 @@ def preflight_drawing(
|
||||
|
||||
questions: list[PreflightQuestion] = []
|
||||
required = (
|
||||
("units", "MISSING_UNITS", "Çizim birimi nedir?", ["mm", "inch"]),
|
||||
("part_type", "MISSING_PART_TYPE", "Üretilecek parça/çizim türü nedir?", []),
|
||||
("dimensions", "MISSING_DIMENSIONS", "Ana üretim ölçüleri nelerdir?", []),
|
||||
("units", "MISSING_UNITS", "What drawing units should be used?", ["mm", "inch"]),
|
||||
(
|
||||
"part_type",
|
||||
"MISSING_PART_TYPE",
|
||||
"What kind of part or drawing is being produced?",
|
||||
[],
|
||||
),
|
||||
("dimensions", "MISSING_DIMENSIONS", "What are the main production dimensions?", []),
|
||||
(
|
||||
"tolerance_policy",
|
||||
"MISSING_TOLERANCE_POLICY",
|
||||
"Hangi tolerans politikası kullanılmalı?",
|
||||
"Which tolerance policy should apply?",
|
||||
["ISO 2768-m", "explicit", "none"],
|
||||
),
|
||||
)
|
||||
@@ -138,7 +143,7 @@ def preflight_drawing(
|
||||
"CONFLICTING_REQUIREMENT",
|
||||
field_name,
|
||||
[seen_constraints[field_name], value],
|
||||
f"{field_name} için birbiriyle çelişen değerler verildi.",
|
||||
f"Conflicting values were supplied for {field_name}.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -59,11 +59,50 @@ async def apply_iso_a3_titleblock(
|
||||
*,
|
||||
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)."""
|
||||
"""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],
|
||||
@@ -263,6 +302,8 @@ async def apply_iso_a3_titleblock(
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"layout": layout or "Model",
|
||||
"outer_border": outer.handle,
|
||||
"inner_border": inner.handle,
|
||||
"titleblock_lines": titleblock_lines,
|
||||
|
||||
Reference in New Issue
Block a user