feat(understand): the snapshot, the vocabulary and the label parsers; MAX_DXF_BYTES 512 MB; the office extra

Track H's foundation, landed on main before its three groups branch.

- engineering/understand/snapshot.py: one bulk read of a drawing into frozen
  WCS records (OCS translated through backends/ocs.py, text decoded, an
  INSERT boxed by its block geometry without its attribute texts). A DXF is
  read with ezdxf after the size check; the headless engine's document is
  read under its own lock; the live engine exports the active document with
  Document.Export(<tmp>/snap, "DXF", <set of all>) - never renamed, deleted
  afterwards - and a DWG is opened read-only, exported and closed. Measured on
  AutoCAD 2026: ".dxf" is refused with Invalid argument, the file lands as
  snap.DXF, model space, paper space and unused block definitions all reach
  it, and the selection set does not narrow a DXF export.
- engineering/understand/labels.py: %%c, \U+2205, the U+2300 sign, o-stroke
  and MTEXT format runs all read as one diameter spelling; sizes are kept as
  drawn and never equated; Cyrillic look-alike capitals fold into Latin tags;
  panels, wiring callouts and electrical data (kW, phases, volts, N, VFD) in
  EN / TR / RU / NL / DE. A power that is not written stays None.
- engineering/understand/vocab.py: layer classification with a confidence and
  the deciding keyword, supply / return words, room labels and block kinds in
  five languages. A CIP layer without a direction word is not guessed.
- MAX_DXF_BYTES defaults to 512 MB (a real layout was 188 MB); the refusal
  names the size and the variable. New `office` extra (openpyxl), also in
  `full`; uv.lock regenerated.

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-24 11:38:59 +03:00
co-authored by Claude Opus 5.5
parent f0c50d772f
commit 9ab0ae1773
13 changed files with 2105 additions and 7 deletions
+326
View File
@@ -0,0 +1,326 @@
"""The snapshot: one bulk read of a drawing into frozen WCS records.
Every expected coordinate and length is worked out by hand in the comment
beside it. The live engine's export is exercised against a fake document that
behaves the way AutoCAD 2026 was measured to behave (the extension must be
"DXF", the file lands as snap.DXF, the document is not renamed).
"""
from __future__ import annotations
import math
from pathlib import Path
import ezdxf
import pytest
from ezdxf.math import Vec2
import backends.com_backend as cb
import config
from backends.base import UnsupportedCapabilityError
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
from engineering.understand.snapshot import (
EntityRecord,
Snapshot,
length_of,
read_snapshot,
records_from_doc,
take_snapshot,
)
EPS = 1e-9
def _doc():
"""A drawing with one entity of each kind the readers use."""
doc = ezdxf.new("R2018")
doc.header["$INSUNITS"] = 4
doc.layers.add("PIPE", color=3, linetype="Continuous")
msp = doc.modelspace()
msp.add_line((0, 0), (3, 4), dxfattribs={"layer": "PIPE"})
# a closed square whose first edge bulges into a semicircle (bulge 1)
msp.add_lwpolyline(
[(0, 0, 1.0), (10, 0, 0.0), (10, 10, 0.0), (0, 10, 0.0)],
format="xyb",
close=True,
dxfattribs={"layer": "PIPE"},
)
msp.add_polyline2d([(0, 0), (0, 5), (5, 5)], dxfattribs={"layer": "PIPE"})
msp.add_arc((100, 0), 10, 0, 90, dxfattribs={"layer": "PIPE"})
# the same quarter arc mirrored: extrusion (0, 0, -1), OCS centre (-200, 0)
# is WCS (200, 0), and the arc runs from 90 to 180 degrees in WCS
msp.add_arc((-200, 0), 10, 0, 90, dxfattribs={"layer": "PIPE", "extrusion": (0, 0, -1)})
msp.add_circle((50, 50), 5)
msp.add_text("%%c51", dxfattribs={"insert": (20, 20), "height": 2.5, "layer": "TXT"})
msp.add_mtext("{\\fArial|b0;SMS51}\\PDN20", dxfattribs={"insert": (30, 30), "char_height": 3.5})
block = doc.blocks.new("TANK_V")
block.add_circle((0, 0), 600)
block.add_attdef("TAG", (0, 0), dxfattribs={"height": 250})
insert = msp.add_blockref("TANK_V", (1000, 2000), dxfattribs={"rotation": 90})
insert.add_auto_attribs({"TAG": "Т4100"}) # Cyrillic Т
leader = msp.add_multileader_mtext("Standard")
leader.set_content("Wiring to CP1", char_height=2.5)
leader.add_leader_line(ezdxf.render.mleader.ConnectionSide.left, [Vec2(0, 0)])
leader.build(insert=Vec2(50, 20))
doc.layouts.get("Layout1").add_line((1, 1), (2, 1))
return doc
def _by_type(snap: Snapshot) -> dict[str, list[EntityRecord]]:
out: dict[str, list[EntityRecord]] = {}
for rec in snap.records:
out.setdefault(rec.type, []).append(rec)
return out
def test_records_carry_wcs_geometry_plain_text_and_the_space():
snap = records_from_doc(_doc(), source="memory")
kinds = _by_type(snap)
assert snap.source == "memory"
assert snap.insunits == 4
assert snap.layouts == ("Model", "Layout1")
assert snap.layers["PIPE"] == {"color": 3, "linetype": "Continuous"}
(line_model,) = [r for r in kinds["LINE"] if r.space == "Model"]
(line_paper,) = [r for r in kinds["LINE"] if r.space == "Layout1"]
assert line_model.points == ((0.0, 0.0), (3.0, 4.0))
assert line_model.layer == "PIPE"
assert line_paper.points == ((1.0, 1.0), (2.0, 1.0))
(square,) = kinds["LWPOLYLINE"]
assert square.closed is True
assert square.bulges == (1.0, 0.0, 0.0, 0.0)
(poly,) = kinds["POLYLINE"]
assert poly.points == ((0.0, 0.0), (0.0, 5.0), (5.0, 5.0)) and poly.closed is False
arcs = sorted(kinds["ARC"], key=lambda r: r.points[0][0])
assert arcs[0].points == ((100.0, 0.0),) and arcs[0].angles == pytest.approx((0.0, 90.0))
assert arcs[1].points[0] == pytest.approx((200.0, 0.0), abs=EPS)
assert arcs[1].angles == pytest.approx((90.0, 180.0), abs=EPS)
(text,) = kinds["TEXT"]
assert text.text == "Ø51" and text.height == 2.5 and text.points == ((20.0, 20.0),)
(mtext,) = kinds["MTEXT"]
assert mtext.text == "SMS51\nDN20" and mtext.height == 3.5
(insert,) = kinds["INSERT"]
assert insert.block == "TANK_V" and insert.points == ((1000.0, 2000.0),)
assert insert.attribs == (("TAG", "Т4100"),)
assert insert.rotation == 90.0
assert insert.bbox == pytest.approx((400.0, 1400.0, 1600.0, 2600.0), abs=1e-6)
(leader,) = kinds["MULTILEADER"]
assert leader.text == "Wiring to CP1"
assert leader.points[0] == (0.0, 0.0) # the arrow tip comes first
assert leader.points[-1] == (50.0, 20.0) # the text insertion comes last
assert snap.extmin is None and snap.extmax is None # never computed
def test_every_record_is_frozen():
rec = records_from_doc(_doc(), source="memory").records[0]
with pytest.raises(AttributeError):
rec.layer = "OTHER"
def test_lengths():
kinds = _by_type(records_from_doc(_doc(), source="memory"))
(line_model,) = [r for r in kinds["LINE"] if r.space == "Model"]
assert length_of(line_model) == pytest.approx(5.0, abs=EPS) # 3-4-5
# semicircle of diameter 10 (5 pi) + three straight sides of 10
(square,) = kinds["LWPOLYLINE"]
assert length_of(square) == pytest.approx(5.0 * math.pi + 30.0, abs=EPS)
(poly,) = kinds["POLYLINE"]
assert length_of(poly) == pytest.approx(10.0, abs=EPS)
for arc in kinds["ARC"]: # a quarter of a radius-10 circle
assert length_of(arc) == pytest.approx(5.0 * math.pi, abs=EPS)
(circle,) = kinds["CIRCLE"]
assert length_of(circle) == pytest.approx(10.0 * math.pi, abs=EPS)
assert length_of(kinds["TEXT"][0]) == 0.0
def test_the_size_limit_defaults_to_512_mb(monkeypatch):
monkeypatch.delenv("MAX_DXF_BYTES", raising=False)
assert config.Settings().max_dxf_bytes == 512 * 1024 * 1024
assert config.DEFAULT_MAX_DXF_BYTES == 536_870_912
def test_read_snapshot_reads_a_file(tmp_path):
path = tmp_path / "plant.dxf"
_doc().saveas(path)
snap = read_snapshot(str(path))
assert snap.source == str(path)
assert len(snap.records) == len(records_from_doc(_doc(), source="x").records)
def test_read_snapshot_refuses_a_file_over_the_limit_naming_size_and_variable(
tmp_path, monkeypatch
):
path = tmp_path / "big.dxf"
_doc().saveas(path)
size = path.stat().st_size
monkeypatch.setattr(config.settings, "max_dxf_bytes", size - 1)
with pytest.raises(ValueError) as excinfo:
read_snapshot(str(path))
message = str(excinfo.value)
assert "MAX_DXF_BYTES" in message
assert f"{size:,} bytes" in message
monkeypatch.setattr(config.settings, "max_dxf_bytes", 0) # 0 disables the check
assert read_snapshot(str(path)).records
def test_read_snapshot_refuses_missing_and_unreadable_files(tmp_path):
with pytest.raises(FileNotFoundError):
read_snapshot(str(tmp_path / "nope.dxf"))
junk = tmp_path / "junk.dxf"
junk.write_text("not a dxf", encoding="utf-8")
with pytest.raises(ValueError) as excinfo:
read_snapshot(str(junk))
assert "not a readable DXF" in str(excinfo.value)
# -- take_snapshot on the headless engine ---------------------------------------
@pytest.mark.asyncio
async def test_the_headless_engine_snapshots_its_current_document_without_touching_it():
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
line = await backend.entity_create_line(0, 0, 10, 0, layer="PIPE")
before = len(await backend.entity_list(limit=1000))
snap = await take_snapshot(backend)
assert snap.source == "memory:untitled"
assert [r.handle for r in snap.records if r.type == "LINE"] == [line.handle]
assert len(await backend.entity_list(limit=1000)) == before
@pytest.mark.asyncio
async def test_a_dxf_path_is_read_directly(tmp_path):
path = tmp_path / "plant.dxf"
_doc().saveas(path)
backend = EzdxfBackend()
await backend.connect()
snap = await take_snapshot(backend, str(path))
assert snap.source == str(path) and snap.records
@pytest.mark.asyncio
async def test_a_dwg_is_refused_headlessly_with_the_dwg_capability(tmp_path):
path = tmp_path / "plant.dwg"
path.write_bytes(b"AC1032")
backend = EzdxfBackend()
await backend.connect()
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await take_snapshot(backend, str(path))
assert excinfo.value.capability == "dwg"
assert "DXF" in str(excinfo.value)
with pytest.raises(FileNotFoundError):
await take_snapshot(backend, str(tmp_path / "missing.dwg"))
with pytest.raises(ValueError):
await take_snapshot(backend, str(tmp_path / "plant.txt"))
# -- take_snapshot on the live engine (a fake that behaves as measured) ----------
class _Selection:
def __init__(self, log):
self.log = log
def Select(self, mode, *args): # noqa: N802 - ActiveX member name
self.log.append(("select", mode))
def Delete(self): # noqa: N802
self.log.append(("delete_selection",))
class _Selections:
def __init__(self, log):
self.log = log
def Add(self, name): # noqa: N802
self.log.append(("add_selection", name))
return _Selection(self.log)
class _Doc:
def __init__(self, name, log):
self.Name = name
self.log = log
self.SelectionSets = _Selections(log)
def Export(self, base, extension, selection): # noqa: N802
if extension != "DXF":
raise ValueError("Invalid argument") # what AutoCAD 2026 answers to ".dxf"
self.log.append(("export", Path(base).name, extension))
_doc().saveas(base + ".DXF") # AutoCAD writes the upper-case extension
def SaveAs(self, *args): # noqa: N802
raise AssertionError("a snapshot must never rename the document")
def Close(self, save): # noqa: N802
self.log.append(("close", save))
class _Documents:
def __init__(self, log):
self.log = log
def Open(self, path, read_only): # noqa: N802
self.log.append(("open", Path(path).name, read_only))
return _Doc(Path(path).name, self.log)
class _App:
def __init__(self, log):
self.Documents = _Documents(log)
def _live(monkeypatch, log):
backend = ComBackend()
async def run_inline(func, *args, **kwargs): # bypass the STA executor
return func(*args, **kwargs)
async def ensure_state():
log.append(("ensure_document_state",))
backend._run = run_inline
backend._ensure_document_state = ensure_state
active = _Doc("Drawing1.dwg", log)
monkeypatch.setattr(cb, "_acad_app", lambda: _App(log))
monkeypatch.setattr(cb, "_acad_doc", lambda: active)
return backend, active
@pytest.mark.asyncio
async def test_the_live_engine_exports_a_dxf_snapshot_and_deletes_it(monkeypatch, tmp_path):
log: list[tuple] = []
made: list[str] = []
real_mkdtemp = __import__("tempfile").mkdtemp
def mkdtemp(prefix=""):
folder = real_mkdtemp(prefix=prefix, dir=tmp_path)
made.append(folder)
return folder
monkeypatch.setattr("engineering.understand.snapshot.tempfile.mkdtemp", mkdtemp)
backend, active = _live(monkeypatch, log)
snap = await take_snapshot(backend)
assert snap.source == "live:Drawing1.dwg"
assert active.Name == "Drawing1.dwg"
assert ("select", cb._AC_SELECTION_SET_ALL) in log
assert ("export", "snap", "DXF") in log
assert ("delete_selection",) in log
assert {r.type for r in snap.records} >= {"LINE", "INSERT", "MULTILEADER"}
assert made and not Path(made[0]).exists(), "the temporary snapshot must be deleted"
@pytest.mark.asyncio
async def test_a_dwg_on_the_live_engine_is_opened_read_only_and_closed_unsaved(
monkeypatch, tmp_path
):
path = tmp_path / "plant.dwg"
path.write_bytes(b"AC1032")
log: list[tuple] = []
backend, _active = _live(monkeypatch, log)
snap = await take_snapshot(backend, str(path))
assert snap.source == "live:plant.dwg"
assert ("open", "plant.dwg", True) in log
assert ("close", False) in log
assert log[-1] == ("ensure_document_state",)