merge: track F group C

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 23:05:27 +03:00
co-authored by Claude Opus 5.5
12 changed files with 2069 additions and 9 deletions
+34
View File
@@ -6,6 +6,16 @@ and alphabetical - keep every side's names.
from __future__ import annotations
from .catalogue import (
CATALOGUE_NAMES,
FAMILIES,
ITEMS,
SIZE_BASIS,
CatalogueItem,
block_spec,
catalogue,
insert_item,
)
from .dimension import (
FIRST_OFFSET_PAPER_MM,
ROWS,
@@ -57,6 +67,15 @@ from .stairs import (
blondel,
stair_prims,
)
from .symbols import (
SYMBOL_KINDS,
draw_grid,
draw_symbol,
fmt_level,
grid_axes,
grid_prims,
symbol_prims,
)
from .walls import (
MIN_JOIN_ANGLE_DEG,
WallGeometry,
@@ -75,9 +94,13 @@ __all__ = [
"ARCH_ROLE_LAYER",
"ARROW_PAPER_MM",
"BLONDEL_RANGE",
"CATALOGUE_NAMES",
"CatalogueItem",
"DEFAULT_TOL",
"FAMILIES",
"FIRST_OFFSET_PAPER_MM",
"Face",
"ITEMS",
"KINDS",
"LANGS",
"MIN_JOIN_ANGLE_DEG",
@@ -86,8 +109,10 @@ __all__ = [
"Room",
"SCHEDULE_KINDS",
"SIDES",
"SIZE_BASIS",
"STAIR_KINDS",
"STEP_PAPER_MM",
"SYMBOL_KINDS",
"Stair",
"TEXT_PAPER_MM",
"TURNS",
@@ -96,17 +121,25 @@ __all__ = [
"Wall",
"WallGeometry",
"axis_segments",
"block_spec",
"blondel",
"catalogue",
"decode",
"detect_rooms",
"draw_grid",
"draw_schedule",
"draw_symbol",
"encode",
"exterior_chains",
"face_containing",
"face_offsets",
"fmt_area_m2",
"fmt_level",
"fmt_number",
"from_payload",
"grid_axes",
"grid_prims",
"insert_item",
"label_room",
"opening_closures",
"opening_from_dict",
@@ -121,6 +154,7 @@ __all__ = [
"segment_of",
"stair_from_dict",
"stair_prims",
"symbol_prims",
"tag_prefix",
"tagged_faces",
"to_payload",
+525
View File
@@ -0,0 +1,525 @@
"""The furniture and sanitary catalogue: authored outlines in, real blocks out.
The standard-parts pattern of track B (``engineering/mech/stdparts.py``)
applied to an architectural plan: :func:`catalogue` searches the items by
name, family and their English or Turkish label; :func:`block_spec` turns one
item into the typed primitives and the ATTDEF ``block_define`` takes;
:func:`insert_item` defines the block ``ARCH_<NAME>`` once, inserts it on the
furniture or sanitary layer and reports what it did.
**Sizes are nominal catalogue dimensions, not standards values.** A 900 x 2000
single bed or a 600 x 600 washing machine is what a furniture or appliance
catalogue lists as a common nominal size; no standard fixes it, and every row
says so in ``SIZE_BASIS``. The outlines are plan symbols drawn inside that
footprint, not manufacturer drawings.
Block-local frame, the same for every item: the origin is the **back-left
corner**, the back (the side that stands against a wall) runs along +X from the
origin, and the item extends towards +Y. So ``rotation=0`` puts an item's back
on a wall that runs along +X below it, and every primitive lies inside
``[0, w] x [0, d]`` - a test holds each item to that.
Block content is on layer ``0`` (colour and linetype ByBlock, which is how
``block_define`` writes them), so an INSERT takes the look of the layer it is
inserted on: ``ARCH_ROLE_LAYER["furniture"]`` or ``ARCH_ROLE_LAYER["sanitary"]``.
"""
from __future__ import annotations
import difflib
import math
from typing import TYPE_CHECKING, NamedTuple
from engineering.arch.lang import LANGS
from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
if TYPE_CHECKING:
from backends.base import AutoCADBackend
FAMILIES: tuple[str, ...] = ("furniture", "sanitary")
#: What every size in this module is - stated in each row and in the tool docstring.
SIZE_BASIS = "nominal catalogue dimensions (w x d, mm) - not a standards value"
BLOCK_PREFIX = "ARCH_"
#: The one ATTDEF every catalogue block carries: the item name, invisible, so a
#: count or a data extract can read what the INSERT is without parsing its name.
ITEM_TAG = "ITEM"
ITEM_TAG_HEIGHT = 100.0
# -- block_define spec helpers (block-local mm, layer 0) ---------------------
def _line(x1: float, y1: float, x2: float, y2: float) -> dict:
return {
"type": "line",
"x1": float(x1),
"y1": float(y1),
"x2": float(x2),
"y2": float(y2),
"layer": "0",
}
def _circle(cx: float, cy: float, r: float) -> dict:
return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r), "layer": "0"}
def _arc(cx: float, cy: float, r: float, start_deg: float, end_deg: float) -> dict:
return {
"type": "arc",
"cx": float(cx),
"cy": float(cy),
"r": float(r),
"start_deg": float(start_deg),
"end_deg": float(end_deg),
"layer": "0",
}
def _poly(points, closed: bool) -> dict:
return {
"type": "polyline",
"points": [[float(x), float(y)] for x, y in points],
"closed": bool(closed),
"layer": "0",
}
def _rect(x0: float, y0: float, x1: float, y1: float) -> dict:
return _poly(((x0, y0), (x1, y0), (x1, y1), (x0, y1)), True)
# -- the items ---------------------------------------------------------------
class CatalogueItem(NamedTuple):
name: str
family: str
label_en: str
label_tr: str
w: float
d: float
entities: tuple[dict, ...]
def _chair(x0: float, y0: float, back_at_top: bool) -> tuple[dict, ...]:
"""A 450 x 450 chair seat with its back rail 60 mm in from the back edge."""
back_y = y0 + 390.0 if back_at_top else y0 + 60.0
return (_rect(x0, y0, x0 + 450.0, y0 + 450.0), _line(x0, back_y, x0 + 450.0, back_y))
ITEMS: tuple[CatalogueItem, ...] = (
# -- furniture -----------------------------------------------------------
CatalogueItem(
"single_bed",
"furniture",
"single bed",
"tek kişilik yatak",
900.0,
2000.0,
(_rect(0, 0, 900, 2000), _rect(100, 50, 800, 450), _line(0, 700, 900, 700)),
),
CatalogueItem(
"double_bed",
"furniture",
"double bed",
"çift kişilik yatak",
1600.0,
2000.0,
(
_rect(0, 0, 1600, 2000),
_rect(100, 50, 750, 450),
_rect(850, 50, 1500, 450),
_line(0, 700, 1600, 700),
),
),
CatalogueItem(
"wardrobe",
"furniture",
"wardrobe",
"gardırop",
1200.0,
600.0,
(_rect(0, 0, 1200, 600), _line(50, 300, 1150, 300)),
),
CatalogueItem(
"sofa_3_seat",
"furniture",
"sofa, three seats",
"üçlü kanepe",
2200.0,
900.0,
(
_rect(0, 0, 2200, 900),
_line(200, 0, 200, 900),
_line(2000, 0, 2000, 900),
_line(200, 200, 2000, 200),
_line(800, 200, 800, 900),
_line(1400, 200, 1400, 900),
),
),
CatalogueItem(
"armchair",
"furniture",
"armchair",
"berjer koltuk",
900.0,
900.0,
(
_rect(0, 0, 900, 900),
_line(150, 0, 150, 900),
_line(750, 0, 750, 900),
_line(150, 200, 750, 200),
),
),
CatalogueItem(
"chair",
"furniture",
"chair",
"sandalye",
450.0,
450.0,
_chair(0.0, 0.0, back_at_top=False),
),
CatalogueItem(
"dining_table_4",
"furniture",
"dining table with four chairs",
"dört kişilik yemek masası",
1400.0,
1700.0,
(
_rect(0, 450, 1400, 1250),
*_chair(125.0, 0.0, back_at_top=False),
*_chair(825.0, 0.0, back_at_top=False),
*_chair(125.0, 1250.0, back_at_top=True),
*_chair(825.0, 1250.0, back_at_top=True),
),
),
CatalogueItem(
"desk",
"furniture",
"desk",
"çalışma masası",
1400.0,
700.0,
(_rect(0, 0, 1400, 700), _line(1000, 0, 1000, 700)),
),
CatalogueItem(
"kitchen_counter",
"furniture",
"kitchen counter run",
"mutfak tezgâhı",
2400.0,
600.0,
(
_rect(0, 0, 2400, 600),
_line(600, 0, 600, 600),
_line(1200, 0, 1200, 600),
_line(1800, 0, 1800, 600),
),
),
CatalogueItem(
"fridge",
"furniture",
"fridge",
"buzdolabı",
600.0,
650.0,
(_rect(0, 0, 600, 650), _line(0, 600, 600, 600)),
),
CatalogueItem(
"bookshelf",
"furniture",
"bookshelf",
"kitaplık",
800.0,
300.0,
(_rect(0, 0, 800, 300), _line(0, 0, 800, 300)),
),
CatalogueItem(
"nightstand",
"furniture",
"nightstand",
"komodin",
500.0,
400.0,
(_rect(0, 0, 500, 400),),
),
CatalogueItem(
"coffee_table",
"furniture",
"coffee table",
"orta sehpa",
1100.0,
600.0,
(_rect(0, 0, 1100, 600),),
),
# -- sanitary ------------------------------------------------------------
CatalogueItem(
"wc",
"sanitary",
"wc with cistern",
"klozet",
380.0,
700.0,
(
_rect(0, 0, 380, 180),
_line(40, 180, 40, 530),
_line(340, 180, 340, 530),
_arc(190, 530, 150, 0, 180),
),
),
CatalogueItem(
"bidet",
"sanitary",
"bidet",
"bide",
360.0,
560.0,
(
_line(0, 0, 360, 0),
_line(0, 0, 0, 380),
_line(360, 0, 360, 380),
_arc(180, 380, 180, 0, 180),
_circle(180, 80, 20),
_circle(180, 380, 20),
),
),
CatalogueItem(
"wall_basin",
"sanitary",
"wall basin",
"lavabo",
550.0,
450.0,
(
_poly(((0, 175), (0, 0), (550, 0), (550, 175)), False),
_arc(275, 175, 275, 0, 180),
_circle(275, 225, 150),
_circle(275, 225, 20),
_circle(275, 50, 15),
),
),
CatalogueItem(
"shower_tray",
"sanitary",
"shower tray",
"duş teknesi",
900.0,
900.0,
(_rect(0, 0, 900, 900), _rect(50, 50, 850, 850), _circle(450, 450, 50)),
),
CatalogueItem(
"bathtub",
"sanitary",
"bathtub",
"küvet",
1700.0,
700.0,
(_rect(0, 0, 1700, 700), _rect(60, 60, 1640, 640), _circle(200, 350, 25)),
),
CatalogueItem(
"kitchen_sink",
"sanitary",
"kitchen sink with drainer",
"eviye",
1000.0,
500.0,
(
_rect(0, 0, 1000, 500),
_rect(50, 50, 450, 450),
_circle(250, 250, 30),
_line(550, 150, 950, 150),
_line(550, 250, 950, 250),
_line(550, 350, 950, 350),
),
),
CatalogueItem(
"washing_machine",
"sanitary",
"washing machine",
"çamaşır makinesi",
600.0,
600.0,
(_rect(0, 0, 600, 600), _circle(300, 300, 200)),
),
)
CATALOGUE_NAMES: tuple[str, ...] = tuple(item.name for item in ITEMS)
_BY_NAME: dict[str, CatalogueItem] = {item.name: item for item in ITEMS}
#: Turkish letters folded to ASCII so "buzdolabi" finds "buzdolabı" and a
#: query typed on an English keyboard still reaches the Turkish label.
_FOLD = str.maketrans(
{"ç": "c", "ğ": "g", "ı": "i", "ö": "o", "ş": "s", "ü": "u", "â": "a", "î": "i", "û": "u"}
)
def _fold(text: str) -> str:
"""Lower-case and fold Turkish letters. ``İ`` is mapped before ``lower()``,
which would otherwise turn it into ``i`` plus a combining dot."""
return " ".join(str(text).replace("İ", "i").lower().translate(_FOLD).split())
def _check_lang(lang: str) -> str:
if lang not in LANGS:
raise ValueError(f"lang must be one of {', '.join(LANGS)}, got {lang!r}.")
return lang
def _row(item: CatalogueItem, lang: str) -> dict:
return {
"name": item.name,
"family": item.family,
"label": item.label_tr if lang == "tr" else item.label_en,
"label_en": item.label_en,
"label_tr": item.label_tr,
"size": [item.w, item.d],
"size_basis": SIZE_BASIS,
"block": f"{BLOCK_PREFIX}{item.name.upper()}",
"layer": ARCH_ROLE_LAYER[item.family],
}
def catalogue(
query: str | None = None, family: str | None = None, *, lang: str = "en"
) -> tuple[dict, ...]:
"""Every catalogue item, optionally filtered.
``family`` is ``furniture`` or ``sanitary`` (anything else is refused with
the list). ``query`` is split into words and every word must occur in the
item's name, family, English label or Turkish label; matching ignores case
and Turkish diacritics, so ``'bed'``, ``'yatak'``, ``'KLOZET'`` and
``'buzdolabi'`` all work. ``lang`` picks the ``label`` column.
"""
_check_lang(lang)
if family is not None and family not in FAMILIES:
raise ValueError(f"family must be one of {', '.join(FAMILIES)}, got {family!r}.")
words = _fold(query).split() if query else []
rows: list[dict] = []
for item in ITEMS:
if family is not None and item.family != family:
continue
haystack = _fold(
f"{item.name.replace('_', ' ')} {item.family} {item.label_en} {item.label_tr}"
)
if all(word in haystack for word in words):
rows.append(_row(item, lang))
return tuple(rows)
def _resolve(name: str) -> CatalogueItem:
key = "_".join(str(name).replace("-", " ").lower().split())
item = _BY_NAME.get(key)
if item is not None:
return item
close = difflib.get_close_matches(key, CATALOGUE_NAMES, n=3, cutoff=0.4)
hint = (
f"nearest: {', '.join(close)}"
if close
else f"the catalogue holds {', '.join(CATALOGUE_NAMES)}"
)
raise ValueError(f"arch catalogue: no item {name!r}; {hint}.")
def block_spec(name: str) -> dict:
"""The typed primitives and the ITEM ATTDEF ``block_define`` takes for one item.
Returns ``{"name", "entities", "attdefs", "base_x", "base_y", "item",
"family", "size", "size_basis"}``. An unknown name is refused with the
nearest catalogue names before anything is built.
"""
item = _resolve(name)
return {
"name": f"{BLOCK_PREFIX}{item.name.upper()}",
"entities": [dict(spec) for spec in item.entities],
"attdefs": [
{
"tag": ITEM_TAG,
"prompt": "Catalogue item",
"default": item.name,
"x": item.w / 2.0,
"y": item.d / 2.0,
"height": ITEM_TAG_HEIGHT,
"align": "middle_center",
"invisible": True,
}
],
"base_x": 0.0,
"base_y": 0.0,
"item": item.name,
"family": item.family,
"size": [item.w, item.d],
"size_basis": SIZE_BASIS,
}
async def _ensure_layer(backend: AutoCADBackend, name: str) -> bool:
"""Create the INSERT's layer from its ``ARCH_LAYERS`` row when the drawing lacks it.
Live ActiveX refuses ``entity.Layer = "X"`` on a missing layer (the
repository rule in CLAUDE.md); the headless engine would create it
silently with no colour or lineweight. Returns whether it was created.
"""
if name.lower() in {layer.name.lower() for layer in await backend.layer_list()}:
return False
row = next((row for row in ARCH_LAYERS if row[0] == name), None)
color, linetype, lineweight = (row[1], row[2], row[3]) if row else (7, "Continuous", 0.25)
await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight)
return True
async def insert_item(backend: AutoCADBackend, name: str, *, at, rotation: float = 0.0) -> dict:
"""Define ``ARCH_<NAME>`` once, insert it at ``at`` turned by ``rotation``.
``at`` is the WCS point the item's back-left corner lands on; ``rotation``
is degrees CCW about it. Refusals - an unknown item (with the nearest
names), a non-finite coordinate or rotation - fire before any write.
"""
spec = block_spec(name)
try:
x, y = float(at[0]), float(at[1])
except (TypeError, IndexError, ValueError) as exc:
raise ValueError(f"arch catalogue: 'at' must be two numbers, got {at!r}.") from exc
if not (math.isfinite(x) and math.isfinite(y)):
raise ValueError(f"arch catalogue: 'at' must be two finite numbers, got {at!r}.")
angle = float(rotation)
if not math.isfinite(angle):
raise ValueError(f"arch catalogue: rotation must be finite, got {rotation!r}.")
layer = ARCH_ROLE_LAYER[spec["family"]]
layer_created = await _ensure_layer(backend, layer)
existing = {block.name.upper() for block in await backend.block_list()}
defined = spec["name"].upper() not in existing
if defined:
await backend.block_define(
spec["name"],
spec["entities"],
spec["attdefs"],
spec["base_x"],
spec["base_y"],
False,
False,
)
ref = await backend.block_insert(
spec["name"], x, y, 1.0, 1.0, angle, {ITEM_TAG: spec["item"]}, layer
)
return {
"ok": True,
"handle": ref.handle,
"block_name": spec["name"],
"defined": defined,
"item": spec["item"],
"family": spec["family"],
"layer": layer,
"layer_created": layer_created,
"at": [x, y],
"rotation": angle,
"size": spec["size"],
"size_basis": SIZE_BASIS,
"backend": backend.name,
}
+421
View File
@@ -0,0 +1,421 @@
"""The structural grid and the architectural symbols - pure primitives.
:func:`grid_prims` draws the axis lines of a structural grid on the ``grid``
role (whose layer carries the CENTER linetype, so no primitive sets one) with a
bubble and its label at both ends: numbers along x, letters along y, unless
``labels`` names them. :func:`symbol_prims` draws the four plan symbols: the
north arrow, the section mark, the level mark and the elevation mark.
Every size here is **paper millimetres times ``scale``** (the plot-scale
denominator: 50 means 1:50), so a 5 mm bubble is 250 drawing units at 1:50 and
reads 5 mm on the printed sheet. The paper sizes below are this module's own
drafting choices, declared as such - no standard in this repository fixes a grid
bubble's diameter or a north arrow's length.
Bubbles, labels and symbols go on the ``symbol`` role, not on ``grid``: a
bubble on the grid layer would inherit CENTER and print as a dashed circle.
Labels are centred with ``engineering.sheet.frames.centred_text`` (the
repository's one centring rule: 0.6 x height advance per character), because
``entity_create_text`` carries no alignment.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from engineering.arch.lang import fmt_number, vocab
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text
from engineering.sheet.frames import centred_text
if TYPE_CHECKING:
from backends.base import AutoCADBackend
# -- declared paper sizes (mm on the printed sheet; multiplied by `scale`) -----
BUBBLE_RADIUS = 5.0
LABEL_HEIGHT = 3.5
NORTH_RADIUS = 8.0
SECTION_ARROW_LENGTH = 6.0
SECTION_ARROW_HALF_WIDTH = 2.0
LEVEL_TRIANGLE = 2.5
LEVEL_LINE = 15.0
LEVEL_TEXT_HEIGHT = 2.5
LEVEL_TEXT_GAP = 1.0
SYMBOL_KINDS: tuple[str, ...] = ("north_arrow", "section_mark", "level", "elevation_mark")
SECTION_SIDES: tuple[str, ...] = ("left", "right")
#: An elevation mark's direction in drawing terms, not compass terms: "up" is
#: +Y on the sheet whatever the north arrow says.
ELEVATION_DIRECTIONS: dict[str, float] = {"right": 0.0, "up": 90.0, "left": 180.0, "down": 270.0}
_PARAMS: dict[str, tuple[str, ...]] = {
"north_arrow": ("rotation",),
"section_mark": ("p1", "p2", "label", "side"),
"level": ("value",),
"elevation_mark": ("label", "directions"),
}
def _finite(value, where: str) -> float:
try:
number = float(value)
except (TypeError, ValueError):
raise ValueError(f"{where}: expected a number, got {value!r}.") from None
if not math.isfinite(number):
raise ValueError(f"{where}: must be finite, got {value!r}.")
return number
def _positive(value, where: str) -> float:
number = _finite(value, where)
if number <= 0.0:
raise ValueError(f"{where}: must be greater than zero, got {number:g}.")
return number
def _point(value, where: str) -> Pt:
try:
x, y = value
except (TypeError, ValueError):
raise ValueError(f"{where}: expected an [x, y] pair, got {value!r}.") from None
return (_finite(x, f"{where}.x"), _finite(y, f"{where}.y"))
def _label(value, where: str) -> str:
text = " ".join(str(value).split()) if value is not None else ""
if not text:
raise ValueError(f"{where}: a label must be a non-empty string.")
return text
def _centred(cx: float, cy: float, text: str, height: float) -> Text:
return centred_text(cx, cy, text, height)._replace(role="symbol")
def _turn(vx: float, vy: float, deg: float) -> Pt:
c = math.cos(math.radians(deg))
s = math.sin(math.radians(deg))
return (vx * c - vy * s, vx * s + vy * c)
# -- the structural grid -----------------------------------------------------
def _letters(index: int) -> str:
"""0 -> A ... 25 -> Z, 26 -> AA: every letter, none skipped."""
out = ""
n = index + 1
while n:
n, rest = divmod(n - 1, 26)
out = chr(ord("A") + rest) + out
return out
def grid_axes(x_axes, y_axes, *, labels=None) -> dict:
"""The grid's axes sorted by position, each with its label.
``labels`` is ``{"x": [...], "y": [...]}`` (either key optional), one label
per axis in the order the positions were given. Refused by path: an empty
axis list, a non-finite or duplicate position, a label count that does not
match, a duplicate or empty label, an unknown ``labels`` key.
"""
out: dict[str, list[dict]] = {}
if labels is not None and not isinstance(labels, dict):
raise ValueError(f"labels: expected {{'x': [...], 'y': [...]}}, got {labels!r}.")
given = {} if labels is None else dict(labels)
unknown = sorted(set(given) - {"x", "y"})
if unknown:
raise ValueError(f"labels: unknown key(s) {', '.join(unknown)}; keys are x, y.")
for key, positions, default in (
("x", x_axes, lambda i: str(i + 1)),
("y", y_axes, _letters),
):
where = f"{key}_axes"
values = [_finite(v, f"{where}[{i}]") for i, v in enumerate(positions or ())]
if not values:
raise ValueError(f"{where}: a grid needs at least one axis in each direction.")
names = given.get(key)
if names is None:
order = sorted(range(len(values)), key=lambda i: values[i])
names_sorted = [default(rank) for rank in range(len(values))]
pairs = [(values[i], names_sorted[rank]) for rank, i in enumerate(order)]
else:
if not isinstance(names, (list, tuple)):
raise ValueError(f"labels.{key}: expected a list of labels, got {names!r}.")
if len(names) != len(values):
raise ValueError(
f"labels.{key}: {len(names)} label(s) for {len(values)} axis position(s)."
)
texts = [_label(n, f"labels.{key}[{i}]") for i, n in enumerate(names)]
if len(set(texts)) != len(texts):
raise ValueError(f"labels.{key}: every axis label must be unique, got {texts}.")
pairs = sorted(zip(values, texts, strict=True), key=lambda pair: pair[0])
for (a, _), (b, _) in zip(pairs, pairs[1:], strict=False):
if abs(b - a) <= 1e-9:
raise ValueError(f"{where}: two axes at the same position {a:g}.")
out[key] = [{"label": text, "position": value} for value, text in pairs]
return out
def grid_prims(
x_axes, y_axes, *, labels=None, extension: float = 1500.0, scale: float = 50
) -> tuple[Prim, ...]:
"""Axis lines on ``grid`` with a labelled bubble at both ends of each.
``x_axes`` are the x positions of the vertical axes (numbered 1, 2, 3 ...
from left to right), ``y_axes`` the y positions of the horizontal axes
(lettered A, B, C ... from bottom to top). Every axis runs ``extension``
drawing units past the outermost crossing axis; the bubble (radius
``BUBBLE_RADIUS * scale``) sits beyond that, touching the line's end.
Per axis the primitives are: the line, then for each end its bubble and
its label.
"""
ext = _positive(extension, "extension")
k = _positive(scale, "scale")
axes = grid_axes(x_axes, y_axes, labels=labels)
r = BUBBLE_RADIUS * k
h = LABEL_HEIGHT * k
xs = [axis["position"] for axis in axes["x"]]
ys = [axis["position"] for axis in axes["y"]]
y_lo, y_hi = min(ys) - ext, max(ys) + ext
x_lo, x_hi = min(xs) - ext, max(xs) + ext
prims: list[Prim] = []
for axis in axes["x"]:
x = axis["position"]
prims.append(Line((x, y_lo), (x, y_hi), "grid"))
for cy in (y_lo - r, y_hi + r):
prims.append(Circle((x, cy), r, "symbol"))
prims.append(_centred(x, cy, axis["label"], h))
for axis in axes["y"]:
y = axis["position"]
prims.append(Line((x_lo, y), (x_hi, y), "grid"))
for cx in (x_lo - r, x_hi + r):
prims.append(Circle((cx, y), r, "symbol"))
prims.append(_centred(cx, y, axis["label"], h))
return tuple(prims)
# -- the four symbols ----------------------------------------------------------
def _north_arrow(at: Pt, k: float, lang: str, rotation=0.0) -> tuple[Prim, ...]:
"""A circle, an arrow whose tip is north, the north letter beyond the tip.
``rotation`` is degrees CCW from the sheet's +Y: 0 puts north straight up.
The letter stays upright; only its position turns with the arrow.
"""
turn = _finite(rotation, "north_arrow.rotation")
big_r = NORTH_RADIUS * k
h = LABEL_HEIGHT * k
ax, ay = at
local = (
(0.0, big_r),
(-0.4 * big_r, -0.6 * big_r),
(0.0, -0.3 * big_r),
(0.4 * big_r, -0.6 * big_r),
)
arrow = tuple((ax + dx, ay + dy) for dx, dy in (_turn(x, y, turn) for x, y in local))
lx, ly = _turn(0.0, big_r + h, turn)
return (
Circle((ax, ay), big_r, "symbol"),
Poly(arrow, True, "symbol"),
_centred(ax + lx, ay + ly, vocab(lang)["north"], h),
)
def _section_mark(at: Pt, k: float, lang: str, p1=None, p2=None, label="A", side="left"):
"""The cut line, a labelled bubble beyond each end, an arrow at each end.
``p1`` and ``p2`` are relative to ``at`` (so ``at=(0, 0)`` makes them WCS).
``side`` is the side of the line the section looks towards, seen from
``p1`` looking at ``p2``.
"""
if p1 is None or p2 is None:
raise ValueError("section_mark: p1 and p2 are required (the two ends of the cut line).")
a = _point(p1, "section_mark.p1")
b = _point(p2, "section_mark.p2")
text = _label(label, "section_mark.label")
if side not in SECTION_SIDES:
raise ValueError(
f"section_mark.side: must be one of {', '.join(SECTION_SIDES)}, got {side!r}."
)
x1, y1 = at[0] + a[0], at[1] + a[1]
x2, y2 = at[0] + b[0], at[1] + b[1]
span = math.hypot(x2 - x1, y2 - y1)
r = BUBBLE_RADIUS * k
h = LABEL_HEIGHT * k
length = SECTION_ARROW_LENGTH * k
half = SECTION_ARROW_HALF_WIDTH * k
if span <= 4.0 * half:
raise ValueError(
f"section_mark: the cut line is {span:g} long; it must be longer than "
f"{4.0 * half:g} ({4.0 * SECTION_ARROW_HALF_WIDTH:g} mm at 1:{k:g}) so the two "
"arrows do not overlap."
)
ux, uy = (x2 - x1) / span, (y2 - y1) / span
dx, dy = (-uy, ux) if side == "left" else (uy, -ux)
prims: list[Prim] = [Line((x1, y1), (x2, y2), "symbol")]
for cx, cy in ((x1 - ux * r, y1 - uy * r), (x2 + ux * r, y2 + uy * r)):
prims.append(Circle((cx, cy), r, "symbol"))
prims.append(_centred(cx, cy, text, h))
for ex, ey, sign in ((x1, y1, 1.0), (x2, y2, -1.0)):
inner = (ex + sign * ux * 2.0 * half, ey + sign * uy * 2.0 * half)
mid = (ex + sign * ux * half, ey + sign * uy * half)
apex = (mid[0] + dx * length, mid[1] + dy * length)
prims.append(Poly(((ex, ey), inner, apex), True, "symbol"))
return tuple(prims)
def fmt_level(value: float, lang: str) -> str:
"""``±0.00`` at zero, ``+3.00`` above, ``-0.45`` below; ``tr`` writes a comma."""
number = _finite(value, "level.value")
if abs(number) < 0.005:
return "±" + fmt_number(0.0, lang, 2)
sign = "+" if number > 0 else "-"
return sign + fmt_number(abs(number), lang, 2)
def _level(at: Pt, k: float, lang: str, value=0.0) -> tuple[Prim, ...]:
"""A triangle whose apex is the level point, a line, the value above the line.
``value`` is in metres, as a level is written on a plan.
"""
text = fmt_level(value, lang)
t = LEVEL_TRIANGLE * k
ax, ay = at
gap = LEVEL_TEXT_GAP * k
return (
Poly(((ax, ay), (ax - t, ay + t), (ax + t, ay + t)), True, "symbol"),
Line((ax + t, ay + t), (ax + t + LEVEL_LINE * k, ay + t), "symbol"),
Text((ax + t + gap, ay + t + gap), text, LEVEL_TEXT_HEIGHT * k, 0.0, "symbol"),
)
def _elevation_mark(at: Pt, k: float, lang: str, label="1", directions=("up",)):
"""A circle, the label in it, and one pointer per viewing direction.
Each pointer is the two tangents from an apex at ``r * sqrt(2)`` along the
direction to the circle, touching it at the direction +/- 45 degrees.
"""
text = _label(label, "elevation_mark.label")
names = list(directions) if not isinstance(directions, str) else [directions]
if not names:
raise ValueError("elevation_mark.directions: name at least one direction.")
for i, name in enumerate(names):
if name not in ELEVATION_DIRECTIONS:
raise ValueError(
f"elevation_mark.directions[{i}]: must be one of "
f"{', '.join(ELEVATION_DIRECTIONS)}, got {name!r}."
)
if len(set(names)) != len(names):
raise ValueError(f"elevation_mark.directions: each direction once, got {names}.")
r = BUBBLE_RADIUS * k
ax, ay = at
prims: list[Prim] = [Circle((ax, ay), r, "symbol"), _centred(ax, ay, text, LABEL_HEIGHT * k)]
for name in names:
theta = ELEVATION_DIRECTIONS[name]
apex_r = r * math.sqrt(2.0)
apex = (
ax + apex_r * math.cos(math.radians(theta)),
ay + apex_r * math.sin(math.radians(theta)),
)
side_a = (
ax + r * math.cos(math.radians(theta - 45.0)),
ay + r * math.sin(math.radians(theta - 45.0)),
)
side_b = (
ax + r * math.cos(math.radians(theta + 45.0)),
ay + r * math.sin(math.radians(theta + 45.0)),
)
prims.append(Poly((side_a, apex, side_b), False, "symbol"))
return tuple(prims)
_BUILDERS = {
"north_arrow": _north_arrow,
"section_mark": _section_mark,
"level": _level,
"elevation_mark": _elevation_mark,
}
def symbol_prims(
kind: str, at: Pt, *, lang: str = "en", scale: float = 50, **params
) -> tuple[Prim, ...]:
"""One architectural symbol at ``at``, sized in paper mm times ``scale``.
``kind`` and its ``params``: ``north_arrow`` (``rotation``),
``section_mark`` (``p1``, ``p2``, ``label``, ``side``), ``level``
(``value`` in metres), ``elevation_mark`` (``label``, ``directions``).
Refused before anything is built: an unknown kind (with the list), a
parameter the kind does not take (with the ones it does), an unknown
``lang``, a non-finite or non-positive ``scale``.
"""
if kind not in SYMBOL_KINDS:
raise ValueError(f"unknown symbol kind {kind!r}; kinds are {', '.join(SYMBOL_KINDS)}.")
extra = sorted(set(params) - set(_PARAMS[kind]))
if extra:
raise ValueError(
f"{kind}: unknown parameter(s) {', '.join(extra)}; it takes {', '.join(_PARAMS[kind])}."
)
vocab(lang)
k = _positive(scale, "scale")
origin = _point(at, "at")
return _BUILDERS[kind](origin, k, lang, **params)
# -- the async layer: the prims through draw_prims ------------------------------
async def draw_grid(
backend: AutoCADBackend,
x_axes,
y_axes,
*,
labels=None,
extension: float = 1500.0,
scale: float = 50,
) -> dict:
"""Draw :func:`grid_prims` through ``draw_prims`` with the arch role map."""
from engineering.mech.draw import draw_prims
axes = grid_axes(x_axes, y_axes, labels=labels)
prims = grid_prims(x_axes, y_axes, labels=labels, extension=extension, scale=scale)
drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER)
return {
"ok": True,
"axes": axes,
"bubble_radius": BUBBLE_RADIUS * float(scale),
"handles": drawn["handles"],
"counts": drawn["counts"],
"layers": sorted({ARCH_ROLE_LAYER["grid"], ARCH_ROLE_LAYER["symbol"]}),
"backend": backend.name,
}
async def draw_symbol(
backend: AutoCADBackend,
kind: str,
at: Pt,
*,
lang: str = "en",
scale: float = 50,
**params,
) -> dict:
"""Draw :func:`symbol_prims` through ``draw_prims`` with the arch role map."""
from engineering.mech.draw import draw_prims
prims = symbol_prims(kind, at, lang=lang, scale=scale, **params)
drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER)
return {
"ok": True,
"kind": kind,
"at": [float(at[0]), float(at[1])],
"handles": drawn["handles"],
"counts": drawn["counts"],
"layer": ARCH_ROLE_LAYER["symbol"],
"backend": backend.name,
}