merge: tracks B+G group G

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 12:25:45 +03:00
co-authored by Claude Opus 5.5
26 changed files with 5451 additions and 342 deletions
+7
View File
@@ -45,6 +45,8 @@ from .preflight import (
preflight_drawing,
)
from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing
from .sheet.frames import draw_sheet_frame, frame_metrics, frame_prims
from .sheet.titleblock import apply_titleblock, titleblock_prims
from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
from .validator import DrawingValidator, ValidationFinding, ValidationResult
@@ -92,4 +94,9 @@ __all__ = [
"RefineRound",
"RepairAction",
"refine_drawing",
"apply_titleblock",
"draw_sheet_frame",
"frame_metrics",
"frame_prims",
"titleblock_prims",
]
+88
View File
@@ -0,0 +1,88 @@
"""The sheet standard: ISO 5457 frames, ISO 7200 title blocks, ISO 7573 parts
lists, ISO 6433 balloons and the delivery formats."""
from engineering.sheet.bom import (
BOM_COLUMNS,
COLUMN_LABELS,
DEFAULT_COLUMNS,
LIST_WIDTH,
ROW_HEIGHT,
add_balloon,
balloon_prims,
draw_bom_table,
extract_records,
read_balloons,
rows_from_records,
table_prims,
)
from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available
from engineering.sheet.frames import (
CENTRING_OVERSHOOT,
EDGE_MARGIN,
FILING_MARGIN,
SHEET_LAYER,
SHEET_TEXT_LAYER,
SHEETS,
TRIM_MARK_LONG,
TRIM_MARK_SHORT,
ZONE_DIVISIONS,
ZONE_MODULE,
draw_sheet_frame,
draw_sheet_prims,
enter_layout,
frame_metrics,
frame_prims,
sheet_size,
zone_divisions,
)
from engineering.sheet.revision import (
REVISION_COLUMNS,
add_revision,
read_revisions,
revision_block_prims,
revision_heading_prims,
revision_row_prims,
revision_tag_prims,
)
__all__ = [
"BOM_COLUMNS",
"CENTRING_OVERSHOOT",
"COLUMN_LABELS",
"DEFAULT_COLUMNS",
"EDGE_MARGIN",
"EXTRACT_FORMATS",
"FILING_MARGIN",
"LIST_WIDTH",
"REVISION_COLUMNS",
"ROW_HEIGHT",
"SHEETS",
"SHEET_LAYER",
"SHEET_TEXT_LAYER",
"TRIM_MARK_LONG",
"TRIM_MARK_SHORT",
"ZONE_DIVISIONS",
"ZONE_MODULE",
"add_balloon",
"add_revision",
"balloon_prims",
"draw_bom_table",
"draw_sheet_frame",
"draw_sheet_prims",
"enter_layout",
"extract_records",
"frame_metrics",
"frame_prims",
"read_balloons",
"read_revisions",
"revision_block_prims",
"revision_heading_prims",
"revision_row_prims",
"revision_tag_prims",
"rows_from_records",
"sheet_size",
"table_prims",
"write_rows",
"xlsx_available",
"zone_divisions",
]
+587
View File
@@ -0,0 +1,587 @@
"""ISO 7573 parts lists and ISO 6433 item references.
SOURCE
------
Parts list ISO 7573:2008 -- an item list carries at least an item reference
(the position number), a quantity, a designation and a reference
to the document or standard that defines the item. When the list
is drawn on the drawing it is placed directly above the title
block, has the same width as it, and the item numbers ascend
upwards so the list can be extended -- which puts the heading row
at the bottom, against the title block. `direction="down"` draws
the same list the other way up, for a list on its own sheet.
Balloons ISO 6433:2012 -- an item reference is a numeral placed outside the
outline of the item and connected to it by a leader line that
terminates in a dot on the item.
NOT TRANSCRIBED, and therefore not asserted anywhere as if it were the
standard's:
* The column widths. ISO 7573 fixes neither the widths nor the order; the
shares below total the 180 mm title-block width, which *is* the standard's
rule, and any column set is rescaled to that total.
* The row height. 7 mm is this repository's `entity_create_table` default.
* ISO 6433's rule that the numeral is at least twice the height of the
dimension characters. The numeral here is 1.4 * the balloon radius, so the
default radius of 4 mm gives a 5.6 mm numeral -- which clears 2 x 2.5 mm
dimension text but not 2 x 3.5 mm. A drawing dimensioned at 3.5 mm should
pass `radius=5.0`, and the docstring of `balloon_prims` says so.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from engineering.mech.primitives import Circle, Line, Prim, Pt, Text
from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values
from engineering.sheet.frames import SHEET_TEXT_LAYER, draw_sheet_prims, enter_layout
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"BALLOON_DOT_RADIUS",
"BOM_COLUMNS",
"COLUMN_LABELS",
"COLUMN_SHARES",
"DEFAULT_COLUMNS",
"DIRECTIONS",
"LIST_STANDARDS",
"LIST_WIDTH",
"PAGE_SIZE",
"ROW_HEIGHT",
"add_balloon",
"all_entities",
"balloon_prims",
"draw_bom_table",
"extract_records",
"read_balloons",
"rows_from_records",
"table_layout",
"table_prims",
]
LIST_WIDTH = 180.0 # ISO 7573: the item list is as wide as the title block.
ROW_HEIGHT = 7.0
TEXT_HEIGHT = 2.5
BALLOON_DOT_RADIUS = 0.5
DIRECTIONS = ("up", "down")
LIST_STANDARDS = ("ISO 7573",)
#: Every column this module knows how to print, in the order it prints them.
BOM_COLUMNS: tuple[str, ...] = (
"item",
"qty",
"designation",
"standard",
"material",
"mass",
"remarks",
)
#: The four ISO 7573 minimum columns plus the material this shop always wants.
DEFAULT_COLUMNS: tuple[str, ...] = ("item", "qty", "designation", "standard", "material")
COLUMN_LABELS: dict[str, str] = {
"item": "POS",
"qty": "QTY",
"designation": "DESIGNATION",
"standard": "STANDARD",
"material": "MATERIAL",
"mass": "MASS",
"remarks": "REMARKS",
}
#: Relative widths; `table_layout` rescales the chosen set to LIST_WIDTH. The
#: default five happen to total 180.0, so their scale factor is exactly 1.
COLUMN_SHARES: dict[str, float] = {
"item": 12.0,
"qty": 14.0,
"designation": 74.0,
"standard": 46.0,
"material": 34.0,
"mass": 22.0,
"remarks": 40.0,
}
#: How many entities one `entity_list` call asks for while paging. It is a page
#: size, never a cap: `all_entities` keeps asking until a short page comes back.
PAGE_SIZE = 1000
async def all_entities(backend: AutoCADBackend, *, type_filter=None, layer=None):
"""Every entity of a type in the CURRENT space -- not the first 200 of them.
`entity_list`'s `limit` defaults to 200 on both engines, so a single call is
a silent truncation on any real drawing: a parts list that stops at the
200th INSERT prints a wrong QTY, and a duplicate-balloon guard that only
sees the first 200 CIRCLEs is blind on a drawing whose hole pattern fills
them. Everything in this module that *counts* or *guards* reads through
here instead.
The loop stops on a short page, and also when a page brings nothing new --
a backend that ignored `offset` would otherwise hand back the same page for
ever. Handles are unique per drawing, so that guard cannot drop a real
entity.
"""
found: list = []
seen: set[str] = set()
offset = 0
while True:
page = await backend.entity_list(
type_filter=type_filter, layer_filter=layer, limit=PAGE_SIZE, offset=offset
)
fresh = [entity for entity in page if entity.handle not in seen]
if not fresh:
return tuple(found)
seen.update(entity.handle for entity in fresh)
found.extend(fresh)
if len(page) < PAGE_SIZE:
return tuple(found)
offset += len(page)
def _check_columns(columns) -> tuple[str, ...]:
chosen = tuple(DEFAULT_COLUMNS if columns is None else columns)
unknown = [c for c in chosen if c not in BOM_COLUMNS]
if unknown:
raise ValueError(
f"unknown parts-list column(s) {', '.join(unknown)}; "
f"available: {', '.join(BOM_COLUMNS)}"
)
if "item" not in chosen:
raise ValueError("ISO 7573: the item reference column 'item' cannot be dropped")
return chosen
def rows_from_records(records, *, columns=None, group_by: str | None = "designation"):
"""Collapse extracted INSERT records into numbered parts-list rows.
`group_by` is a record key (`"designation"`, `"standard"`, ...) or None for
one row per insert. Rows are sorted by the grouping value so a rerun on the
same drawing numbers the same parts the same way; `handles` carries every
INSERT the row stands for, which is what a balloon links to.
"""
chosen = _check_columns(columns)
if group_by is not None and group_by not in BOM_COLUMNS:
raise ValueError(
f"group_by must be None or one of {', '.join(BOM_COLUMNS)}; got {group_by!r}"
)
buckets: dict[str, dict] = {}
order: list[str] = []
for index, record in enumerate(records):
key = str(record.get(group_by, "")) if group_by is not None else f"#{index:06d}"
bucket = buckets.get(key)
if bucket is None:
bucket = {c: record.get(c, "") for c in chosen if c != "item"}
bucket["qty"] = 0
bucket["handles"] = []
bucket["_key"] = key
buckets[key] = bucket
order.append(key)
bucket["qty"] += int(record.get("qty", 1) or 1)
handle = record.get("handle")
if handle:
bucket["handles"].append(str(handle))
if group_by is not None:
order.sort()
rows: list[dict] = []
for item, key in enumerate(order, start=1):
bucket = buckets[key]
bucket.pop("_key", None)
bucket["handles"] = tuple(bucket["handles"])
bucket["item"] = item
rows.append({c: bucket.get(c, "") for c in chosen} | {"handles": bucket["handles"]})
return tuple(rows)
def table_layout(
rows,
*,
at: Pt,
direction: str = "up",
columns=None,
row_height: float = ROW_HEIGHT,
):
"""Everything both renderers need, so the prims and the real TABLE agree.
`at` is the anchor: the list's bottom-left corner for `direction="up"` (it
sits on the title block) and its top-left corner for `direction="down"`.
`insert` is where `entity_create_table` must start, since a TABLE is drawn
downwards from its insertion point.
"""
if direction not in DIRECTIONS:
raise ValueError(f"direction must be one of {DIRECTIONS}, got {direction!r}")
chosen = _check_columns(columns)
if row_height <= 0:
raise ValueError(f"row_height must be positive, got {row_height!r}")
raw = [COLUMN_SHARES[c] for c in chosen]
scale = LIST_WIDTH / sum(raw)
widths = [w * scale for w in raw]
heading = [COLUMN_LABELS[c] for c in chosen]
body = [[str(row.get(c, "")) for c in chosen] for row in rows]
cells = list(reversed(body)) + [heading] if direction == "up" else [heading] + body
height = row_height * len(cells)
ax, ay = float(at[0]), float(at[1])
insert = (ax, ay + height) if direction == "up" else (ax, ay)
return {
"columns": chosen,
"labels": heading,
"widths": widths,
"width": sum(widths),
"height": height,
"row_height": row_height,
"direction": direction,
"anchor": (ax, ay),
"insert": insert,
"cells": cells,
}
def table_prims(
rows,
*,
at: Pt,
direction: str = "up",
standard: str = "ISO 7573",
height: float = ROW_HEIGHT,
) -> tuple[Prim, ...]:
"""The parts list as primitive descriptions, for a caller that does not
want a real TABLE (and for the tests that pin the layout)."""
if standard not in LIST_STANDARDS:
raise ValueError(
f"parts-list standard {standard!r} is not transcribed; "
f"available: {', '.join(LIST_STANDARDS)}"
)
layout = table_layout(rows, at=at, direction=direction, row_height=height)
ax, ay = layout["anchor"]
total_w, total_h = layout["width"], layout["height"]
prims: list[Prim] = []
for index in range(len(layout["cells"]) + 1):
y = ay + index * layout["row_height"]
prims.append(Line(p1=(ax, y), p2=(ax + total_w, y)))
x = ax
prims.append(Line(p1=(x, ay), p2=(x, ay + total_h)))
for width in layout["widths"]:
x += width
prims.append(Line(p1=(x, ay), p2=(x, ay + total_h)))
pad = min(1.0, layout["row_height"] * 0.15)
for row_index, cells in enumerate(layout["cells"]):
# cells are top-to-bottom; the top row is the last one up the sheet
baseline = ay + (len(layout["cells"]) - row_index - 1) * layout["row_height"] + pad
x = ax
for column_index, value in enumerate(cells):
if value:
prims.append(Text(at=(x + pad, baseline), text=value, height=TEXT_HEIGHT))
x += layout["widths"][column_index]
return tuple(prims)
def balloon_prims(
item: int,
at: Pt,
leader_to: Pt,
*,
radius: float = 4.0,
height: float | None = None,
) -> tuple[Prim, ...]:
"""One ISO 6433 item reference: the balloon, the leader, the dot, the numeral.
The numeral is 1.4 * `radius` high. ISO 6433 asks for at least twice the
dimension character height, so a sheet dimensioned at 3.5 mm wants
`radius=5.0` (a 7 mm numeral); the 4 mm default clears 2.5 mm dimensions.
"""
number = int(item)
if number <= 0:
raise ValueError(f"an ISO 6433 item reference is a positive integer; got {item!r}")
if radius <= 0:
raise ValueError(f"balloon radius must be positive; got {radius!r}")
text_height = float(radius) * 1.4 if height is None else float(height)
cx, cy = float(at[0]), float(at[1])
tx, ty = float(leader_to[0]), float(leader_to[1])
distance = math.hypot(tx - cx, ty - cy)
if distance <= radius:
raise ValueError(
f"balloon {number}: the leader target ({tx}, {ty}) is inside the balloon at "
f"({cx}, {cy}) r={radius}; ISO 6433 puts the reference outside the item"
)
ux, uy = (tx - cx) / distance, (ty - cy) / distance
label = str(number)
return (
Circle(center=(cx, cy), radius=float(radius)),
Line(p1=(cx + ux * radius, cy + uy * radius), p2=(tx, ty)),
Circle(center=(tx, ty), radius=BALLOON_DOT_RADIUS),
Text(
at=(cx - 0.3 * text_height * len(label), cy - 0.5 * text_height),
text=label,
height=text_height,
),
)
#: Block attributes read when an INSERT carries no ACADMCP_MECH payload.
_ATTRIBUTE_KEYS = {
"designation": ("DESIGNATION", "DESIG", "NAME", "TAG"),
"standard": ("STANDARD", "STD", "NORM"),
"material": ("MATERIAL", "MAT"),
"size": ("SIZE",),
"qty": ("QTY", "QUANTITY"),
"remarks": ("REMARKS", "NOTE"),
}
def _from_attributes(attributes: dict) -> dict:
upper = {str(k).upper(): v for k, v in (attributes or {}).items()}
record: dict = {}
for field, keys in _ATTRIBUTE_KEYS.items():
for key in keys:
if key in upper and str(upper[key]).strip():
record[field] = str(upper[key]).strip()
break
return record
async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int | None = None):
"""Walk the INSERTs and return one parts-list record per block reference.
Reads only: the entity listing, each INSERT's attributes and its
ACADMCP_MECH payload. Nothing is written, so this is safe on a drawing that
is not ours. `source` is "xdata" when the payload supplied the row and
"attributes" when the block's ATTRIBs did; a block with neither is skipped
rather than guessed at.
`limit` is None by default and the whole drawing is read, by paging
`entity_list` rather than taking its 200-entity default: a parts list that
stops at the 200th INSERT does not print a shorter list, it prints a wrong
QTY, and nothing on the sheet says so. A caller that wants a cap passes one
and reads `len(records)` back against it -- `bom_extract` asks for one more
record than the cap so it can report `truncated` exactly.
"""
if limit is not None and int(limit) <= 0:
raise ValueError(f"bom_extract: limit must be a positive number of records; got {limit!r}")
inserts = await all_entities(backend, type_filter="INSERT", layer=layer)
records: list[dict] = []
for entity in inserts:
payload: dict = {}
try:
raw = await backend.entity_get_xdata(entity.handle, APP_ID)
payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {}
except (ValueError, KeyError, TypeError):
payload = {}
if payload.get("kind") != "std_part":
payload = {}
# Measured on both engines: `block_get_attributes` returns the flat
# ``{tag: value}`` mapping itself, not ``{"attributes": {...}}``
# (ezdxf_backend.py:6687 builds it from ``ent.attribs``, com_backend.py:5703
# from ``ref.GetAttributes()``). Reading a wrapper key here would have
# made every attribute fallback silently empty.
attributes = {}
try:
attributes = await backend.block_get_attributes(entity.handle)
except Exception: # noqa: BLE001 - a block with no ATTRIBs is not an error here
attributes = {}
fallback = _from_attributes(attributes)
designation = str(payload.get("designation") or fallback.get("designation") or "").strip()
if not designation:
continue
records.append(
{
"handle": entity.handle,
"block": entity.properties.get("block_name", ""),
"layer": entity.layer,
"designation": designation,
"standard": str(payload.get("standard") or fallback.get("standard") or ""),
"size": str(payload.get("size") or fallback.get("size") or ""),
"material": str(payload.get("material") or fallback.get("material") or ""),
"mass": str(payload.get("mass") or ""),
"remarks": str(payload.get("remarks") or fallback.get("remarks") or ""),
"qty": int(payload.get("qty") or fallback.get("qty") or 1),
"source": "xdata" if payload else "attributes",
}
)
if limit is not None and len(records) >= int(limit):
break
return tuple(records)
async def draw_bom_table(
backend: AutoCADBackend,
rows,
*,
at: Pt,
direction: str = "up",
columns=None,
row_height: float = ROW_HEIGHT,
layer: str = SHEET_TEXT_LAYER,
layout: str | None = None,
) -> dict:
"""Draw the parts list as a real TABLE entity.
`entity_create_table` grows downwards from its insertion point, so an
upward-growing ISO 7573 list is drawn from an insertion one table height
above the anchor, with the heading as the last (bottom) row.
`representation` is reported rather than assumed: the live engine draws a
real ACAD_TABLE, while the headless one returns a *composite* whose
``handle`` is its first child rule (``tests/test_table_leader.py`` pins
that contract). A caller that means to edit the table afterwards has to
know which of the two it got.
"""
if not rows:
raise ValueError("bom_table: there are no parts-list rows to draw")
spec = table_layout(rows, at=at, direction=direction, columns=columns, row_height=row_height)
previous = await enter_layout(backend, layout, caller="bom_table")
try:
entity = await backend.entity_create_table(
spec["insert"][0],
spec["insert"][1],
spec["cells"],
headers=None,
column_widths=spec["widths"],
row_height=spec["row_height"],
text_height=TEXT_HEIGHT,
layer=layer,
)
finally:
if layout is not None:
await backend.layout_set_current(previous)
ax, ay = spec["anchor"]
return {
"ok": True,
"handle": entity.handle,
"entity_type": entity.type,
"representation": entity.properties.get("representation", "native"),
"child_handles": list(entity.properties.get("child_handles") or []),
"standard": "ISO 7573",
"direction": direction,
"columns": list(spec["columns"]),
"rows": len(rows),
"insert": spec["insert"],
"layout": layout or "Model",
"bbox": {"min": [ax, ay], "max": [ax + spec["width"], ay + spec["height"]]},
}
async def read_balloons(backend: AutoCADBackend):
"""Every ISO 6433 balloon this server drew in the CURRENT space, from its
ACADMCP_MECH payload.
Paged, not capped: a drawing whose first 200 CIRCLEs are a hole pattern --
routine -- used to read back no balloons at all, which made every guard in
`add_balloon` silently pass.
"""
found: list[dict] = []
for entity in await all_entities(backend, type_filter="CIRCLE"):
try:
raw = await backend.entity_get_xdata(entity.handle, APP_ID)
payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {}
except (ValueError, KeyError, TypeError):
continue
if payload.get("kind") != "balloon":
continue
found.append(
{
"handle": entity.handle,
"item": int(payload.get("item", 0)),
"targets": list(payload.get("targets") or []),
"text": payload.get("text", ""),
}
)
found.sort(key=lambda row: row["item"])
return tuple(found)
async def add_balloon(
backend: AutoCADBackend,
*,
item: int,
at: Pt,
leader_to: Pt,
targets=(),
radius: float = 4.0,
layer: str = "DIM",
layout: str | None = None,
) -> dict:
"""Draw (or renumber) one ISO 6433 item reference.
A balloon already pointing at the same targets is *renumbered* -- its
numeral is edited and its payload rewritten -- so running the balloon pass
again after the parts list is regrouped does not litter the sheet with
duplicates. An item number already on the sheet is refused before anything
is drawn unless it is that renumber: ISO 129-1 and ISO 6433 both want one
reference per item.
Both guards read the space the balloon is going ON. `read_balloons` lists
the current space, so reading it before `enter_layout` inspected Model
while the balloons lived on the layout -- and the layout is the sheet
workflow, so the guards were off exactly where they matter.
"""
number = int(item)
wanted = tuple(sorted(str(t) for t in targets))
prims = balloon_prims(number, at, leader_to, radius=radius) # validates first
previous = await enter_layout(backend, layout, caller="balloon_add")
try:
existing = await read_balloons(backend)
for balloon in existing:
if wanted and tuple(sorted(balloon["targets"])) == wanted:
if balloon["text"]:
await backend.entity_edit_text(balloon["text"], text=str(number))
payload = {
"v": PAYLOAD_VERSION,
"kind": "balloon",
"item": number,
"targets": list(wanted),
"text": balloon["text"],
}
await backend.entity_set_xdata(balloon["handle"], APP_ID, to_values(payload))
return {
"ok": True,
"renumbered": True,
"item": number,
"handle": balloon["handle"],
"text": balloon["text"],
"targets": list(wanted),
"layout": layout or "Model",
}
# Anything still carrying this number is a clash: the renumber arm above
# has already returned for the one balloon that stands for these
# targets. Comparing the target tuples here instead let two untargeted
# balloons -- `targets` is optional in the tool, so `() != ()` -- carry
# the same item number with no refusal at all.
clash = next((b for b in existing if b["item"] == number), None)
if clash is not None:
has_targets = f"targets {clash['targets']}" if clash["targets"] else "no targets"
raise ValueError(
f"item reference {number} is already used by balloon {clash['handle']} "
f"({has_targets}); ISO 6433 gives each item one reference. Pass that "
"balloon's targets to renumber it in place, or use a free number."
)
handles = await draw_sheet_prims(
backend, prims, layer_map={"visible": layer, "text": layer, "center": layer}
)
payload = {
"v": PAYLOAD_VERSION,
"kind": "balloon",
"item": number,
"targets": list(wanted),
"text": handles[3],
}
await backend.entity_set_xdata(handles[0], APP_ID, to_values(payload))
return {
"ok": True,
"renumbered": False,
"item": number,
"handle": handles[0],
"leader": handles[1],
"dot": handles[2],
"text": handles[3],
"targets": list(wanted),
"layout": layout or "Model",
}
finally:
if layout is not None:
await backend.layout_set_current(previous)
+81
View File
@@ -0,0 +1,81 @@
"""Parts-list rows to a file: CSV always, XLSX when openpyxl is installed.
CSV needs nothing but the standard library, so the deliverable is never blocked
-- which is exactly why `xlsx_write` can be a capability rather than a hard
dependency. The refusal names both the missing package and the format that
always works.
"""
from __future__ import annotations
import csv
from importlib.util import find_spec
from pathlib import Path
from backends.capability import UnsupportedCapabilityError
from engineering.sheet.bom import BOM_COLUMNS, COLUMN_LABELS, DEFAULT_COLUMNS
__all__ = ["EXTRACT_FORMATS", "write_rows", "xlsx_available"]
EXTRACT_FORMATS = ("csv", "xlsx")
def xlsx_available() -> bool:
"""Is openpyxl importable right now? Re-evaluated per call, so installing
the package changes the answer without a restart."""
return find_spec("openpyxl") is not None
def _prepare(rows, columns):
chosen = tuple(DEFAULT_COLUMNS if columns is None else columns)
unknown = [c for c in chosen if c not in BOM_COLUMNS]
if unknown:
raise ValueError(
f"unknown parts-list column(s) {', '.join(unknown)}; "
f"available: {', '.join(BOM_COLUMNS)}"
)
body = [[str(row.get(column, "")) for column in chosen] for row in rows]
if not body:
raise ValueError("data_extract: there are no parts-list rows to write")
return chosen, [COLUMN_LABELS[c] for c in chosen], body
def write_rows(rows, path: str, *, columns=None, fmt: str = "csv") -> dict:
"""Write the rows to `path`. Refuses before the file is created."""
if fmt not in EXTRACT_FORMATS:
raise ValueError(f"format must be one of {', '.join(EXTRACT_FORMATS)}; got {fmt!r}")
chosen, heading, body = _prepare(rows, columns)
if fmt == "xlsx" and not xlsx_available():
raise UnsupportedCapabilityError(
"xlsx_write",
"data_extract(format='xlsx') needs openpyxl, which is not installed. "
'Install it with: pip install openpyxl (or pip install -e ".[full]"), '
"or ask for format='csv', which always works and carries the same rows.",
)
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
if fmt == "csv":
with open(target, "w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(heading)
writer.writerows(body)
else:
from openpyxl import Workbook
book = Workbook()
sheet = book.active
sheet.title = "Parts list"
sheet.append(heading)
for row in body:
sheet.append(row)
book.save(target)
return {
"ok": True,
"path": str(target),
"format": fmt,
"columns": list(chosen),
"rows": len(body),
"bytes": target.stat().st_size,
}
+342
View File
@@ -0,0 +1,342 @@
"""ISO 5457 drawing sheets: sizes, margins, frame, zone grid, centring and trim marks.
SOURCE
------
Sheet sizes ISO 216:2007, A series, trimmed sizes in millimetres.
Frame and margins ISO 5457:1999, 4.2 -- the drawing frame that bounds the
drawing space leaves a 20 mm filing margin on the binding
edge and 10 mm on the other three, for every size A4 to A0.
Centring marks ISO 5457:1999, 4.3 -- four marks at the middle of each side
of the trimmed sheet, running from the trimmed edge to 5 mm
inside the frame.
Grid reference ISO 5457:1999, 4.4 -- the number of divisions along a side is
an even number obtained from the length of that side of the
frame divided by 50 mm. Columns are numbered from the left,
rows are lettered from the top.
Trimming marks ISO 5457:1999, 4.5 -- one mark in each corner of the trimmed
sheet, two overlapping 10 mm x 5 mm rectangles.
COVERAGE: A4, A3, A2, A1, A0. The B series, the elongated sizes (A3.1, A4.2,
...) and the untrimmed sizes are not transcribed; `sheet_size` refuses an
unknown name and lists the five it has.
NOT IMPLEMENTED, deliberately, so nobody reads more into the geometry than is
in it:
* ISO 5457's allowance for shorter *corner* fields in the grid. The divisions
drawn here are equal (frame side / n). The division COUNT -- the number a
zone reference quotes, and the thing that is visible on the sheet -- is the
standard's; the two end fields' boundary sits up to 5 mm from where a
corner-field layout would put it.
* The grid is drawn for A4 too, although ISO 5457 does not require one there.
Pass `zones=False`.
"""
from __future__ import annotations
import string
from typing import TYPE_CHECKING
from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"CENTRING_OVERSHOOT",
"EDGE_MARGIN",
"FILING_MARGIN",
"ORIENTATIONS",
"SHEETS",
"SHEET_LAYER",
"SHEET_ROLE_LAYER",
"SHEET_TEXT_LAYER",
"TRIM_MARK_LONG",
"TRIM_MARK_SHORT",
"ZONE_DIVISIONS",
"ZONE_MODULE",
"ZONE_TEXT_HEIGHT",
"centred_text",
"draw_sheet_frame",
"draw_sheet_prims",
"enter_layout",
"frame_metrics",
"frame_prims",
"sheet_size",
"zone_divisions",
]
#: Portrait width x height in millimetres (ISO 216). Landscape swaps the pair.
SHEETS: dict[str, tuple[float, float]] = {
"A4": (210.0, 297.0),
"A3": (297.0, 420.0),
"A2": (420.0, 594.0),
"A1": (594.0, 841.0),
"A0": (841.0, 1189.0),
}
FILING_MARGIN = 20.0
EDGE_MARGIN = 10.0
ZONE_MODULE = 50.0
ZONE_TEXT_HEIGHT = 3.5
CENTRING_OVERSHOOT = 5.0
TRIM_MARK_LONG = 10.0
TRIM_MARK_SHORT = 5.0
#: The sheet is not part geometry: borders, rules and marks go on TITLEBLOCK and
#: the lettering on TEXT, which is where `titleblock_apply_iso_a3` has always put
#: them and what `tests/test_engineering_titleblock.py` pins.
SHEET_LAYER = "TITLEBLOCK"
SHEET_TEXT_LAYER = "TEXT"
SHEET_ROLE_LAYER: dict[str, str] = {
"visible": SHEET_LAYER,
"center": SHEET_LAYER,
"text": SHEET_TEXT_LAYER,
}
#: (divisions along the long side, divisions along the short side), ISO 5457 4.4.
#: Each row reproduces the even-division rule applied to that side of the frame
#: -- `test_zone_division_counts_match_the_authored_table` asserts both, so the
#: table and the rule can never drift apart.
ZONE_DIVISIONS: dict[str, tuple[int, int]] = {
"A4": (6, 4),
"A3": (8, 6),
"A2": (12, 8),
"A1": (16, 12),
"A0": (24, 16),
}
ORIENTATIONS = ("landscape", "portrait")
_LETTERS = string.ascii_uppercase
def sheet_size(size: str, orientation: str = "landscape") -> tuple[float, float]:
"""Trimmed width x height in millimetres for a sheet name and orientation."""
key = str(size).strip().upper()
if key not in SHEETS:
raise ValueError(
f"ISO 5457 sheet {size!r} is not in the table; covered sizes: {', '.join(SHEETS)}"
)
if orientation not in ORIENTATIONS:
raise ValueError(f"orientation must be one of {ORIENTATIONS}, got {orientation!r}")
w, h = SHEETS[key]
return (h, w) if orientation == "landscape" else (w, h)
def zone_divisions(side: float) -> int:
"""ISO 5457 4.4: an even number of divisions, `side` / 50 mm rounded to it.
`round()` is banker's at an exact .5; no frame side of A4..A0 lands there
(the closest is A2 landscape's 400 / 100 = 4.0, which is exact), and a side
that did would round down -- the safer half of the ambiguity, because it
makes the fields longer rather than shorter.
"""
return max(int(2 * round(float(side) / (2.0 * ZONE_MODULE))), 2)
def frame_metrics(size: str, *, orientation: str = "landscape") -> dict:
"""Every number the frame is built from, for a caller that wants to place
geometry inside it without re-deriving the margins."""
w, h = sheet_size(size, orientation)
x0, y0 = FILING_MARGIN, EDGE_MARGIN
x1, y1 = w - EDGE_MARGIN, h - EDGE_MARGIN
columns = zone_divisions(x1 - x0)
rows = zone_divisions(y1 - y0)
if rows > len(_LETTERS):
raise ValueError(f"{size}: {rows} grid rows need more than {len(_LETTERS)} letters")
return {
"size": str(size).strip().upper(),
"orientation": orientation,
"sheet": [w, h],
"margins": {
"left": FILING_MARGIN,
"right": EDGE_MARGIN,
"top": EDGE_MARGIN,
"bottom": EDGE_MARGIN,
},
"frame": [x0, y0, x1, y1],
"drawing_area": [x1 - x0, y1 - y0],
"centre": [w / 2.0, h / 2.0],
"zones": {
"columns": columns,
"rows": rows,
"column_width": (x1 - x0) / columns,
"row_height": (y1 - y0) / rows,
"numbers": [str(i + 1) for i in range(columns)],
"letters": [_LETTERS[i] for i in range(rows)],
},
}
def _rect(x0: float, y0: float, x1: float, y1: float, role: str = "visible") -> Poly:
return Poly(points=((x0, y0), (x1, y0), (x1, y1), (x0, y1)), closed=True, role=role)
def centred_text(cx: float, cy: float, text: str, height: float) -> Text:
"""`entity_create_text` carries no alignment parameter, so centring is done
by offsetting the left-baseline insertion point: 0.6 * height of advance per
character (the txt.shx / ISO 3098 width factor) and half a cap height."""
return Text(at=(cx - 0.3 * height * len(text), cy - 0.5 * height), text=text, height=height)
def frame_prims(
size: str,
*,
orientation: str = "landscape",
zones: bool = True,
marks: bool = True,
) -> tuple[Prim, ...]:
"""The ISO 5457 sheet as primitive descriptions, sheet-local, with (0, 0) at
the lower-left corner of the trimmed sheet.
Order is pinned: [0] the trimmed sheet, [1] the drawing frame, then the zone
ticks, then the zone labels, then the four centring marks, then the eight
trimming rectangles.
"""
metrics = frame_metrics(size, orientation=orientation)
w, h = metrics["sheet"]
x0, y0, x1, y1 = metrics["frame"]
prims: list[Prim] = [_rect(0.0, 0.0, w, h), _rect(x0, y0, x1, y1)]
if zones:
zone = metrics["zones"]
cw, rh = zone["column_width"], zone["row_height"]
for i in range(1, zone["columns"]):
x = x0 + i * cw
prims.append(Line(p1=(x, y1), p2=(x, h)))
prims.append(Line(p1=(x, 0.0), p2=(x, y0)))
for j in range(1, zone["rows"]):
y = y0 + j * rh
prims.append(Line(p1=(0.0, y), p2=(x0, y)))
prims.append(Line(p1=(x1, y), p2=(w, y)))
for i, label in enumerate(zone["numbers"]):
cx = x0 + (i + 0.5) * cw
prims.append(centred_text(cx, y1 + EDGE_MARGIN / 2.0, label, ZONE_TEXT_HEIGHT))
prims.append(centred_text(cx, y0 / 2.0, label, ZONE_TEXT_HEIGHT))
for j, label in enumerate(zone["letters"]):
# ISO 5457 4.4: rows are lettered from the top.
cy = y1 - (j + 0.5) * rh
prims.append(centred_text(FILING_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT))
prims.append(centred_text(x1 + EDGE_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT))
if marks:
cx, cy = metrics["centre"]
prims.append(Line(p1=(0.0, cy), p2=(x0 + CENTRING_OVERSHOOT, cy), role="center"))
prims.append(Line(p1=(w, cy), p2=(x1 - CENTRING_OVERSHOOT, cy), role="center"))
prims.append(Line(p1=(cx, 0.0), p2=(cx, y0 + CENTRING_OVERSHOOT), role="center"))
prims.append(Line(p1=(cx, h), p2=(cx, y1 - CENTRING_OVERSHOOT), role="center"))
for sx, ox in ((1.0, 0.0), (-1.0, w)):
for sy, oy in ((1.0, 0.0), (-1.0, h)):
prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_LONG, oy + sy * TRIM_MARK_SHORT))
prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_SHORT, oy + sy * TRIM_MARK_LONG))
return tuple(prims)
async def enter_layout(backend: AutoCADBackend, layout: str | None, *, caller: str) -> str:
"""Make `layout` current and return the space to restore afterwards.
A sheet border frames the printed sheet rather than the model, so every
sheet tool takes a layout; asking for a border must not silently leave the
caller standing on it, which is why the caller's space comes back.
"""
if layout is None:
return "Model"
listing = await backend.layout_list()
names = {str(entry) for entry in (listing.get("layouts") or [])}
if layout not in names:
raise RuntimeError(
f"{caller}: layout {layout!r} does not exist "
f"(have: {', '.join(sorted(names))}). Create it with layout_create first."
)
# The caller's tab comes from the listing that was just read -- both engines
# report it there (ezdxf from $TILEMODE / the active layout, COM from
# ``doc.ActiveLayout.Name``). A private attribute would not: only
# EzdxfBackend has ``_current_space``, so reading it left a live caller
# standing on Model after every border.
previous = str(listing.get("current") or "Model")
switched = await backend.layout_set_current(layout)
if not switched.get("ok", True):
raise RuntimeError(f"{caller}: {switched.get('error')}")
return previous
async def draw_sheet_prims(
backend: AutoCADBackend,
prims,
*,
origin: Pt = (0.0, 0.0),
layer_map: dict | None = None,
) -> list[str]:
"""Draw sheet primitives at `origin` and return their handles, in order."""
ox, oy = float(origin[0]), float(origin[1])
layers = dict(SHEET_ROLE_LAYER if layer_map is None else layer_map)
handles: list[str] = []
for prim in prims:
layer = layers.get(prim.role, SHEET_LAYER)
if isinstance(prim, Poly):
entity = await backend.entity_create_polyline(
points=[[ox + px, oy + py] for px, py in prim.points],
closed=prim.closed,
layer=layer,
)
elif isinstance(prim, Line):
entity = await backend.entity_create_line(
ox + prim.p1[0], oy + prim.p1[1], ox + prim.p2[0], oy + prim.p2[1], layer=layer
)
elif isinstance(prim, Circle):
entity = await backend.entity_create_circle(
ox + prim.center[0], oy + prim.center[1], prim.radius, layer=layer
)
elif isinstance(prim, Text):
entity = await backend.entity_create_text(
text=prim.text,
x=ox + prim.at[0],
y=oy + prim.at[1],
height=prim.height,
rotation=prim.rotation,
layer=layer,
)
else:
raise ValueError(
f"sheet primitives are Line, Circle, Poly and Text; got {type(prim).__name__}"
)
handles.append(entity.handle)
return handles
async def draw_sheet_frame(
backend: AutoCADBackend,
size: str,
*,
orientation: str = "landscape",
zones: bool = True,
marks: bool = True,
origin: Pt = (0.0, 0.0),
layout: str | None = None,
) -> dict:
"""Draw the ISO 5457 frame. Every refusal fires before the first entity."""
metrics = frame_metrics(size, orientation=orientation)
prims = frame_prims(size, orientation=orientation, zones=zones, marks=marks)
previous = await enter_layout(backend, layout, caller="sheet_frame")
try:
handles = await draw_sheet_prims(backend, prims, origin=origin)
finally:
if layout is not None:
await backend.layout_set_current(previous)
ox, oy = float(origin[0]), float(origin[1])
w, h = metrics["sheet"]
return {
"ok": True,
"size": metrics["size"],
"orientation": orientation,
"layout": layout or "Model",
"zones": zones,
"marks": marks,
"handles": handles,
"outer_border": handles[0],
"inner_border": handles[1],
"metrics": metrics,
"bbox": {"min": [ox, oy], "max": [ox + w, oy + h]},
}
+396
View File
@@ -0,0 +1,396 @@
"""The revision block, revision clouds and the triangular revision tags.
SOURCE
------
ISO 7200:2004 lists `version` (the revision code) among the title block's data
fields, and a drawing that has been revised records each version with its
description, date and issuer. ISO 7200 does NOT standardise the revision
block's geometry, so the block here reuses the ISO 7573 item-list ruling: the
same 180 mm width as the title block and the same 7 mm rows. That is a layout
decision, and it is written down here rather than asserted as a standard.
The triangular revision tag and the revision cloud are drawing-office practice,
not ISO geometry: the cloud marks the changed region and the triangle carries
the revision code that the block's row explains. `entity_create_revcloud` draws
the cloud, so a revision *with* clouds is refused up front on an engine that
has no revcloud (the COM backend: `revcloud` is false there, no ActiveX member,
command-line only) rather than half-written.
Placement: the block defaults to the upper-right corner of the drawing frame
and grows downwards, so it does not fight the ISO 7573 item list for the space
directly above the title block.
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING
from backends.xdata_specs import MAX_BYTES, app_size
from engineering.mech.primitives import Line, Poly, Prim, Pt, Text
from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT, TEXT_HEIGHT, all_entities
from engineering.sheet.frames import (
SHEET_LAYER,
draw_sheet_prims,
enter_layout,
frame_metrics,
)
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"REVISION_COLUMNS",
"REVISION_LABELS",
"REVISION_SHARES",
"REV_APP_ID",
"REV_PAYLOAD_VERSION",
"REV_RE",
"TAG_SIDE",
"add_revision",
"read_revisions",
"rev_payload",
"rev_values",
"revision_block_prims",
"revision_heading_prims",
"revision_row_prims",
"revision_tag_prims",
]
REVISION_COLUMNS: tuple[str, ...] = ("rev", "description", "date", "by")
REVISION_LABELS: dict[str, str] = {
"rev": "REV",
"description": "DESCRIPTION",
"date": "DATE",
"by": "BY",
}
#: Widths in millimetres; they total the 180 mm the title block is wide.
REVISION_SHARES: dict[str, float] = {
"rev": 16.0,
"description": 96.0,
"date": 40.0,
"by": 28.0,
}
TAG_SIDE = 7.0
TAG_TEXT_HEIGHT = 3.5
#: A revision code: one to three capitals or digits (ISO 7200's `version`
#: field is free text; this is the shop's form and keeps the tag legible).
REV_RE = re.compile(r"^[A-Z0-9]{1,3}$")
def _check_rev(rev: str) -> str:
code = str(rev).strip().upper()
if not REV_RE.fullmatch(code):
raise ValueError(
f"revision code {rev!r} must be one to three capitals or digits (A, B, 01, ...)"
)
return code
def revision_tag_prims(rev: str, at: Pt, *, side: float = TAG_SIDE) -> tuple[Prim, ...]:
"""An equilateral triangle carrying the revision code, apex up, `at` its
lower-left corner."""
code = _check_rev(rev)
if side <= 0:
raise ValueError(f"revision tag side must be positive; got {side!r}")
x, y = float(at[0]), float(at[1])
height = side * (3.0**0.5) / 2.0
triangle = Poly(
points=((x, y), (x + side, y), (x + side / 2.0, y + height)),
closed=True,
)
label = Text(
at=(x + side / 2.0 - 0.3 * TAG_TEXT_HEIGHT * len(code), y + height * 0.25),
text=code,
height=TAG_TEXT_HEIGHT,
)
return (triangle, label)
def _band(cells: dict, *, ax: float, top: float, height: float) -> list[Prim]:
"""One horizontal band: its bottom rule, its five verticals and its text.
The band's top rule belongs to the band above it (or to
`revision_heading_prims`), so bands stack without drawing a rule twice --
which is what lets `add_revision` append one band at a time and still
produce the block `revision_block_prims` describes.
"""
bottom = top - height
widths = [REVISION_SHARES[c] for c in REVISION_COLUMNS]
prims: list[Prim] = [Line(p1=(ax, bottom), p2=(ax + LIST_WIDTH, bottom))]
x = ax
prims.append(Line(p1=(x, top), p2=(x, bottom)))
for width in widths:
x += width
prims.append(Line(p1=(x, top), p2=(x, bottom)))
pad = min(1.0, height * 0.15)
x = ax
for column_index, column in enumerate(REVISION_COLUMNS):
value = str(cells.get(column, ""))
if value:
prims.append(Text(at=(x + pad, bottom + pad), text=value, height=TEXT_HEIGHT))
x += widths[column_index]
return prims
def revision_heading_prims(*, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]:
"""The block's top rule and its heading band. `at` is the TOP-left corner."""
if height <= 0:
raise ValueError(f"row height must be positive; got {height!r}")
ax, ay = float(at[0]), float(at[1])
heading = {c: REVISION_LABELS[c] for c in REVISION_COLUMNS}
return (
Line(p1=(ax, ay), p2=(ax + LIST_WIDTH, ay)),
*_band(heading, ax=ax, top=ay, height=height),
)
def revision_row_prims(
row: dict, *, at: Pt, index: int, height: float = ROW_HEIGHT
) -> tuple[Prim, ...]:
"""One revision band, `index` rows below the heading. Its first Text is the
REV cell -- the primitive `add_revision` hangs the payload on."""
if height <= 0:
raise ValueError(f"row height must be positive; got {height!r}")
if int(index) < 0:
raise ValueError(f"row index must be zero or more; got {index!r}")
ax, ay = float(at[0]), float(at[1])
return tuple(_band(row, ax=ax, top=ay - (int(index) + 1) * height, height=height))
#: The revision row's own XDATA application.
#:
#: `engineering/mech/xdata.py::KINDS` is pinned to ("part", "std_part",
#: "balloon") and `engineering/mech/critique.py` scans ACADMCP_MECH for exactly
#: those three on INSERTs and CIRCLEs. A revision row is neither, so it carries
#: its own application rather than a fourth kind that `encode` would refuse.
#: The chunking is the same rule the mech codec follows -- ASCII JSON split at
#: 255 characters -- and `entity_set_xdata(handle, app_name, values)` supplies
#: the 1001 application tag itself, so only the chunks travel.
REV_APP_ID = "ACADMCP_SHEET_REV"
REV_PAYLOAD_VERSION = 1
_REV_CHUNK = 255
def rev_values(payload: dict) -> list[str]:
"""The chunk strings for one revision record. Refused before anything is written."""
if not isinstance(payload, dict):
raise TypeError(f"{REV_APP_ID}: a payload must be a dict, got {type(payload).__name__}")
version = payload.get("v", REV_PAYLOAD_VERSION)
if version != REV_PAYLOAD_VERSION:
raise ValueError(
f"{REV_APP_ID}: payload version {version!r}; this build writes and reads "
f"version {REV_PAYLOAD_VERSION}"
)
text = json.dumps(
{**payload, "v": REV_PAYLOAD_VERSION},
separators=(",", ":"),
sort_keys=True,
ensure_ascii=True,
)
values = [text[i : i + _REV_CHUNK] for i in range(0, len(text), _REV_CHUNK)]
size = app_size(REV_APP_ID, [(1000, value) for value in values])
if size > MAX_BYTES:
raise ValueError(
f"{REV_APP_ID}: the record encodes to {size} bytes; the limit is 16 KB per "
"entity across every application -- shorten the description"
)
return values
def rev_payload(values) -> dict:
"""The record back, or a ValueError. Never a plausible-looking guess."""
parts = [value if isinstance(value, str) else str(value[1]) for value in values]
try:
payload = json.loads("".join(parts))
except json.JSONDecodeError as exc:
raise ValueError(f"{REV_APP_ID}: the chunk stream is not JSON ({exc.msg})") from exc
if not isinstance(payload, dict):
raise ValueError(
f"{REV_APP_ID}: the payload decoded to {type(payload).__name__}, not a dict"
)
if payload.get("v") != REV_PAYLOAD_VERSION:
raise ValueError(f"{REV_APP_ID}: payload version {payload.get('v')!r} is not readable here")
return payload
def revision_block_prims(rows, *, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]:
"""The whole revision block: `at` is its TOP-left corner, the heading is
the first band and the revisions follow downwards in the order given."""
prims: list[Prim] = list(revision_heading_prims(at=at, height=height))
for index, row in enumerate(rows):
prims.extend(revision_row_prims(row, at=at, index=index, height=height))
return tuple(prims)
async def read_revisions(backend: AutoCADBackend):
"""Every revision row this server wrote in the CURRENT space, from its
ACADMCP_SHEET_REV payload.
Paged, not capped: `entity_list`'s 200-entity default made a lettered
drawing read back no revisions at all, which both broke the idempotency
guard and restarted `index = len(existing)` at 0 -- a second heading band
and a second row drawn exactly on top of the first.
"""
found: list[dict] = []
for entity in await all_entities(backend, type_filter="TEXT"):
try:
raw = await backend.entity_get_xdata(entity.handle, REV_APP_ID)
payload = rev_payload(raw.get("xdata", {}).get(REV_APP_ID, [])) or {}
except (ValueError, KeyError, TypeError):
continue
if not payload.get("rev"):
continue
found.append(
{
"handle": entity.handle,
"rev": str(payload.get("rev", "")),
"description": str(payload.get("description", "")),
"date": str(payload.get("date", "")),
"by": str(payload.get("by", "")),
}
)
found.sort(key=lambda row: row["rev"])
return tuple(found)
async def add_revision(
backend: AutoCADBackend,
*,
rev: str,
description: str,
date: str = "",
by: str = "",
clouds=(),
tags=(),
size: str = "A3",
orientation: str = "landscape",
origin: Pt = (0.0, 0.0),
at: Pt | None = None,
cloud_segment: float = 8.0,
layout: str | None = None,
) -> dict:
"""Append one revision row, and optionally cloud and tag what changed.
Idempotent per revision code: a code the drawing already carries returns
`created=False` and draws nothing at all, so re-running a revision pass
cannot stack duplicate rows. Refusals fire before the first entity: a
malformed code, a cloud region that is not four finite numbers, and -- on
an engine whose `revcloud` capability is false -- any request for clouds.
The idempotency guard reads the space the row is going ON. `read_revisions`
lists the current space, so reading it before `enter_layout` inspected
Model while the block lived on the layout, and every re-run of a
`layout=`-scoped revision pass stacked a second heading band and a second
row exactly on top of the first.
"""
code = _check_rev(rev)
regions = [[float(v) for v in region] for region in clouds]
for index, region in enumerate(regions):
if len(region) != 4:
raise ValueError(f"clouds[{index}] must be [x0, y0, x1, y1]; got {clouds[index]!r}")
points = [[float(p[0]), float(p[1])] for p in tags]
if regions:
feature = backend.capabilities().features.get("revcloud")
if feature is None or not feature.supported:
from backends.base import UnsupportedCapabilityError
raise UnsupportedCapabilityError(
"revcloud",
f"add_revision: the {backend.name} backend cannot draw revision clouds "
"(no ActiveX member; REVCLOUD is command-line only). Re-run without "
"`clouds` to record the row, or draw the sheet on the ezdxf engine.",
)
previous = await enter_layout(backend, layout, caller="revision_add")
try:
existing = await read_revisions(backend)
match = next((row for row in existing if row["rev"] == code), None)
if match is not None:
return {
"ok": True,
"created": False,
"rev": code,
"handle": match["handle"],
"row": len(existing),
"clouds": [],
"tags": [],
"layout": layout or "Model",
}
index = len(existing)
new_row = {"rev": code, "description": description, "date": date, "by": by}
metrics = frame_metrics(size, orientation=orientation)
_x0, _y0, x1, y1 = metrics["frame"]
anchor = (
(float(origin[0]) + x1 - LIST_WIDTH, float(origin[1]) + y1)
if at is None
else (float(at[0]), float(at[1]))
)
# Append-only: the heading band is drawn once, on the first revision, and
# every later revision adds exactly one band below the last. Nothing is
# redrawn, so a revision can never stack a second copy of the block.
head_prims = list(revision_heading_prims(at=anchor)) if index == 0 else []
row_prims = list(revision_row_prims(new_row, at=anchor, index=index))
block_handles = await draw_sheet_prims(backend, head_prims + row_prims)
cloud_handles = []
for region in regions:
x0, y0, x1r, y1r = region
cloud = await backend.entity_create_revcloud(
[[x0, y0], [x1r, y0], [x1r, y1r], [x0, y1r]],
cloud_segment,
layer=SHEET_LAYER,
closed=True,
)
cloud_handles.append(cloud.get("handle"))
# One entry per tag *asked for*, not per entity drawn: a tag is two
# primitives (the triangle and its letter), so a flat handle list would
# report twice as many tags as the caller requested.
tag_handles = []
for point in points:
drawn = await draw_sheet_prims(backend, revision_tag_prims(code, (point[0], point[1])))
tag_handles.append({"at": [point[0], point[1]], "handles": drawn})
# The payload rides on the row band's first Text primitive, which is its
# REV cell -- `rev` is validated non-empty, so that cell is always drawn.
row_handles = block_handles[len(head_prims) :]
row_handle = next(
handle
for handle, prim in zip(row_handles, row_prims, strict=True)
if isinstance(prim, Text)
)
await backend.entity_set_xdata(
row_handle,
REV_APP_ID,
rev_values(
{
"v": REV_PAYLOAD_VERSION,
"rev": code,
"description": description,
"date": date,
"by": by,
}
),
)
return {
"ok": True,
"created": True,
"rev": code,
"handle": row_handle,
"row": index + 1,
"block_handles": block_handles,
"clouds": cloud_handles,
"tags": tag_handles,
"layout": layout or "Model",
"bbox": {
"min": [anchor[0], anchor[1] - ROW_HEIGHT * (index + 2)],
"max": [anchor[0] + LIST_WIDTH, anchor[1]],
},
}
finally:
if layout is not None:
await backend.layout_set_current(previous)
+376
View File
@@ -0,0 +1,376 @@
"""ISO 7200 title blocks for every ISO 5457 sheet from A4 to A0.
SOURCE
------
Width ISO 7200:2004 -- the title block is 180 mm wide and sits in the
lower right-hand corner of the drawing space, against the
frame. 180 mm is exactly the drawing-space width of a portrait
A4, which is why one block fits every size.
Data fields ISO 7200:2004 -- the mandatory fields are the legal owner
(`company`), the identification number (`drawing_no`), the
date of issue (`date`), the segment/sheet number (`sheet`) and
the title (`title`). The rest carried here are optional
fields: creator (`drawn_by`), approval person (`checked_by`),
version (`revision`), and the supplementary item data this
shop draws (`part_no`, `material`, `scale`, `units`). ISO
7200's full list of optional fields is NOT transcribed -- only
the fields this block has a cell for.
Projection angle The truncated-cone symbol of ISO 5456-2 / ISO 128-30. ISO's
own figure is paywalled and was NOT read here; the arrangement
is transcribed from reproductions of it, measured, and named
in `projection_symbol_prims`.
The row heights (20 / 15 / 15 / 10 mm) and the cell splits are this
repository's layout, not ISO 7200 values: the standard fixes the *data*, not
the ruling. They are exactly the ones `titleblock_apply_iso_a3` has drawn since
v1.0, so the A3 sheet this server makes has not moved.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING
from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text
from engineering.sheet.frames import (
draw_sheet_prims,
enter_layout,
frame_metrics,
frame_prims,
)
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"LABEL_HEIGHT",
"LABEL_PAD_X",
"LABEL_PAD_Y",
"PROJECTION_ANGLES",
"ROW1_H",
"ROW2_H",
"ROW3_H",
"ROW4_H",
"ROW4_VALUE_PAD_Y",
"TB_HEIGHT",
"TB_WIDTH",
"TITLEBLOCK_FIELDS",
"TITLEBLOCK_RULE_COUNT",
"TITLE_HEIGHT",
"TITLE_TEXT_INDEX",
"VALUE_FIELDS",
"VALUE_HEIGHT",
"VALUE_PAD_X",
"VALUE_PAD_Y",
"TitleBlockMetadata",
"apply_titleblock",
"projection_symbol_prims",
"titleblock_origin",
"titleblock_prims",
"value_text_indices",
]
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
LABEL_PAD_X = 1.5
LABEL_PAD_Y = 1.5
VALUE_PAD_X = 3.0
VALUE_PAD_Y = 5.0
ROW4_VALUE_PAD_Y = 1.5
PROJECTION_ANGLES = ("first", "third")
PROJECTION_CONE_LENGTH = 8.0
PROJECTION_R = 4.0
PROJECTION_SMALL_R = 2.0
PROJECTION_GAP = 4.0
#: Field -> the label printed in its cell, in the order the cells are drawn.
VALUE_FIELDS: tuple[tuple[str, str], ...] = (
("drawing_no", "DWG NO"),
("revision", "REV"),
("sheet", "SHEET"),
("part_no", "PART NO"),
("material", "MATERIAL"),
("scale", "SCALE"),
("drawn_by", "DRAWN"),
("checked_by", "CHECKED"),
("date", "DATE"),
("company", "COMPANY"),
)
#: Every ISO 7200 data field this block carries. `units` has no cell of its own
#: (the sheet is millimetres and the drawing says so), but it round-trips in the
#: payload so a reader is never told the drawing is unitless.
TITLEBLOCK_FIELDS: tuple[str, ...] = (
"title",
"drawing_no",
"part_no",
"material",
"scale",
"units",
"drawn_by",
"checked_by",
"date",
"sheet",
"revision",
"company",
)
#: The block frame plus the ten rules, at the head of the primitive tuple.
TITLEBLOCK_RULE_COUNT = 11
TITLE_TEXT_INDEX = 11
@dataclass
class TitleBlockMetadata:
"""ISO 7200 data fields. Strings, written verbatim -- no 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"
def titleblock_origin(size: str, *, orientation: str = "landscape") -> Pt:
"""Lower-left corner of the title block, sheet-local."""
metrics = frame_metrics(size, orientation=orientation)
x0, y0, x1, _y1 = metrics["frame"]
if x1 - x0 < TB_WIDTH - 1e-9:
raise ValueError(
f"{metrics['size']} {orientation}: the drawing area is {x1 - x0:.1f} mm wide, "
f"narrower than the ISO 7200 title block ({TB_WIDTH:.0f} mm)"
)
return (x1 - TB_WIDTH, y0)
def projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]:
"""The ISO 5456-2 / ISO 128-30 projection-angle symbol, centred on `at`.
ARRANGEMENT -- transcribed, not derived. The symbol is a truncated cone
drawn in two views: the cone in elevation (a trapezoid) and the end view
(two concentric circles). Which side the end view sits on is what makes the
symbol mean *first* or *third* angle, and it is a convention that has to be
read off the standard's figure: the projection rule alone cannot pick it,
because the same picture is a legal drawing of the mirrored object seen
from the other side.
ISO 5456-2's own figure is behind ISO's paywall and was not read. Two
independent reproductions of it were downloaded and measured instead, and
they agree on every landmark used here:
* FreeCAD's ISO 5457 sheet template, first-angle symbol
(`src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg`,
ids `first_angle_trapezoid` / `first_angle_*_circle`): the trapezoid is
`m 389,222 -10,2.5 v 5 l 10,2.5 z` -- a 5-unit side at x = 379 and a
10-unit side at x = 389 -- and the circles (r 2.5 and r 5) are at
cx = 396. Short side LEFT; end view to the RIGHT of the cone.
* Wikimedia Commons `Convention placement vues dessin technique.svg`, which
draws both symbols side by side, labelled FR and US. FR (first angle):
trapezoid 35.856 units tall at x = 13.343 and 66.135 at x = 81.869,
circles at x = 148.005. US (third angle): trapezoid 35.856 at
x = 380.275 and 66.135 at x = 448.801, circles at x = 314.140.
So, in both symbols alike, the trapezoid's SHORT side is on the left; only
the end view changes side:
first angle cone, then the circles to its RIGHT
third angle the circles, then the cone to their RIGHT
which is the same thing as the rule the standard is usually quoted by --
the short side points AWAY from the circles in first angle and TOWARDS
them in third. `tests/test_sheet_titleblock.py` pins the ordering against
the measured figures above, so this cannot be "fixed" back by eye.
The proportions (large circle diameter 8, small 4, cone 8 mm long) are this
module's; ISO 5456-2's proportion table is not transcribed. They keep the
1:2 small-to-large ratio both reference figures' circles have.
"""
if angle not in PROJECTION_ANGLES:
raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}")
cx, cy = float(at[0]), float(at[1])
half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0
# The end view sits right of the cone in first angle, left of it in third.
sign = 1.0 if angle == "first" else -1.0
circles_x = (half - PROJECTION_R) * sign
# The trapezoid itself is the same picture in both symbols: short side left.
cone_left = -half if angle == "first" else half - PROJECTION_CONE_LENGTH
cone_right = cone_left + PROJECTION_CONE_LENGTH
cone = Poly(
points=(
(cx + cone_left, cy - PROJECTION_SMALL_R),
(cx + cone_right, cy - PROJECTION_R),
(cx + cone_right, cy + PROJECTION_R),
(cx + cone_left, cy + PROJECTION_SMALL_R),
),
closed=True,
)
axis = Line(p1=(cx - half - 1.0, cy), p2=(cx + half + 1.0, cy), role="center")
return (
cone,
Circle(center=(cx + circles_x, cy), radius=PROJECTION_R),
Circle(center=(cx + circles_x, cy), radius=PROJECTION_SMALL_R),
axis,
)
def value_text_indices() -> dict[str, int]:
"""Field -> its index in the tuple `titleblock_prims` returns.
The cells are drawn label-then-value, starting right after the title, so
the value of the i-th field is at TITLE_TEXT_INDEX + 2i + 2.
"""
return {field: TITLE_TEXT_INDEX + 2 * i + 2 for i, (field, _label) in enumerate(VALUE_FIELDS)}
def titleblock_prims(
size: str,
fields,
*,
orientation: str = "landscape",
projection: str | None = "first",
) -> tuple[Prim, ...]:
"""The title block as primitive descriptions, sheet-local.
Order is pinned and read back by `value_text_indices`: the block frame, the
ten rules, the title, then (label, value) per cell in `VALUE_FIELDS` order,
then the four projection-symbol primitives when `projection` is not None.
"""
meta = fields if isinstance(fields, TitleBlockMetadata) else TitleBlockMetadata(**fields)
if projection is not None and projection not in PROJECTION_ANGLES:
raise ValueError(
f"projection must be one of {PROJECTION_ANGLES} or None, got {projection!r}"
)
tb_x0, tb_y0 = titleblock_origin(size, orientation=orientation)
tb_x1, tb_y1 = tb_x0 + TB_WIDTH, tb_y0 + TB_HEIGHT
row4_top = tb_y0 + ROW4_H
row3_top = row4_top + ROW3_H
row2_top = row3_top + ROW2_H
r2_split1 = tb_x0 + TB_WIDTH * 0.5
r2_split2 = tb_x0 + TB_WIDTH * 0.75
r3_split1 = tb_x0 + TB_WIDTH / 3.0
r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0
r4_split1 = tb_x0 + TB_WIDTH * 0.25
r4_split2 = tb_x0 + TB_WIDTH * 0.50
r4_split3 = tb_x0 + TB_WIDTH * 0.75
prims: list[Prim] = [
Poly(
points=((tb_x0, tb_y0), (tb_x1, tb_y0), (tb_x1, tb_y1), (tb_x0, tb_y1)),
closed=True,
),
Line(p1=(tb_x0, row4_top), p2=(tb_x1, row4_top)),
Line(p1=(tb_x0, row3_top), p2=(tb_x1, row3_top)),
Line(p1=(tb_x0, row2_top), p2=(tb_x1, row2_top)),
Line(p1=(r2_split1, row3_top), p2=(r2_split1, row2_top)),
Line(p1=(r2_split2, row3_top), p2=(r2_split2, row2_top)),
Line(p1=(r3_split1, row4_top), p2=(r3_split1, row3_top)),
Line(p1=(r3_split2, row4_top), p2=(r3_split2, row3_top)),
Line(p1=(r4_split1, tb_y0), p2=(r4_split1, row4_top)),
Line(p1=(r4_split2, tb_y0), p2=(r4_split2, row4_top)),
Line(p1=(r4_split3, tb_y0), p2=(r4_split3, row4_top)),
]
title_x = tb_x0 + TB_WIDTH * 0.5 - (len(meta.title) * TITLE_HEIGHT * 0.3)
title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0
prims.append(Text(at=(title_x, title_y), text=meta.title, height=TITLE_HEIGHT))
# field -> (cell left edge, the row's top rule, the value's baseline)
cells: dict[str, tuple[float, float, float]] = {
"drawing_no": (tb_x0, row2_top, row3_top + VALUE_PAD_Y),
"revision": (r2_split1, row2_top, row3_top + VALUE_PAD_Y),
"sheet": (r2_split2, row2_top, row3_top + VALUE_PAD_Y),
"part_no": (tb_x0, row3_top, row4_top + VALUE_PAD_Y),
"material": (r3_split1, row3_top, row4_top + VALUE_PAD_Y),
"scale": (r3_split2, row3_top, row4_top + VALUE_PAD_Y),
"drawn_by": (tb_x0, row4_top, tb_y0 + ROW4_VALUE_PAD_Y),
"checked_by": (r4_split1, row4_top, tb_y0 + ROW4_VALUE_PAD_Y),
"date": (r4_split2, row4_top, tb_y0 + ROW4_VALUE_PAD_Y),
"company": (r4_split3, row4_top, tb_y0 + ROW4_VALUE_PAD_Y),
}
for field, label in VALUE_FIELDS:
left, row_top, value_y = cells[field]
prims.append(
Text(
at=(left + LABEL_PAD_X, row_top - LABEL_HEIGHT - LABEL_PAD_Y),
text=label,
height=LABEL_HEIGHT,
)
)
prims.append(
Text(at=(left + VALUE_PAD_X, value_y), text=getattr(meta, field), height=VALUE_HEIGHT)
)
if projection is not None:
prims.extend(projection_symbol_prims((tb_x0 + 15.0, row2_top + ROW1_H / 2.0), projection))
return tuple(prims)
async def apply_titleblock(
backend: AutoCADBackend,
*,
size: str = "A3",
metadata: TitleBlockMetadata,
origin: Pt = (0.0, 0.0),
orientation: str = "landscape",
projection: str | None = "first",
frame: bool = True,
zones: bool = False,
marks: bool = False,
layout: str | None = None,
) -> dict:
"""Draw the ISO 5457 frame (optionally) and the ISO 7200 title block.
`projection=None`, `zones=False` and `marks=False` reproduce exactly what
`titleblock_apply_iso_a3` has always drawn. Every refusal -- an unknown
size, an unknown orientation, an unknown projection angle, a missing layout
-- fires before the first entity reaches the drawing.
"""
metrics = frame_metrics(size, orientation=orientation)
tb_prims = titleblock_prims(size, metadata, orientation=orientation, projection=projection)
sheet_prims = (
frame_prims(size, orientation=orientation, zones=zones, marks=marks) if frame else ()
)
previous = await enter_layout(backend, layout, caller="titleblock_apply")
try:
frame_handles = await draw_sheet_prims(backend, sheet_prims, origin=origin)
tb_handles = await draw_sheet_prims(backend, tb_prims, origin=origin)
finally:
if layout is not None:
await backend.layout_set_current(previous)
ox, oy = float(origin[0]), float(origin[1])
w, h = metrics["sheet"]
symbol_start = TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS)
return {
"ok": True,
"size": metrics["size"],
"orientation": orientation,
"layout": layout or "Model",
"projection": projection,
"outer_border": frame_handles[0] if frame else None,
"inner_border": frame_handles[1] if frame else None,
"frame_handles": frame_handles,
"titleblock_lines": tb_handles[:TITLEBLOCK_RULE_COUNT],
"title_text": tb_handles[TITLE_TEXT_INDEX],
"value_texts": {f: tb_handles[i] for f, i in value_text_indices().items()},
"projection_symbol": tb_handles[symbol_start:],
"metadata": asdict(metadata),
"bbox": {"min": [ox, oy], "max": [ox + w, oy + h]},
}
+73 -289
View File
@@ -1,57 +1,71 @@
"""ISO 7200 / A3 (420 x 297 mm) title block generator.
"""ISO 7200 / A3 title block -- the historical entry point.
Deterministic — the title text is written verbatim from metadata.
The layout moved to `engineering.sheet.titleblock`, which draws every ISO 5457
size from A4 to A0. What is left here is the A3 alias: `apply_iso_a3_titleblock`
forwards to `apply_titleblock(size="A3", projection=None, zones=False,
marks=False)`, which is bit-for-bit the geometry this module drew before -- no
projection-angle symbol, no zone grid, no trimming marks -- so every caller and
every test that pinned it keeps working. The module-level constants are
re-exported for the same reason.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING
from engineering.sheet.frames import (
EDGE_MARGIN,
FILING_MARGIN,
SHEET_LAYER,
SHEET_TEXT_LAYER,
sheet_size,
)
from engineering.sheet.titleblock import (
LABEL_HEIGHT,
ROW1_H,
ROW2_H,
ROW3_H,
ROW4_H,
TB_HEIGHT,
TB_WIDTH,
TITLE_HEIGHT,
VALUE_HEIGHT,
TitleBlockMetadata,
apply_titleblock,
)
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"BOTTOM_MARGIN",
"LABEL_HEIGHT",
"LAYER_BORDER",
"LAYER_TEXT",
"LEFT_MARGIN",
"RIGHT_MARGIN",
"ROW1_H",
"ROW2_H",
"ROW3_H",
"ROW4_H",
"SHEET_H",
"SHEET_W",
"TB_HEIGHT",
"TB_WIDTH",
"TITLE_HEIGHT",
"TOP_MARGIN",
"VALUE_HEIGHT",
"TitleBlockMetadata",
"apply_iso_a3_titleblock",
]
# 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"
SHEET_W, SHEET_H = sheet_size("A3", "landscape")
LEFT_MARGIN = FILING_MARGIN
RIGHT_MARGIN = EDGE_MARGIN
TOP_MARGIN = EDGE_MARGIN
BOTTOM_MARGIN = EDGE_MARGIN
LAYER_BORDER = SHEET_LAYER
LAYER_TEXT = SHEET_TEXT_LAYER
async def apply_iso_a3_titleblock(
@@ -63,255 +77,25 @@ async def apply_iso_a3_titleblock(
) -> 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
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.
Equivalent to `titleblock_apply(size="A3")` with the projection-angle
symbol, the zone grid and the trimming marks switched off. Reach for
`titleblock_apply` for any other size, or for the symbol.
"""
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,
return await apply_titleblock(
backend,
size="A3",
metadata=metadata,
origin=origin,
orientation="landscape",
projection=None,
frame=True,
zones=False,
marks=False,
layout=layout,
)
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],
},
}