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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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]},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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],
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user