engineering/arch/symbols.py is pure: grid_prims() draws each structural axis on the grid role - its CENTER linetype comes from the A-GRID-E-N row, no primitive sets one - with a 5 mm (paper) bubble and its label touching both ends, on the symbol role so the circle does not print dashed. Numbers run along x and letters along y (A..Z, AA, ... none skipped) unless labels names them; positions are sorted, duplicates and label-count mismatches refused by path. symbol_prims() draws the four plan symbols: the north arrow (a circle, an arrow turned CCW from +Y, the vocab(lang) north letter upright beyond the tip), the section mark (the cut line, a labelled bubble past each end and a viewing arrow at each end, p1/p2 relative to the origin), the level mark (the triangle, its line and the value formatted +3.00 / ±0.00, with a decimal comma in Turkish) and the elevation mark (a circle whose pointers are the tangents from r*sqrt(2) touching it at +/-45 degrees). An unknown kind is refused with the list, a parameter the kind does not take with the ones it does. Every size is a declared paper-mm drafting choice times the plot scale, not a standards value. arch_grid and arch_symbol draw through draw_prims with ARCH_ROLE_LAYER; alias records (COLUMNGRIDADD, English and Turkish synonyms), a regenerated inventory, the README snapshot at 233 tools and the branch-local group snapshot (entity_creation 21) complete the registration. Beyond the plan: grid_axes refuses a labels value that is not a dict and a labels.x / labels.y that is not a list (a string was split into one label per character, a number raised a bare TypeError). No new ActiveX member: a live run on AutoCAD 2026 (scratch document, closed after) drew the grid and all four symbols through draw_prims - A-GRID-E-N created with CENTER, bubbles, texts and symbols on A-SYMB-T-N, the Turkish north letter K and the level "+3,00". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""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,
|
|
}
|