engineering/arch/schedule.py turns the plan records into schedule rows: doors by tag with width, height, swing, hand and wall; windows by tag with width, height, sill and wall; rooms by number with name and the measured area the label carries, plus a total. Tags compare as numbers (D2 before D10). An opening without a tag gets the next free D1 / W1 - K1 / P1 in Turkish - in wall order; an explicit tag is never renumbered and a generated one never repeats it. A value the model does not carry is an empty cell, never a default, and Turkish output writes decimal commas. draw_schedule reads the ACADMCP_ARCH records off the drawing through the one reader rooms.py already has and draws a real TABLE the way the ISO 7573 parts list does - top-left insertion, the table growing down, the parts list's 7 mm rows and 2.5 mm text at the plot scale, the engine's representation reported. Nothing to schedule, an unknown kind or language and more rows than one TABLE takes are refused before anything is drawn. arch_schedule joins arch_room and arch_rooms_detect under SECTION 25's rooms marker (banner 3 tools), shares the TABLE command with entity_create_table and bom_table, and the counts follow: 232 tools in 25 groups. Live fix in com_backend.entity_create_table. MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property. pywin32 raised AttributeError on `table.TextHeight = h`, the old code swallowed it, and every live table kept the Standard style's heights (title 6.0, header and data 4.5), which is 0.1 mm on paper at 1:50. The member that works is SetCellTextHeight(row, col, h), probed on a scratch document: every cell read back 125.0 via GetCellTextHeight. The row heights stayed 350.0 and GetBoundingBox matched the reported bbox (10000,3250)-(15750,5000). Then draw_schedule ran end to end through ComBackend on a scratch document: a native TABLE on A-SYMB-T-N for doors (5 rows) and rooms/tr (6 rows, total 45,00 m²), and the document was closed afterwards. The new fake in tests/test_table_leader.py has only the measured members. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
285 lines
9.9 KiB
Python
285 lines
9.9 KiB
Python
"""Door, window and room schedules - a read of the drawing, drawn as a real TABLE.
|
|
|
|
`schedule_rows` turns plan data (the shape `read_plan` returns: ``walls``,
|
|
``openings``, ``rooms`` lists of model dataclasses) into rows; `draw_schedule`
|
|
reads that data off the drawing's ``ACADMCP_ARCH`` records and draws it through
|
|
the same `entity_create_table` path the ISO 7573 parts list takes
|
|
(`engineering/sheet/bom.py::draw_bom_table`): insertion at the top-left corner,
|
|
the table growing downwards, and the engine's representation reported rather
|
|
than assumed - a real ACAD_TABLE live, a composite of rules and MTEXT headless.
|
|
|
|
Rows:
|
|
|
|
* doors - tag, width, height, swing, hand, wall; windows - tag, width,
|
|
height, sill, wall. Sorted by tag, numbers compared as numbers (D2 before
|
|
D10). An opening without a tag gets the next free ``<prefix><n>`` in wall
|
|
order (the order of ``walls``, then the offset along the wall); an explicit
|
|
tag is never renumbered and a generated one never repeats it.
|
|
* rooms - number, name, area (the measured one the label carries), sorted by
|
|
number with the unnumbered last, plus a total row.
|
|
|
|
A value the model does not carry (a door without a height) is an empty cell,
|
|
never a default. Headers come from `vocab(lang)`; ``lang="tr"`` also writes
|
|
decimal commas.
|
|
|
|
DECLARED, not standard: the paper column widths below and the reuse of the
|
|
parts list's 7 mm row and 2.5 mm text (`engineering.sheet.bom.ROW_HEIGHT`,
|
|
`TEXT_HEIGHT`) - no standard fixes a schedule's layout.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
from typing import TYPE_CHECKING
|
|
|
|
from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab
|
|
from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
|
|
|
|
if TYPE_CHECKING:
|
|
from backends.base import AutoCADBackend
|
|
|
|
__all__ = [
|
|
"COLUMN_WIDTHS",
|
|
"MAX_ROWS",
|
|
"SCHEDULE_COLUMNS",
|
|
"SCHEDULE_KINDS",
|
|
"SCHEDULE_TITLES",
|
|
"draw_schedule",
|
|
"schedule_rows",
|
|
]
|
|
|
|
SCHEDULE_KINDS: tuple[str, ...] = ("doors", "windows", "rooms")
|
|
|
|
SCHEDULE_COLUMNS: dict[str, tuple[str, ...]] = {
|
|
"doors": ("tag", "width", "height", "swing", "hand", "wall"),
|
|
"windows": ("tag", "width", "height", "sill", "wall"),
|
|
"rooms": ("number", "name", "area"),
|
|
}
|
|
|
|
#: The `vocab` key of each schedule's title row.
|
|
SCHEDULE_TITLES: dict[str, str] = {
|
|
"doors": "door_schedule",
|
|
"windows": "window_schedule",
|
|
"rooms": "room_schedule",
|
|
}
|
|
|
|
#: Column widths in paper millimetres (x the scale denominator in the drawing).
|
|
COLUMN_WIDTHS: dict[str, float] = {
|
|
"tag": 15.0,
|
|
"width": 20.0,
|
|
"height": 20.0,
|
|
"swing": 20.0,
|
|
"hand": 20.0,
|
|
"sill": 20.0,
|
|
"wall": 20.0,
|
|
"number": 15.0,
|
|
"name": 45.0,
|
|
"area": 25.0,
|
|
}
|
|
|
|
#: `entity_create_table` takes at most 200 rows, the title and header included.
|
|
MAX_ROWS = 198
|
|
|
|
_OPENING_KIND = {"doors": "door", "windows": "window"}
|
|
|
|
|
|
def _check(kind: str, lang: str) -> None:
|
|
if kind not in SCHEDULE_KINDS:
|
|
raise ValueError(
|
|
f"kind: {kind!r} is not a schedule; schedules are {', '.join(SCHEDULE_KINDS)}"
|
|
)
|
|
if lang not in LANGS:
|
|
raise ValueError(
|
|
f"lang: {lang!r} is not a schedule language; languages are {', '.join(LANGS)}"
|
|
)
|
|
|
|
|
|
def _natural(text: str) -> tuple:
|
|
"""``D10`` after ``D2``: digit runs compare as numbers."""
|
|
return tuple(
|
|
(0, int(part), "") if part.isdigit() else (1, 0, part)
|
|
for part in re.findall(r"\d+|\D+", str(text))
|
|
)
|
|
|
|
|
|
def _mm(value, lang: str) -> str:
|
|
"""A length in mm: whole numbers without decimals, others to 0.1 mm; None is blank."""
|
|
if value is None:
|
|
return ""
|
|
number = float(value)
|
|
decimals = 0 if abs(number - round(number)) < 1e-9 else 1
|
|
return fmt_number(number, lang, decimals)
|
|
|
|
|
|
def _opening_rows(kind: str, plan: dict, lang: str) -> tuple[dict, ...]:
|
|
words = vocab(lang)
|
|
wanted = _OPENING_KIND[kind]
|
|
wall_order = {wall.id: index for index, wall in enumerate(plan.get("walls") or ())}
|
|
openings = [o for o in plan.get("openings") or () if o.kind == wanted]
|
|
openings.sort(
|
|
key=lambda o: (wall_order.get(o.wall, len(wall_order)), o.wall, float(o.offset), o.id)
|
|
)
|
|
prefix = tag_prefix(wanted, lang)
|
|
used = {str(o.tag) for o in openings if o.tag}
|
|
counter = 0
|
|
rows: list[dict] = []
|
|
for opening in openings:
|
|
tag = str(opening.tag) if opening.tag else None
|
|
generated = tag is None
|
|
if generated:
|
|
counter += 1
|
|
while f"{prefix}{counter}" in used:
|
|
counter += 1
|
|
tag = f"{prefix}{counter}"
|
|
used.add(tag)
|
|
row = {
|
|
"id": opening.id,
|
|
"tag": tag,
|
|
"generated": generated,
|
|
"width": _mm(opening.width, lang),
|
|
"height": _mm(opening.height, lang),
|
|
"wall": opening.wall,
|
|
}
|
|
if kind == "doors":
|
|
row["swing"] = words[opening.swing]
|
|
row["hand"] = words[opening.hand]
|
|
else:
|
|
row["sill"] = _mm(opening.sill, lang)
|
|
rows.append(row)
|
|
rows.sort(key=lambda row: _natural(row["tag"]))
|
|
return tuple(rows)
|
|
|
|
|
|
def _room_rows(plan: dict, lang: str) -> tuple[dict, ...]:
|
|
rooms = sorted(
|
|
plan.get("rooms") or (),
|
|
key=lambda r: (
|
|
r.number is None or str(r.number) == "",
|
|
_natural(r.number or ""),
|
|
r.name,
|
|
r.id,
|
|
),
|
|
)
|
|
if not rooms:
|
|
return ()
|
|
rows = [
|
|
{
|
|
"id": room.id,
|
|
"number": "" if room.number is None else str(room.number),
|
|
"name": room.name,
|
|
"area": fmt_area_m2(room.area, lang),
|
|
"area_mm2": float(room.area),
|
|
"total": False,
|
|
}
|
|
for room in rooms
|
|
]
|
|
total = math.fsum(float(room.area) for room in rooms)
|
|
rows.append(
|
|
{
|
|
"id": "",
|
|
"number": "",
|
|
"name": vocab(lang)["total"],
|
|
"area": fmt_area_m2(total, lang),
|
|
"area_mm2": total,
|
|
"total": True,
|
|
}
|
|
)
|
|
return tuple(rows)
|
|
|
|
|
|
def schedule_rows(kind: str, plan: dict, *, lang: str = "en") -> tuple[dict, ...]:
|
|
"""The rows of one schedule, each a dict holding every column of ``SCHEDULE_COLUMNS[kind]``
|
|
as display text, plus ``id`` (and ``generated`` for openings, ``area_mm2`` and
|
|
``total`` for rooms). Refused: an unknown kind or language, named with the list."""
|
|
_check(kind, lang)
|
|
if kind == "rooms":
|
|
return _room_rows(plan, lang)
|
|
return _opening_rows(kind, plan, lang)
|
|
|
|
|
|
async def _ensure_layer(backend: AutoCADBackend, name: str) -> None:
|
|
"""Create the schedule layer from its `ARCH_LAYERS` row when the drawing lacks it.
|
|
|
|
Live ActiveX will not create a layer on assignment (the repository rule),
|
|
so the layer exists before the table does.
|
|
"""
|
|
if name.casefold() in {layer.name.casefold() for layer in await backend.layer_list()}:
|
|
return
|
|
row = next(r for r in ARCH_LAYERS if r[0] == name)
|
|
await backend.layer_create(name=row[0], color=row[1], linetype=row[2], lineweight=row[3])
|
|
|
|
|
|
async def draw_schedule(
|
|
backend: AutoCADBackend, kind: str, *, at, lang: str = "en", scale=50
|
|
) -> dict:
|
|
"""Read the drawing's records and draw one schedule as a TABLE whose top-left is ``at``.
|
|
|
|
Refused before anything is drawn: an unknown kind or language, a scale that
|
|
is not a finite positive number, an ``at`` that is not two finite numbers,
|
|
a drawing with nothing to schedule, and more rows than one TABLE takes.
|
|
"""
|
|
from engineering.arch.rooms import read_records # the one ACADMCP_ARCH reader
|
|
from engineering.sheet.bom import ROW_HEIGHT, TEXT_HEIGHT
|
|
|
|
_check(kind, lang)
|
|
try:
|
|
factor = float(scale)
|
|
except (TypeError, ValueError):
|
|
raise ValueError(f"scale: expected a number, got {scale!r}") from None
|
|
if not math.isfinite(factor) or factor <= 0.0:
|
|
raise ValueError(f"scale: must be a finite number greater than zero, got {scale!r}")
|
|
try:
|
|
x, y = float(at[0]), float(at[1])
|
|
except (TypeError, ValueError, IndexError):
|
|
raise ValueError(f"at: expected [x, y], got {at!r}") from None
|
|
if not (math.isfinite(x) and math.isfinite(y)):
|
|
raise ValueError(f"at: must be two finite numbers, got {at!r}")
|
|
|
|
plan = await read_records(backend)
|
|
rows = schedule_rows(kind, plan, lang=lang)
|
|
if not rows:
|
|
raise ValueError(
|
|
f"kind: this drawing carries no {kind[:-1]} records to schedule "
|
|
"(ACADMCP_ARCH on the architectural layers); draw the plan first"
|
|
)
|
|
if len(rows) > MAX_ROWS:
|
|
raise ValueError(
|
|
f"kind: {len(rows)} {kind} rows do not fit one TABLE ({MAX_ROWS} data rows at most)"
|
|
)
|
|
words = vocab(lang)
|
|
columns = SCHEDULE_COLUMNS[kind]
|
|
layer = ARCH_ROLE_LAYER["symbol"]
|
|
await _ensure_layer(backend, layer)
|
|
entity = await backend.entity_create_table(
|
|
x,
|
|
y,
|
|
[[row[c] for c in columns] for row in rows],
|
|
headers=[words[c] for c in columns],
|
|
column_widths=[COLUMN_WIDTHS[c] * factor for c in columns],
|
|
row_height=ROW_HEIGHT * factor,
|
|
text_height=TEXT_HEIGHT * factor,
|
|
title=words[SCHEDULE_TITLES[kind]],
|
|
layer=layer,
|
|
)
|
|
width = sum(COLUMN_WIDTHS[c] for c in columns) * factor
|
|
height = (len(rows) + 2) * ROW_HEIGHT * factor
|
|
return {
|
|
"ok": True,
|
|
"kind": kind,
|
|
"lang": lang,
|
|
"handle": entity.handle,
|
|
"entity_type": entity.type,
|
|
"representation": entity.properties.get("representation", "native"),
|
|
"child_handles": list(entity.properties.get("child_handles") or []),
|
|
"layer": layer,
|
|
"columns": list(columns),
|
|
"headers": [words[c] for c in columns],
|
|
"title": words[SCHEDULE_TITLES[kind]],
|
|
"rows": [dict(row) for row in rows],
|
|
"row_count": len(rows),
|
|
"table_rows": entity.properties.get("rows"),
|
|
"insert": [x, y],
|
|
"bbox": {"min": [x, y - height], "max": [x + width, y]},
|
|
}
|