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
+2 -2
View File
@@ -85,8 +85,8 @@ ENABLE_3D=false
# These four are read by config.py but were missing from this file, which made
# it look like the complete list. Two of them are the remote-HTTP controls.
# Reject DXF input larger than this many bytes. 0 disables the check.
MAX_DXF_BYTES=52428800
# Reject DXF input larger than this many bytes (512 MB). 0 disables the check.
MAX_DXF_BYTES=536870912
# Upper bound on list/selection response sizes.
MAX_LIST_LIMIT=5000
+1 -1
View File
@@ -242,7 +242,7 @@ Nothing loads a `.env` file — export these, or set them in your MCP client's `
| `ALLOWED_PATHS` | *(empty)* | Comma-separated absolute paths the server may access |
| `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots |
| `EZDXF_UNDO_DEPTH` | `0` | Headless undo history depth; `0` disables it |
| `MAX_DXF_BYTES` | `52428800` | Reject larger DXF input; `0` disables |
| `MAX_DXF_BYTES` | `536870912` | Reject larger DXF input (512 MB); `0` disables |
| `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes |
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (s); `0` disables |
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless timeout (s); `0` disables |
+9 -3
View File
@@ -9,6 +9,9 @@ from pathlib import Path
#: CAD this project is developed and tested against.
DEFAULT_CAD_PROGID = "AutoCAD.Application"
#: Default ``MAX_DXF_BYTES``: 512 MB.
DEFAULT_MAX_DXF_BYTES = 512 * 1024 * 1024
class Settings:
"""Server configuration loaded from environment variables."""
@@ -62,9 +65,12 @@ class Settings:
os.environ.get("DANGEROUS_COMMANDS_ENABLED", "false").lower().strip() == "true"
)
# Reject opening DXF files larger than this many bytes (default 50 MB).
# Set to 0 to disable the check.
self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(50 * 1024 * 1024)))
# Reject opening DXF files larger than this many bytes (default 512 MB).
# Set to 0 to disable the check. It was 50 MB until v1.6's track H: a
# real plant layout read on 2026-09-24 was a 188 MB DXF, and the
# understanding tools exist for exactly such drawings. The refusal
# names the file's size and this variable.
self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(DEFAULT_MAX_DXF_BYTES)))
# Hard cap on list/select limit params to keep tool responses bounded.
self.max_list_limit: int = int(os.environ.get("MAX_LIST_LIMIT", "5000"))
+57
View File
@@ -0,0 +1,57 @@
"""Understanding drawings somebody else made (v1.6 track H).
One bulk read per drawing (:mod:`.snapshot`), multilingual vocabulary
(:mod:`.vocab`) and label parsers (:mod:`.labels`) are the foundation; the
readers built on them - the one-call report, the scale check, the line
networks and takeoffs, the diff and the topology checks - add their modules
here. Everything except the snapshot's live export is pure: records in,
results out, the drawing never modified.
"""
from engineering.understand.labels import (
normalize_panel,
parse_diameters,
parse_electrical,
parse_tag,
plain,
wiring_target,
)
from engineering.understand.snapshot import (
EntityRecord,
Pt,
Snapshot,
length_of,
read_snapshot,
records_from_doc,
take_snapshot,
)
from engineering.understand.vocab import (
DISCIPLINES,
SERVICES,
classify_layer,
equipment_kind,
room_label,
supply_return,
)
__all__ = [
"DISCIPLINES",
"SERVICES",
"EntityRecord",
"Pt",
"Snapshot",
"classify_layer",
"equipment_kind",
"length_of",
"normalize_panel",
"parse_diameters",
"parse_electrical",
"parse_tag",
"plain",
"read_snapshot",
"records_from_doc",
"room_label",
"supply_return",
"take_snapshot",
"wiring_target",
]
+302
View File
@@ -0,0 +1,302 @@
"""Label parsers for foreign drawings: diameters, equipment tags, panels, electrical data.
Pure functions over one string each - no ezdxf, no I/O - so every rule here is
tested on the spellings real drawings use, and the
readers above it (the network, the takeoffs, the scale check) share one
reading of a label instead of three.
What a label *says* is kept exactly as drawn. ``Ø51``, ``SMS51``, ``DN20``,
``Ø25,4`` and ``Ø25`` are five different sizes: the parser normalises only the
spelling of the diameter sign and the whitespace, never the number, and never
equates two sizing systems (an SMS size and a DN size are different series; the
drawing has to say they are the same pipe, not this module).
Two kinds of foreign-drawing noise are decoded before anything is parsed:
* **AutoCAD text codes.** ``%%c`` is AutoCAD's diameter sign, ``%%d`` the degree
sign, ``%%p`` plus-minus; ``\\U+2205`` is a Unicode escape (AutoCAD's help
lists U+2205 as its diameter symbol). MTEXT carries format runs
(``{\\fArial|b0;Ø51}``, ``\\H2.5;``, ``\\P`` for a new paragraph) that are
formatting, not content.
* **Cyrillic look-alikes in tags.** A Russian-labelled P&ID typed on a Russian
keyboard writes ``Т4100`` with a Cyrillic Т (U+0422); it must match the
layout's Latin ``T4100``. Eleven capitals have a Latin twin of identical
shape (Т М С Р А В Е К Н О Х) and are folded; a Cyrillic letter without one
(П, Щ, Ш, ...) is left alone, so a Russian word never turns into a tag.
"""
from __future__ import annotations
import re
__all__ = [
"CYRILLIC_LOOKALIKES",
"fold_lookalikes",
"normalize_panel",
"parse_diameters",
"parse_electrical",
"parse_tag",
"plain",
"wiring_target",
]
#: Cyrillic capitals drawn identically to a Latin capital, and that capital.
CYRILLIC_LOOKALIKES: dict[str, str] = {
"\u0422": "T", # Т
"\u041c": "M", # М
"\u0421": "C", # С
"\u0420": "P", # Р
"\u0410": "A", # А
"\u0412": "B", # В
"\u0415": "E", # Е
"\u041a": "K", # К
"\u041d": "H", # Н
"\u041e": "O", # О
"\u0425": "X", # Х
}
_LOOKALIKE_TABLE = str.maketrans(CYRILLIC_LOOKALIKES)
#: ``%%`` codes that stand for a character (AutoCAD's control codes).
_PERCENT_CHARS = {"c": "\u00d8", "d": "\u00b0", "p": "\u00b1"}
#: MTEXT codes that carry an argument up to the next ``;`` and draw nothing.
_ARG_CODES = frozenset("fFHWQTACcp")
#: MTEXT on/off toggles (underline, overline, strike-through, column break).
_TOGGLE_CODES = frozenset("LlOoKkN")
_UNICODE_ESCAPE = re.compile(r"\\[Uu]\+([0-9A-Fa-f]{4})")
_PERCENT_CODE = re.compile(r"%%(\d{3}|[cdpCDP]|[uoUOkK])")
#: Every spelling of the diameter sign met in drawings: Ø (U+00D8), ø (U+00F8),
#: ∅ (U+2205, AutoCAD's listed diameter symbol), ⌀ (U+2300,
#: the Unicode DIAMETER SIGN).
_DIAMETER_SIGNS = "\u00d8\u00f8\u2205\u2300"
_NUMBER = r"\d+(?:[.,]\d+)?"
_DIAMETER_RE = re.compile(
rf"(?P<sign>[{_DIAMETER_SIGNS}])\s*(?P<sign_n>{_NUMBER})"
rf"|(?<![A-Za-z])(?P<sms>SMS)\s*-?\s*(?P<sms_n>{_NUMBER})"
rf"|(?<![A-Za-z])(?P<dn>DN)\s*-?\s*(?P<dn_n>\d+)"
rf"|(?<![\d.,])(?P<a>{_NUMBER})\s*[xX\u00d7\u0445\u0425]\s*(?P<b>{_NUMBER})(?![.,]?\d)"
r"(?!\s*(?:mm2|mm\u00b2|\u043c\u043c2|\u043c\u043c\u00b2))"
rf"|(?<![\d.,])(?P<inch>\d+(?:[.,]\d+)?(?:\s*/\s*\d+)?)\s*(?:\"|''|\u201d|\u2033)",
re.IGNORECASE,
)
#: A token that may be a tag: starts with a letter, keeps letters, digits, dots
#: and hyphens (``CP-M82``, ``M47.1``).
_TAG_TOKEN = re.compile(r"[A-Za-z\u0400-\u04ff][A-Za-z0-9\u0400-\u04ff.\-]*")
#: An equipment tag: 1-4 letters, an optional second letter group after a
#: hyphen (``CP-M82``), an optional hyphen, 1-5 digits, an optional letter
#: suffix (``M41A``) and an optional ``.n`` (``M47.1``).
_TAG_RE = re.compile(r"[A-Z]{1,4}(?:-[A-Z]{1,4})?-?\d{1,5}[A-Z]?(?:\.\d{1,3})?")
#: Prefixes that look like a tag and are not one: nominal sizes (DN20, SMS51),
#: a pressure rating (PN16) and an ingress-protection code (IP65).
_NOT_TAG_PREFIXES = ("DN", "SMS", "PN", "IP")
_PANEL_RE = re.compile(
r"(?<![A-Z0-9])([A-Z]{1,4})\s*[-_ ]?\s*((?:[A-Z]{1,3})?\d{1,4}[A-Z]?)(?![A-Z0-9])"
)
_KW_RE = re.compile(
r"(\d+(?:[.,]\d+)?)\s*(?:kw|\u043a\u0432\u0442)(?![a-z\u0430-\u044f])", re.IGNORECASE
)
_PHASE_RE = re.compile(
r"(?<!\d)([13])\s*(?:\u0444\b\.?|ph\b\.?|phase|faz|~|/\s*n\b)", re.IGNORECASE
)
_VOLT_RE = re.compile(
r"(?<![\d.,])(\d{2,4})\s*(?:v(?:ac|dc)?|\u0432)(?![a-z\u0430-\u044f])", re.IGNORECASE
)
_NEUTRAL_RE = re.compile(r"(?:\+|/)\s*n(?![a-z])", re.IGNORECASE)
#: Words that say a load is driven by a frequency converter, EN/TR/RU/NL/DE.
_VFD_WORDS = (
"vfd",
"vsd",
"inverter",
"frekans",
"\u0447\u0430\u0441\u0442\u043e\u0442\u043d", # частотн(ый преобразователь)
"\u0447\u0440\u043f", # ЧРП
"frequentieregelaar",
"frequenzumrichter",
)
#: Phrases that introduce the panel a load is wired to, EN/TR/RU/NL/DE.
_WIRING_PHRASES = (
"wiring to",
"wired to",
"cable to",
"connected to",
"\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a", # подключение к
"\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043a", # подключить к
"bedrading naar",
"verdrahtung zu",
"verdrahtung nach",
)
#: Turkish puts the panel first: ``CP1 panosuna`` ("to the CP1 panel").
_WIRING_PHRASES_AFTER = ("panosuna", "panosundan")
def plain(text: str | None) -> str:
"""The text a reader sees: codes decoded, MTEXT formatting removed.
``\\U+XXXX`` becomes its character; ``%%c`` / ``%%d`` / ``%%p`` become Ø / ° /
±, ``%%nnn`` the character with that code, ``%%%`` a percent sign, and the
``%%u`` / ``%%o`` / ``%%k`` toggles disappear. In MTEXT, ``\\P`` is a line
break, ``\\~`` a space, ``\\S a^b;`` / ``\\S a/b;`` the stacked ``a/b``,
``\\\\`` ``\\{`` ``\\}`` the literal characters; every argument code
(``\\f...;``, ``\\H...;``, ``\\C...;`` ...) and every toggle is dropped, and so
are the grouping braces.
"""
if not text:
return ""
source = _UNICODE_ESCAPE.sub(lambda m: chr(int(m.group(1), 16)), str(text))
source = source.replace("%%%", "\x00")
def percent(match: re.Match) -> str:
code = match.group(1)
if code.isdigit():
return chr(int(code))
return _PERCENT_CHARS.get(code.lower(), "")
source = _PERCENT_CODE.sub(percent, source).replace("\x00", "%")
out: list[str] = []
i, n = 0, len(source)
while i < n:
ch = source[i]
if ch == "\\" and i + 1 < n:
code = source[i + 1]
if code in "\\{}":
out.append(code)
i += 2
elif code == "P":
out.append("\n")
i += 2
elif code == "~":
out.append(" ")
i += 2
elif code == "S":
end = source.find(";", i + 2)
end = n if end < 0 else end
out.append(re.sub(r"[\^#]", "/", source[i + 2 : end]))
i = end + 1
elif code in _ARG_CODES:
end = source.find(";", i + 2)
i = n if end < 0 else end + 1
elif code in _TOGGLE_CODES:
i += 2
else:
out.append(code)
i += 2
elif ch in "{}":
i += 1
else:
out.append(ch)
i += 1
return "".join(out).strip()
def parse_diameters(text: str | None) -> tuple[str, ...]:
"""Every size label in ``text``, in order, each once, spelled one way.
``Ø51`` / ``%%c51`` / ``\\U+2205 51`` / ``⌀51`` / ``ø51`` -> ``"Ø51"``;
``Ø25,4`` keeps its comma; ``SMS 51`` -> ``"SMS51"``; ``DN 20`` ->
``"DN20"``; ``20 x 27`` / ``20х27`` (Cyrillic х) -> ``"20x27"``; ``1,5"`` ->
``'1,5"'``. An ``a x b`` followed by mm² is a cable cross-section, not a
pipe, and is skipped.
"""
found: list[str] = []
for match in _DIAMETER_RE.finditer(plain(text)):
if match.group("sign"):
token = "\u00d8" + match.group("sign_n")
elif match.group("sms"):
token = "SMS" + match.group("sms_n")
elif match.group("dn"):
token = "DN" + match.group("dn_n")
elif match.group("a"):
token = f"{match.group('a')}x{match.group('b')}"
else:
token = re.sub(r"\s+", "", match.group("inch")) + '"'
if token not in found:
found.append(token)
return tuple(found)
def fold_lookalikes(text: str) -> str:
"""Uppercase, with the eleven Cyrillic look-alike capitals made Latin."""
return str(text).upper().translate(_LOOKALIKE_TABLE)
def parse_tag(text: str | None) -> str | None:
"""The first equipment tag in ``text``, uppercase, look-alikes folded.
``"Т4100"`` (Cyrillic Т) -> ``"T4100"``; ``"pump M41A"`` -> ``"M41A"``;
``"CP-M82"`` stays ``"CP-M82"``. A size label (``DN20``, ``SMS51``), a
pressure rating (``PN16``) or an IP code (``IP65``) is not a tag; a token
that keeps a Cyrillic letter without a Latin twin is not one either.
"""
for match in _TAG_TOKEN.finditer(plain(text)):
token = fold_lookalikes(match.group(0)).rstrip(".-")
if not _TAG_RE.fullmatch(token):
continue
if re.match(r"[A-Z]+", token).group(0) in _NOT_TAG_PREFIXES:
continue
return token
return None
def normalize_panel(text: str | None) -> str | None:
"""A panel name spelled one way: ``CP 1`` / ``CP1`` / ``cp-1`` -> ``CP-1``.
The letters, one hyphen, then the rest: ``CP M82`` -> ``CP-M82``. Cyrillic
look-alikes are folded first (``СР-1`` typed in Cyrillic is ``CP-1``).
Returns None when the text holds no panel-shaped token.
"""
if not text:
return None
match = _PANEL_RE.search(fold_lookalikes(plain(text)))
if match is None:
return None
return f"{match.group(1)}-{match.group(2)}"
def parse_electrical(text: str | None) -> dict:
"""Power, phases, voltage, neutral and drive type stated in ``text``.
``"P = 18,5 кВт, 3 ф. 400 В + N + PE, ЧРП"`` ->
``{"kw": 18.5, "phases": 3, "voltage": 400, "neutral": True, "vfd": True}``.
What the text does not state stays ``None`` / ``False``: a power that is not
written is never estimated.
"""
body = plain(text)
lowered = body.lower()
kw = _KW_RE.search(body)
phases = _PHASE_RE.search(body)
volts = _VOLT_RE.search(body)
return {
"kw": float(kw.group(1).replace(",", ".")) if kw else None,
"phases": int(phases.group(1)) if phases else None,
"voltage": int(volts.group(1)) if volts else None,
"neutral": bool(_NEUTRAL_RE.search(body)),
"vfd": any(word in lowered for word in _VFD_WORDS),
}
def wiring_target(text: str | None) -> str | None:
"""The panel a wiring callout points to, normalised.
``"Wiring to CP1"`` -> ``"CP-1"``; ``"подключение к шкафу CP-1"`` ->
``"CP-1"``; ``"CP 2 panosuna"`` -> ``"CP-2"`` (Turkish names the panel
first). None when the text has no wiring phrase or no panel beside it.
"""
body = plain(text)
lowered = body.lower()
for phrase in _WIRING_PHRASES:
at = lowered.find(phrase)
if at >= 0:
panel = normalize_panel(body[at + len(phrase) :])
if panel is not None:
return panel
for phrase in _WIRING_PHRASES_AFTER:
at = lowered.find(phrase)
if at > 0:
matches = list(_PANEL_RE.finditer(fold_lookalikes(body[:at])))
if matches:
return f"{matches[-1].group(1)}-{matches[-1].group(2)}"
return None
+420
View File
@@ -0,0 +1,420 @@
"""One bulk read of a drawing into frozen records - the input of every track H reader.
Reading a 35,000-entity P&ID one COM call at a time takes minutes; reading its
DXF takes about a second. So every analysis in ``engineering/understand/`` runs
on a :class:`Snapshot`: the drawing read once, into small frozen
:class:`EntityRecord` s, and never touched again.
Where the snapshot comes from:
* **A DXF file** - :func:`read_snapshot`, ``ezdxf.readfile`` after the size
check (``MAX_DXF_BYTES``, 512 MB by default; the refusal names the size and
the variable).
* **The headless engine's current document** - :func:`records_from_doc` on the
in-memory ``ezdxf`` document, inside the backend's own lock.
* **The live engine's current document** - ``Document.Export(<tmp>/snap, "DXF",
<selection set of all>)``, then :func:`read_snapshot` on the file, which is
deleted afterwards. Measured on AutoCAD 2026 (``25.1s (LMS Tech)``) on
2026-09-24, in a new scratch document closed afterwards:
- the extension argument must be ``"DXF"``; ``".dxf"`` fails with *Invalid
argument*; the file is written as ``snap.DXF`` (upper-case extension);
- the document keeps its name (``Export`` is not ``SaveAs``), and an unsaved
document exports;
- **model space, every paper-space layout and every block definition reach
the snapshot** - a LINE in Layout1 was read back in Layout1, and an unused
block definition was in the BLOCKS section;
- **the selection set does not narrow a DXF export**: an empty selection set
wrote the same model space, paper space and blocks as the set of all. The
set of all is passed anyway, because the ActiveX signature requires one.
- xrefs were not measured, so nothing is claimed for the live engine;
headless, ezdxf reads the host file only. Either way an xref's own
entities are not read in 1.6 - the host drawing is - and
``drawing_understand``'s docstring says so.
A DWG path on the live engine is opened read-only, exported the same way and
closed without saving.
Records hold what the readers need and nothing else: WCS points (OCS is
translated here with ``backends/ocs.py``, so a mirrored ARC or LWPOLYLINE is
where it looks), plain text (``labels.plain``: AutoCAD codes and MTEXT format
runs decoded), block names and attributes, a WCS box. Block definitions are not
exploded: an INSERT is one record with its block name, attributes and box.
"""
from __future__ import annotations
import asyncio
import math
import os
import shutil
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
import ezdxf
from ezdxf import bbox as ezbbox
import config
from backends.capability import UnsupportedCapabilityError
from backends.ocs import to_wcs_2d, wcs_arc_angles, wcs_bulge
from engineering.measure import polygon_area_perimeter
from engineering.understand.labels import plain
__all__ = [
"EntityRecord",
"Pt",
"Snapshot",
"length_of",
"read_snapshot",
"records_from_doc",
"take_snapshot",
]
Pt = tuple[float, float]
#: ezdxf's placeholder for an extents header nobody computed.
_UNSET_EXTENT = 1e19
#: The live export's base name inside its temporary folder.
_SNAP_BASE = "snap"
@dataclass(frozen=True)
class EntityRecord:
"""One entity of a layout, in WCS, as the readers need it."""
handle: str
type: str
layer: str
space: str
points: tuple[Pt, ...] = ()
bulges: tuple[float, ...] = ()
closed: bool = False
radius: float | None = None
angles: tuple[float, float] | None = None
text: str | None = None
height: float | None = None
block: str | None = None
attribs: tuple[tuple[str, str], ...] = ()
bbox: tuple[float, float, float, float] | None = None
rotation: float = 0.0
scale: tuple[float, float] = (1.0, 1.0)
@dataclass(frozen=True)
class Snapshot:
"""A whole drawing, read once."""
source: str
insunits: int
extmin: Pt | None
extmax: Pt | None
layers: dict[str, dict]
layouts: tuple[str, ...]
records: tuple[EntityRecord, ...]
def _xy(point) -> Pt:
return (float(point[0]), float(point[1]))
def _box(entity) -> tuple[float, float, float, float] | None:
"""The entity's WCS box. An INSERT's box is its block geometry only: the
ATTRIB texts are left out, because a TAG lettered beside a tank would push
the tank's box past its body (the rule `engineering/pid/graph.py` keeps)."""
try:
if entity.dxftype() == "INSERT":
box = ezbbox.extents(entity.virtual_entities(), fast=True)
else:
box = ezbbox.extents([entity], fast=True)
except Exception: # a proxy or a malformed entity has no box; the record stays
return None
if not box.has_data:
return None
return (
float(box.extmin.x),
float(box.extmin.y),
float(box.extmax.x),
float(box.extmax.y),
)
def _header_point(doc, name: str) -> Pt | None:
try:
value = doc.header.get(name)
except Exception:
return None
if value is None or abs(float(value[0])) >= _UNSET_EXTENT:
return None
return _xy(value)
def _extrusion(entity):
"""The entity's extrusion, or +Z for a type that has none (MULTILEADER, ...)."""
if entity.dxf.is_supported("extrusion"):
return entity.dxf.get("extrusion", (0.0, 0.0, 1.0))
return (0.0, 0.0, 1.0)
def _polyline(entity) -> tuple[tuple[Pt, ...], tuple[float, ...], bool]:
extrusion = _extrusion(entity)
if entity.dxftype() == "LWPOLYLINE":
elevation = float(entity.dxf.get("elevation", 0.0))
rows = [(float(x), float(y), float(b)) for x, y, b in entity.get_points("xyb")]
points = tuple(tuple(to_wcs_2d(extrusion, x, y, elevation)) for x, y, _ in rows)
bulges = tuple(wcs_bulge(extrusion, b) for _, _, b in rows)
return points, bulges, bool(entity.closed)
vertices = list(entity.vertices)
if entity.is_2d_polyline:
points = tuple(
tuple(to_wcs_2d(extrusion, v.dxf.location.x, v.dxf.location.y, v.dxf.location.z))
for v in vertices
)
bulges = tuple(wcs_bulge(extrusion, v.dxf.get("bulge", 0.0)) for v in vertices)
else: # a 3D polyline is WCS already; its z is dropped
points = tuple(_xy(v.dxf.location) for v in vertices)
bulges = tuple(0.0 for _ in vertices)
return points, bulges, bool(entity.is_closed)
def _record(entity, space: str) -> EntityRecord | None:
kind = entity.dxftype()
dxf = entity.dxf
base = {
"handle": str(dxf.handle),
"type": kind,
"layer": str(dxf.get("layer", "0")),
"space": space,
"bbox": _box(entity),
}
extrusion = _extrusion(entity)
if kind == "LINE":
return EntityRecord(**base, points=(_xy(dxf.start), _xy(dxf.end)))
if kind in ("LWPOLYLINE", "POLYLINE"):
if kind == "POLYLINE" and (entity.is_poly_face_mesh or entity.is_polygon_mesh):
return EntityRecord(**base)
points, bulges, closed = _polyline(entity)
return EntityRecord(**base, points=points, bulges=bulges, closed=closed)
if kind in ("ARC", "CIRCLE"):
centre = tuple(to_wcs_2d(extrusion, dxf.center.x, dxf.center.y, dxf.center.z))
angles = None
if kind == "ARC":
angles = wcs_arc_angles(
extrusion, dxf.center, dxf.radius, dxf.start_angle, dxf.end_angle
)
return EntityRecord(**base, points=(centre,), radius=float(dxf.radius), angles=angles)
if kind == "TEXT":
insert = dxf.insert
return EntityRecord(
**base,
points=(tuple(to_wcs_2d(extrusion, insert.x, insert.y, insert.z)),),
text=plain(dxf.get("text", "")),
height=float(dxf.get("height", 0.0)),
rotation=float(dxf.get("rotation", 0.0)),
)
if kind == "MTEXT":
return EntityRecord(
**base,
points=(_xy(dxf.insert),),
text=plain(entity.text),
height=float(dxf.get("char_height", 0.0)),
rotation=float(entity.get_rotation()),
)
if kind == "INSERT":
insert = dxf.insert
attribs = tuple((str(a.dxf.tag), plain(a.dxf.get("text", ""))) for a in entity.attribs)
return EntityRecord(
**base,
points=(tuple(to_wcs_2d(extrusion, insert.x, insert.y, insert.z)),),
block=str(dxf.name),
attribs=attribs,
rotation=float(dxf.get("rotation", 0.0)),
scale=(float(dxf.get("xscale", 1.0)), float(dxf.get("yscale", 1.0))),
)
if kind == "MULTILEADER":
context = entity.context
points: list[Pt] = []
for leader in context.leaders:
for line in leader.lines:
points.extend(_xy(v) for v in line.vertices)
points.append(_xy(leader.last_leader_point))
text = None
height = None
if context.mtext is not None:
text = plain(context.mtext.default_content)
points.append(_xy(context.mtext.insert))
height = float(context.char_height)
return EntityRecord(**base, points=tuple(points), text=text, height=height)
if kind == "DIMENSION":
override = str(dxf.get("text", "") or "")
text = plain(override) if override not in ("", "<>") else None
middle = dxf.get("text_midpoint", dxf.defpoint)
points = (_xy(dxf.defpoint), tuple(to_wcs_2d(extrusion, middle.x, middle.y, middle.z)))
return EntityRecord(**base, points=points, text=text)
if kind == "LEADER":
return EntityRecord(**base, points=tuple(_xy(v) for v in entity.vertices))
if kind == "POINT":
return EntityRecord(**base, points=(_xy(dxf.location),))
if kind == "SPLINE":
return EntityRecord(**base, points=tuple(_xy(p) for p in entity.control_points))
if kind == "ELLIPSE":
return EntityRecord(**base, points=(_xy(dxf.center),))
return EntityRecord(**base)
def records_from_doc(doc, *, source: str) -> Snapshot:
"""Every entity of every layout of an ``ezdxf`` document, as records.
Model space first, then each paper layout in tab order. ``insunits`` is
``$INSUNITS`` (0 when absent); ``extmin`` / ``extmax`` are the declared
``$EXTMIN`` / ``$EXTMAX``, None when the header holds ezdxf's "never
computed" placeholder. Nothing in ``doc`` is modified.
"""
records: list[EntityRecord] = []
layouts = tuple(doc.layouts.names_in_taborder())
for name in layouts:
for entity in doc.layouts.get(name):
record = _record(entity, name)
if record is not None:
records.append(record)
layers = {
str(layer.dxf.name): {
"color": int(layer.dxf.get("color", 7)),
"linetype": str(layer.dxf.get("linetype", "Continuous")),
}
for layer in doc.layers
}
return Snapshot(
source=source,
insunits=int(doc.header.get("$INSUNITS", 0) or 0),
extmin=_header_point(doc, "$EXTMIN"),
extmax=_header_point(doc, "$EXTMAX"),
layers=layers,
layouts=layouts,
records=tuple(records),
)
def _read(path: str, *, source: str) -> Snapshot:
try:
doc = ezdxf.readfile(path)
except (OSError, ezdxf.DXFError) as exc:
raise ValueError(f"{path} is not a readable DXF file: {exc}") from exc
return records_from_doc(doc, source=source)
def read_snapshot(path: str) -> Snapshot:
"""The snapshot of a DXF file on disk, after the size check.
Refuses a missing file (``FileNotFoundError``) and a file larger than
``MAX_DXF_BYTES`` (``ValueError`` naming the size, the limit and the
variable; 0 disables the check) before reading a byte of it.
"""
target = Path(path)
if not target.is_file():
raise FileNotFoundError(f"{path}: no such file")
limit = int(config.settings.max_dxf_bytes)
size = target.stat().st_size
if limit > 0 and size > limit:
raise ValueError(
f"{path} is {size:,} bytes ({size / 2**20:.1f} MB), over the MAX_DXF_BYTES "
f"limit of {limit:,} bytes ({limit / 2**20:.0f} MB). Raise MAX_DXF_BYTES "
"(0 disables the check) to read it."
)
return _read(str(target), source=str(target))
def _com_export(backend, path: str | None) -> tuple[str, str, str]:
"""(snapshot file, its temporary folder, source) - runs on the COM thread."""
from backends import com_backend as com
app = com._acad_app()
opened = None
if path is None:
doc = com._acad_doc()
else:
opened = app.Documents.Open(str(path), True) # read-only
doc = opened
folder = tempfile.mkdtemp(prefix="acadmcp_snap_")
try:
name = str(backend._wait_out_rejected_call(lambda: doc.Name))
selection = doc.SelectionSets.Add(f"_SNAP_{uuid.uuid4().hex[:8]}")
try:
selection.Select(com._AC_SELECTION_SET_ALL)
# "DXF", never ".dxf": the dotted spelling is refused with
# "Invalid argument" (measured, AutoCAD 2026).
doc.Export(os.path.join(folder, _SNAP_BASE), "DXF", selection)
finally:
try:
selection.Delete()
except Exception: # a selection set left behind is harmless; the export is not
pass
except Exception:
shutil.rmtree(folder, ignore_errors=True)
raise
finally:
if opened is not None:
opened.Close(False)
written = sorted(Path(folder).glob(f"{_SNAP_BASE}.*"))
if not written:
shutil.rmtree(folder, ignore_errors=True)
raise RuntimeError(f"AutoCAD reported the export of {name} done but wrote no file")
return str(written[0]), folder, f"live:{name}"
async def take_snapshot(backend, path: str | None = None) -> Snapshot:
"""The snapshot of ``path``, or of the backend's current document.
``path`` given: a ``.dxf`` is read directly (on either engine); a ``.dwg``
needs the live engine, which opens it read-only, exports it and closes it -
headlessly it is refused with the ``dwg`` capability. ``path`` None: the
headless engine reads its in-memory document under its own lock; the live
engine exports the active document to a temporary DXF (see the module
docstring for what was measured) and deletes it afterwards. The snapshot of
a live document is not size-checked: it is already in AutoCAD's memory.
"""
if path is not None:
suffix = Path(path).suffix.lower()
if suffix == ".dxf":
return await asyncio.to_thread(read_snapshot, str(path))
if suffix != ".dwg":
raise ValueError(f"{path}: a snapshot reads a .dxf or a .dwg, not {suffix or 'this'}")
if not Path(path).is_file():
raise FileNotFoundError(f"{path}: no such file")
if backend.name != "com":
raise UnsupportedCapabilityError(
"dwg",
f"{path} is a DWG, which the headless engine cannot read. Export it to "
"DXF (DXFOUT or SAVEAS in AutoCAD) and pass the .dxf, or run the server "
"on the live engine (AUTOCAD_MCP_BACKEND=com).",
)
if backend.name == "com":
written, folder, source = await backend._run(_com_export, backend, path)
if path is not None:
await backend._ensure_document_state()
try:
return await asyncio.to_thread(_read, written, source=source)
finally:
shutil.rmtree(folder, ignore_errors=True)
source = backend._doc_path or "memory:untitled"
return await backend._async(lambda: records_from_doc(backend._doc, source=source))
def length_of(rec: EntityRecord) -> float:
"""Drawn length in drawing units: LINE, LWPOLYLINE / POLYLINE with their
bulges (``engineering.measure``, the same arithmetic ``analysis_measure_entity``
reports), ARC, CIRCLE. Every other type is 0.0.
"""
if rec.type == "LINE" and len(rec.points) == 2:
return math.dist(rec.points[0], rec.points[1])
if rec.type in ("LWPOLYLINE", "POLYLINE") and len(rec.points) >= 2:
bulges = rec.bulges or tuple(0.0 for _ in rec.points)
vertices = [(x, y, b) for (x, y), b in zip(rec.points, bulges, strict=False)]
return polygon_area_perimeter(vertices, rec.closed)[1]
if rec.type == "ARC" and rec.radius is not None and rec.angles is not None:
sweep = (rec.angles[1] - rec.angles[0]) % 360.0
return rec.radius * math.radians(sweep)
if rec.type == "CIRCLE" and rec.radius is not None:
return 2.0 * math.pi * rec.radius
return 0.0
+451
View File
@@ -0,0 +1,451 @@
"""Multilingual keyword tables for reading foreign drawings: EN / TR / RU / NL / DE.
A foreign drawing says what a layer carries in its name and in the texts on it,
in whatever language the office that drew it speaks: a P&ID may name
its piping layers in English and Dutch (``P_cipsupplyline``,
``KLEPPEN``) and label its lines in Russian. These tables are what lets the
reader tell a CIP supply line from a product line without a human.
They are **vocabulary, not a standard**: words a drafter writes, each with the
language it belongs to. A classification made from them carries a confidence
(0.9 for a direct keyword, 0.6 for weaker evidence) and the keyword that
decided it, so a reader can always see why.
Matching is done on a *folded* spelling - casefolded, diacritics stripped,
Turkish dotless ı made i - so ``DÖNÜŞ``, ``dönüş`` and ``donus`` are one word.
Cyrillic keeps its letters (``подача`` stays ``подача``). A keyword of five
letters or more matches anywhere in a layer name written without separators
(``cipsupplyline`` holds ``supply``); a shorter one must start a word, so
``air`` finds ``air_line`` and not ``stairs``.
"""
from __future__ import annotations
import re
import unicodedata
__all__ = [
"DISCIPLINES",
"EQUIPMENT_KINDS",
"LANGUAGES",
"SERVICES",
"classify_layer",
"equipment_kind",
"fold",
"room_label",
"supply_return",
]
LANGUAGES = ("en", "tr", "ru", "nl", "de")
SERVICES: tuple[str, ...] = (
"product",
"cip_supply",
"cip_return",
"glycol_supply",
"glycol_return",
"steam",
"condensate",
"cold_water",
"hot_water",
"ice_water",
"compressed_air",
"drain",
"electrical",
"unknown",
)
DISCIPLINES: tuple[str, ...] = (
"piping",
"electrical",
"architecture",
"equipment",
"annotation",
"structure",
"unknown",
)
EQUIPMENT_KINDS: tuple[str, ...] = (
"tank",
"pump",
"valve",
"reducer",
"motor",
"agitator",
"panel",
)
#: Confidence of a classification decided by one direct keyword.
DIRECT = 0.9
#: Confidence of a classification from weaker evidence: a CIP or glycol layer
#: whose name says no direction, a generic water layer.
WEAK = 0.6
#: A keyword this long matches anywhere in a separator-free name.
_SUBSTRING_MIN = 5
# (keyword, language) rows. Order matters inside a table: the first row that
# matches decides, so a longer or more specific word comes before a word it
# contains.
_SUPPLY_WORDS = (
("supply", "en"),
("besleme", "tr"),
("gidis", "tr"),
("подач", "ru"), # подача: supply
("подающ", "ru"), # подающий: supplying
("aanvoer", "nl"),
("vorlauf", "de"),
)
_RETURN_WORDS = (
("return", "en"),
("donus", "tr"),
("возврат", "ru"), # return
("обрат", "ru"), # обратка / обратный: return
("retour", "nl"),
("rucklauf", "de"),
)
_CIP_WORDS = (
("cip", "en"),
("мойк", "ru"), # мойка: washing, CIP
)
_GLYCOL_WORDS = (
("glycol", "en"),
("glikol", "tr"),
("гликол", "ru"), # гликоль: glycol
("glykol", "de"),
)
#: Services a layer name can state directly (CIP and glycol are decided with
#: their direction words above).
_SERVICE_WORDS = (
("product", "product", "en"),
("product", "urun", "tr"),
("product", "продукт", "ru"), # product
("product", "produkt", "de"),
("steam", "steam", "en"),
("steam", "buhar", "tr"),
("steam", "пар", "ru"), # steam
("steam", "stoom", "nl"),
("steam", "dampf", "de"),
("condensate", "condensate", "en"),
("condensate", "kondensat", "de"),
("condensate", "kondens", "tr"),
("condensate", "конденсат", "ru"), # condensate
("condensate", "condensaat", "nl"),
("ice_water", "ice water", "en"),
("ice_water", "buzlu su", "tr"),
("ice_water", "ледяная вода", "ru"),
("ice_water", "ijswater", "nl"),
("ice_water", "eiswasser", "de"),
("hot_water", "hot water", "en"),
("hot_water", "sicak su", "tr"),
("hot_water", "горячая вода", "ru"),
("hot_water", "warm water", "nl"),
("hot_water", "warmwasser", "de"),
("cold_water", "cold water", "en"),
("cold_water", "soguk su", "tr"),
("cold_water", "холодная вода", "ru"),
("cold_water", "koud water", "nl"),
("cold_water", "kaltwasser", "de"),
("compressed_air", "compressed air", "en"),
("compressed_air", "basincli hava", "tr"),
("compressed_air", "сжатый воздух", "ru"),
("compressed_air", "perslucht", "nl"),
("compressed_air", "druckluft", "de"),
("compressed_air", "air", "en"),
("compressed_air", "hava", "tr"),
("compressed_air", "воздух", "ru"), # air
("drain", "drain", "en"),
("drain", "sewer", "en"),
("drain", "drenaj", "tr"),
("drain", "atik su", "tr"),
("drain", "дренаж", "ru"), # drainage
("drain", "канализац", "ru"), # канализация: sewer
("drain", "riool", "nl"),
("drain", "abwasser", "de"),
)
_ELECTRICAL_WORDS = (
("electrical", "en"),
("electric", "en"),
("power", "en"),
("cable", "en"),
("elektrik", "tr"),
("kablo", "tr"),
("электр", "ru"), # электрика: electrical
("кабел", "ru"), # кабель: cable
("elektra", "nl"),
("kabel", "nl"),
("elektro", "de"),
)
#: Water words that name no temperature: a piping layer of unknown service.
_WATER_WORDS = (
("water", "en"),
("вода", "ru"), # water
("wasser", "de"),
)
_DISCIPLINE_WORDS = (
("piping", "piping", "en"),
("piping", "pipe", "en"),
("piping", "valve", "en"),
("piping", "boru", "tr"),
("piping", "tesisat", "tr"),
("piping", "vana", "tr"),
("piping", "труб", "ru"), # трубопровод: pipeline
("piping", "клапан", "ru"), # valve
("piping", "арматур", "ru"), # арматура: valves and fittings
("piping", "leiding", "nl"),
("piping", "klep", "nl"),
("piping", "afsluiter", "nl"),
("piping", "rohr", "de"),
("piping", "ventil", "de"),
("piping", "armatur", "de"),
("architecture", "wall", "en"),
("architecture", "door", "en"),
("architecture", "window", "en"),
("architecture", "duvar", "tr"),
("architecture", "kapi", "tr"),
("architecture", "pencere", "tr"),
("architecture", "стен", "ru"), # стена: wall
("architecture", "двер", "ru"), # дверь: door
("architecture", "окн", "ru"), # окно: window
("architecture", "muur", "nl"),
("architecture", "muren", "nl"),
("architecture", "deur", "nl"),
("architecture", "raam", "nl"),
("architecture", "wand", "de"),
("architecture", "fenster", "de"),
("equipment", "equipment", "en"),
("equipment", "tank", "en"),
("equipment", "pump", "en"),
("equipment", "ekipman", "tr"),
("equipment", "pompa", "tr"),
("equipment", "оборудован", "ru"), # оборудование: equipment
("equipment", "насос", "ru"), # pump
("equipment", "apparatuur", "nl"),
("equipment", "pomp", "nl"),
("equipment", "ausrustung", "de"),
("equipment", "pumpe", "de"),
("annotation", "text", "en"),
("annotation", "dim", "en"),
("annotation", "anno", "en"),
("annotation", "note", "en"),
("annotation", "title", "en"),
("annotation", "yazi", "tr"),
("annotation", "olcu", "tr"),
("annotation", "antet", "tr"),
("annotation", "текст", "ru"), # text
("annotation", "размер", "ru"), # dimension
("annotation", "штамп", "ru"), # title block
("annotation", "tekst", "nl"),
("annotation", "maat", "nl"),
("annotation", "bemassung", "de"),
("structure", "column", "en"),
("structure", "beam", "en"),
("structure", "steel", "en"),
("structure", "grid", "en"),
("structure", "kolon", "tr"),
("structure", "kiris", "tr"),
("structure", "celik", "tr"),
("structure", "aks", "tr"),
("structure", "колонн", "ru"), # колонна: column
("structure", "балк", "ru"), # балка: beam
("structure", "kolom", "nl"),
("structure", "balk", "nl"),
("structure", "staal", "nl"),
("structure", "stutze", "de"),
("structure", "trager", "de"),
("structure", "stahl", "de"),
)
_ROOM_WORDS = (
("room", "en"),
("oda", "tr"),
("mahal", "tr"),
("помещ", "ru"), # помещение: room
("комнат", "ru"), # комната: room
("ruimte", "nl"),
("kamer", "nl"),
("raum", "de"),
("zimmer", "de"),
)
_EQUIPMENT_KIND_WORDS = (
("reducer", "reducer", "en"),
("reducer", "reduksiyon", "tr"),
("reducer", "переход", "ru"), # reducer
("reducer", "verloop", "nl"),
("reducer", "reduzier", "de"),
("valve", "valve", "en"),
("valve", "vlv", "en"),
("valve", "vana", "tr"),
("valve", "клапан", "ru"), # valve
("valve", "кран", "ru"), # valve (cock)
("valve", "klep", "nl"),
("valve", "afsluiter", "nl"),
("valve", "ventil", "de"),
("valve", "armatur", "de"),
("agitator", "agitator", "en"),
("agitator", "mixer", "en"),
("agitator", "karistirici", "tr"),
("agitator", "мешалк", "ru"), # мешалка: agitator
("agitator", "roerwerk", "nl"),
("agitator", "ruhrwerk", "de"),
("pump", "pump", "en"),
("pump", "pompa", "tr"),
("pump", "насос", "ru"), # pump
("pump", "pomp", "nl"),
("pump", "pumpe", "de"),
("tank", "tank", "en"),
("tank", "vessel", "en"),
("tank", "емкост", "ru"), # емкость: vessel
("tank", "бак", "ru"), # tank
("tank", "vat", "nl"),
("tank", "behalter", "de"),
("motor", "motor", "en"),
("motor", "мотор", "ru"), # motor
("motor", "электродвиг", "ru"), # электродвигатель: electric motor
("panel", "panel", "en"),
("panel", "cabinet", "en"),
("panel", "pano", "tr"),
("panel", "шкаф", "ru"), # cabinet
("panel", "щит", "ru"), # switchboard
("panel", "schakelkast", "nl"),
("panel", "kast", "nl"),
("panel", "schrank", "de"),
)
def fold(text: str | None) -> str:
"""Casefolded, diacritics stripped, Turkish ı made i; Cyrillic letters kept."""
if not text:
return ""
decomposed = unicodedata.normalize("NFKD", str(text).casefold().replace("ı", "i"))
return "".join(ch for ch in decomposed if not unicodedata.combining(ch))
def _words(folded: str) -> list[str]:
return [w for w in re.split(r"[^\w]+|_|\d+", folded) if w]
def _compact(folded: str) -> str:
return "".join(w for w in _words(folded))
def _matches(keyword: str, folded: str) -> bool:
key = fold(keyword)
if " " in key or len(key) >= _SUBSTRING_MIN:
return key.replace(" ", "") in _compact(folded)
return any(word.startswith(key) for word in _words(folded))
def _first(rows, folded: str):
for row in rows:
if _matches(row[-2], folded):
return row
return None
def _direction(folded: str) -> tuple[str, str, str] | None:
"""("supply" | "return", keyword, language), or None when neither or both."""
supply = _first(_SUPPLY_WORDS, folded)
ret = _first(_RETURN_WORDS, folded)
if (supply is None) == (ret is None):
return None
if supply is not None:
return ("supply", *supply)
return ("return", *ret)
def _result(discipline, service, confidence, keyword, language) -> dict:
return {
"discipline": discipline,
"service": service,
"confidence": confidence,
"keyword": keyword,
"language": language,
}
def classify_layer(name: str) -> dict:
"""What a layer most likely carries, decided by the words in its name.
``{"discipline", "service", "confidence", "keyword", "language"}``. Order:
electrical words; CIP and glycol with a direction word (``P_cipsupplyline``
-> ``cip_supply``; without one, the service stays ``unknown`` at 0.6 - the
layer alone is not trusted to say which way a CIP line runs); the stated
services; a water word with no temperature (piping, ``unknown``, 0.6); the
discipline words. Nothing matched: ``unknown`` / ``unknown`` at 0.0.
"""
folded = fold(name)
row = _first(_ELECTRICAL_WORDS, folded)
if row is not None:
return _result("electrical", "electrical", DIRECT, *row)
for rows, stem in ((_CIP_WORDS, "cip"), (_GLYCOL_WORDS, "glycol")):
row = _first(rows, folded)
if row is None:
continue
direction = _direction(folded)
if direction is None:
return _result("piping", "unknown", WEAK, *row)
return _result("piping", f"{stem}_{direction[0]}", DIRECT, row[0], row[1])
row = _first(_SERVICE_WORDS, folded)
if row is not None:
return _result("piping", row[0], DIRECT, row[1], row[2])
row = _first(_WATER_WORDS, folded)
if row is not None:
return _result("piping", "unknown", WEAK, *row)
row = _first(_DISCIPLINE_WORDS, folded)
if row is not None:
return _result(row[0], "unknown", DIRECT, row[1], row[2])
return _result("unknown", "unknown", 0.0, None, None)
def supply_return(text: str | None) -> str | None:
"""``"supply"`` or ``"return"`` when the text says one of them, else None.
``ПОДАЧА`` / ``BESLEME`` / ``SUPPLY`` / ``AANVOER`` / ``VORLAUF`` are supply;
``ВОЗВРАТ`` / ``DÖNÜŞ`` / ``RETURN`` / ``RETOUR`` / ``RÜCKLAUF`` return. A
text naming both (``CIP SUPPLY / RETURN``) says neither and is None.
"""
direction = _direction(fold(text))
return None if direction is None else direction[0]
def room_label(text: str | None) -> dict | None:
"""A room label read: ``{"number", "name", "language"}``, or None.
A text is a room label when one of its words is a room word (*room*,
*oda*, *mahal*, *помещение*, *комната*, *ruimte*, *kamer*, *Raum*,
*Zimmer*). The number is the first word holding a digit; the name is what
is left, in the text's own spelling, or None. ``"ROOM 1 PROCESS HALL"`` ->
``{"number": "1", "name": "PROCESS HALL", "language": "en"}``.
"""
if not text:
return None
words = str(text).split()
for index, word in enumerate(words):
folded = fold(word)
for keyword, language in _ROOM_WORDS:
if folded.startswith(fold(keyword)):
rest = words[:index] + words[index + 1 :]
number = next((w for w in rest if any(ch.isdigit() for ch in w)), None)
name = " ".join(w for w in rest if w != number).strip() or None
return {"number": number, "name": name, "language": language}
return None
def equipment_kind(block_name: str | None) -> dict | None:
"""The kind of equipment a block name says it is: ``{"kind", "keyword", "language"}``.
``KLEP_VLINDER`` -> valve (nl); ``VERLOOPSTUK`` -> reducer (nl); an
anonymous ``*U12`` or a name with no keyword -> None.
"""
folded = fold(block_name)
row = _first(_EQUIPMENT_KIND_WORDS, folded)
if row is None:
return None
return {"kind": row[0], "keyword": row[1], "language": row[2]}
+7
View File
@@ -88,6 +88,12 @@ pdf = [
# Also satisfies ezdxf's hard Pillow requirement, transitively.
"matplotlib>=3.8",
]
office = [
# XLSX output: the takeoff workbooks (pipe_takeoff / cable_takeoff) and
# data_extract(format="xlsx"). Without it every one of them still writes
# CSV and refuses XLSX with the `xlsx_write` capability, naming this extra.
"openpyxl>=3.1",
]
full = [
# Same marker as `com` above, and for the same reason: `[full]` is the
# extra this project recommends to contributors, and it was
@@ -95,6 +101,7 @@ full = [
'pywin32>=306; sys_platform == "win32"',
"Pillow>=10.0",
"matplotlib>=3.8",
"openpyxl>=3.1",
]
[project.scripts]
+30
View File
@@ -0,0 +1,30 @@
"""The `office` extra: openpyxl for the XLSX workbooks, and in `full` too.
Declared and locked is what is tested - not installed: the CI lane that
installs `[pdf]` has no openpyxl and must stay green, because every XLSX
writer refuses with the `xlsx_write` capability and writes CSV instead.
"""
from __future__ import annotations
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _extras() -> dict:
with open(ROOT / "pyproject.toml", "rb") as handle:
return tomllib.load(handle)["project"]["optional-dependencies"]
def test_the_office_extra_carries_openpyxl_and_full_carries_it_too():
extras = _extras()
assert extras["office"] == ["openpyxl>=3.1"]
assert "openpyxl>=3.1" in extras["full"]
def test_the_lock_records_openpyxl_for_the_office_extra():
lock = (ROOT / "uv.lock").read_text(encoding="utf-8")
assert '\nname = "openpyxl"\n' in lock
assert '{ name = "openpyxl" }' in lock
+238
View File
@@ -0,0 +1,238 @@
"""Label parsers: every spelling a foreign drawing uses, read one way.
Review Focus 1 (AutoCAD text codes) and 2 (Cyrillic look-alike tags) of the
track H plan live here: one test per spelling, because a parser that handles
four of five codes fails silently on the fifth drawing.
"""
from __future__ import annotations
import pytest
from engineering.understand.labels import (
CYRILLIC_LOOKALIKES,
normalize_panel,
parse_diameters,
parse_electrical,
parse_tag,
plain,
wiring_target,
)
# -- plain(): AutoCAD codes and MTEXT format runs -------------------------------
def test_plain_decodes_the_percent_codes():
assert plain("%%c51") == "\u00d851"
assert plain("90%%d") == "90\u00b0"
assert plain("%%p0.1") == "\u00b10.1"
assert plain("50%%%") == "50%"
assert plain("%%uUNDER%%u") == "UNDER"
assert plain("%%065") == "A"
def test_plain_decodes_a_unicode_escape():
assert plain("\\U+2205 51") == "\u2205 51"
assert plain("\\u+00D8") == "\u00d8"
def test_plain_drops_mtext_format_runs_and_keeps_the_content():
assert plain("{\\fArial|b0|i0|c0|p34;\u00d851}") == "\u00d851"
assert plain("\\H2.5;\\C1;SMS51") == "SMS51"
assert plain("\\A1;\\W0.8;\\Q15;\\T1.1;DN20") == "DN20"
assert plain("\\LUNDERLINED\\l") == "UNDERLINED"
def test_plain_turns_paragraphs_spaces_and_stacks_into_text():
assert plain("PUMP\\PM11") == "PUMP\nM11"
assert plain("a\\~b") == "a b"
assert plain("\\S1/2;") == "1/2"
assert plain("\\S+0.1^-0.2;") == "+0.1/-0.2"
def test_plain_keeps_escaped_backslashes_and_braces():
assert plain("\\\\P") == "\\P"
assert plain("\\{x\\}") == "{x}"
def test_plain_of_nothing_is_empty():
assert plain(None) == ""
assert plain("") == ""
# -- parse_diameters(): Review Focus 1, one spelling per test --------------------
def test_the_autocad_percent_code_is_a_diameter():
assert parse_diameters("%%c51") == ("\u00d851",)
def test_a_unicode_escaped_diameter_sign_is_a_diameter():
assert parse_diameters("\\U+2205 51") == ("\u00d851",)
assert parse_diameters("\\U+220551") == ("\u00d851",)
def test_the_diameter_sign_u2300_is_a_diameter():
assert parse_diameters("\u230051") == ("\u00d851",)
def test_a_lowercase_o_stroke_is_a_diameter():
assert parse_diameters("\u00f851") == ("\u00d851",)
def test_a_diameter_inside_an_mtext_format_run_is_a_diameter():
assert parse_diameters("{\\fArial|b0|i0|c0|p34;\u00d851}") == ("\u00d851",)
assert parse_diameters("{\\fArial|b0;\\U+2205}51") == ("\u00d851",)
def test_a_diameter_after_a_paragraph_break_is_found():
assert parse_diameters("PRODUCT\\P%%c51") == ("\u00d851",)
def test_labels_are_kept_as_drawn_and_never_equated():
assert parse_diameters("\u00d825,4") == ("\u00d825,4",)
assert parse_diameters("\u00d825") == ("\u00d825",)
assert parse_diameters("SMS 51") == ("SMS51",)
assert parse_diameters("dn 20") == ("DN20",)
assert parse_diameters("DN-25") == ("DN25",)
assert parse_diameters("20 x 27") == ("20x27",)
assert parse_diameters("20\u044527") == ("20x27",) # Cyrillic х
assert parse_diameters('1,5"') == ('1,5"',)
assert parse_diameters("1/2\u2033") == ('1/2"',)
def test_several_labels_in_one_text_come_in_order_and_once():
assert parse_diameters("\u00d851 / SMS51 / \u00d851") == ("\u00d851", "SMS51")
def test_a_cable_cross_section_is_not_a_pipe_size():
assert parse_diameters("5x4 mm\u00b2") == ()
assert parse_diameters("3x2,5 mm2") == ()
def test_text_without_a_size_yields_nothing():
assert parse_diameters("CIP SUPPLY") == ()
assert parse_diameters("T101") == ()
# -- parse_tag(): Review Focus 2, one look-alike per test -------------------------
@pytest.mark.parametrize(("cyrillic", "latin"), sorted(CYRILLIC_LOOKALIKES.items()))
def test_every_cyrillic_lookalike_folds_to_its_latin_twin(cyrillic, latin):
assert parse_tag(f"{cyrillic}101") == f"{latin}101"
def test_a_cyrillic_t_tag_matches_the_latin_one():
assert parse_tag("\u04224100") == "T4100"
def test_a_cyrillic_m_tag_matches_the_latin_one():
assert parse_tag("\u041c82") == "M82"
def test_a_tag_with_a_cyrillic_prefix_and_a_hyphen():
assert parse_tag("\u0421\u0420-1") == "CP-1" # С Р typed in Cyrillic
def test_a_russian_word_is_never_folded_into_a_tag():
assert parse_tag("\u041f\u041e\u0414\u0410\u0427\u0410") is None # ПОДАЧА
assert parse_tag("\u041f\u041e\u04171") is None # ПОЗ1: П has no Latin twin
def test_tag_shapes():
assert parse_tag("T4100") == "T4100"
assert parse_tag("pump m41a") == "M41A"
assert parse_tag("M47.1") == "M47.1"
assert parse_tag("CP-1") == "CP-1"
assert parse_tag("CP-M82") == "CP-M82"
assert parse_tag("JB-2.") == "JB-2"
assert parse_tag("CIP TANK T101") == "T101"
def test_sizes_ratings_and_ip_codes_are_not_tags():
assert parse_tag("DN20") is None
assert parse_tag("SMS51") is None
assert parse_tag("PN16") is None
assert parse_tag("IP65") is None
assert parse_tag("DN20 T101") == "T101"
def test_text_without_a_tag():
assert parse_tag("ROOM 1 PROCESS HALL") is None
assert parse_tag(None) is None
# -- panels and wiring callouts ----------------------------------------------------
def test_panel_spellings_become_one():
assert normalize_panel("CP 1") == "CP-1"
assert normalize_panel("CP1") == "CP-1"
assert normalize_panel("cp-1") == "CP-1"
assert normalize_panel("CP M82") == "CP-M82"
assert normalize_panel("\u0421\u0420 2") == "CP-2"
assert normalize_panel("PANEL") is None
assert normalize_panel("") is None
def test_wiring_callouts_in_four_languages():
assert wiring_target("Wiring to CP1") == "CP-1"
assert wiring_target("WIRING TO CP 2") == "CP-2"
assert wiring_target("wiring to the CP-M82") == "CP-M82"
# подключение к шкафу CP-1
assert (
wiring_target(
"\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 "
"\u043a \u0448\u043a\u0430\u0444\u0443 CP-1"
)
== "CP-1"
)
assert wiring_target("bedrading naar CP3") == "CP-3"
assert wiring_target("CP 2 panosuna") == "CP-2"
def test_a_callout_without_a_wiring_phrase_names_no_panel():
assert wiring_target("CP1") is None
assert wiring_target("Wiring to") is None
# -- electrical data ---------------------------------------------------------------
def test_russian_electrical_text():
# P = 18,5 кВт, 3 ф. 400 В + N + PE, ЧРП
text = "P = 18,5 \u043a\u0412\u0442, 3 \u0444. 400 \u0412 + N + PE, \u0427\u0420\u041f"
assert parse_electrical(text) == {
"kw": 18.5,
"phases": 3,
"voltage": 400,
"neutral": True,
"vfd": True,
}
def test_english_and_turkish_electrical_text():
assert parse_electrical("5.5 kW 3ph 400V VFD") == {
"kw": 5.5,
"phases": 3,
"voltage": 400,
"neutral": False,
"vfd": True,
}
assert parse_electrical("2,2 kW 3 faz 400 V frekans konvert\u00f6r\u00fc")["vfd"] is True
assert parse_electrical("3/N/PE ~ 400 V")["neutral"] is True
assert parse_electrical("24 VDC")["voltage"] == 24
def test_a_power_that_is_not_written_is_never_invented():
# Нагреватель, 3 ф. 400 В
heater = parse_electrical(
"\u041d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044c, 3 \u0444. 400 \u0412"
)
assert heater["kw"] is None
assert heater["phases"] == 3 and heater["voltage"] == 400
def test_energy_is_not_power():
assert parse_electrical("1200 kWh")["kw"] is None
+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",)
+234
View File
@@ -0,0 +1,234 @@
"""The multilingual vocabulary: layer names, direction words, room labels, block kinds.
Every layer name below is one a drafter would write; the ones in the synthetic
plant pair (tests/fixtures/plant_pair.py) are here too, because that is what the
readers above this module are measured on.
"""
from __future__ import annotations
import pytest
from engineering.understand.vocab import (
DISCIPLINES,
EQUIPMENT_KINDS,
LANGUAGES,
SERVICES,
classify_layer,
equipment_kind,
fold,
room_label,
supply_return,
)
def test_the_closed_lists():
assert LANGUAGES == ("en", "tr", "ru", "nl", "de")
assert SERVICES == (
"product",
"cip_supply",
"cip_return",
"glycol_supply",
"glycol_return",
"steam",
"condensate",
"cold_water",
"hot_water",
"ice_water",
"compressed_air",
"drain",
"electrical",
"unknown",
)
assert DISCIPLINES == (
"piping",
"electrical",
"architecture",
"equipment",
"annotation",
"structure",
"unknown",
)
assert EQUIPMENT_KINDS == ("tank", "pump", "valve", "reducer", "motor", "agitator", "panel")
def test_fold_strips_diacritics_and_the_turkish_dotless_i():
assert fold("DÖNÜŞ") == "donus"
assert fold("GİDİŞ") == "gidis"
assert fold("çıkış") == "cikis"
assert fold("RückLauf") == "rucklauf"
assert fold("ПОДАЧА") == "подача"
assert fold(None) == ""
@pytest.mark.parametrize(
("name", "service", "keyword", "language"),
[
("P_product piping", "product", "product", "en"),
("P_cipsupplyline", "cip_supply", "cip", "en"),
("P_cipreturnline", "cip_return", "cip", "en"),
("P_ijswater", "ice_water", "ijswater", "nl"),
("CIP ВОЗВРАТ", "cip_return", "cip", "en"),
("glikol dönüş", "glycol_return", "glikol", "tr"),
("GLYKOL-VORLAUF", "glycol_supply", "glykol", "de"),
("buhar hattı", "steam", "buhar", "tr"),
("КОНДЕНСАТ", "condensate", "конденсат", "ru"),
("P_ICE WATER", "ice_water", "ice water", "en"),
("sıcak su", "hot_water", "sicak su", "tr"),
("DRUCKLUFT", "compressed_air", "druckluft", "de"),
("air_line", "compressed_air", "air", "en"),
("RIOOL", "drain", "riool", "nl"),
],
)
def test_service_layers(name, service, keyword, language):
row = classify_layer(name)
assert row["service"] == service
assert row["discipline"] == "piping"
assert row["confidence"] == 0.9
assert row["keyword"] == keyword
assert row["language"] == language
def test_electrical_layers_are_their_own_service():
for name in ("E_power", "ELEKTRIK", "ЭЛЕКТРИКА", "kabel"):
row = classify_layer(name)
assert (row["discipline"], row["service"], row["confidence"]) == (
"electrical",
"electrical",
0.9,
), name
def test_a_cip_layer_without_a_direction_is_not_guessed():
row = classify_layer("P_CIP")
assert row == {
"discipline": "piping",
"service": "unknown",
"confidence": 0.6,
"keyword": "cip",
"language": "en",
}
def test_a_cip_layer_naming_both_directions_is_not_guessed_either():
assert classify_layer("CIP supply return")["service"] == "unknown"
def test_a_water_layer_with_no_temperature_is_piping_of_unknown_service():
row = classify_layer("P_WATER")
assert (row["discipline"], row["service"], row["confidence"]) == ("piping", "unknown", 0.6)
@pytest.mark.parametrize(
("name", "discipline", "language"),
[
("KLEPPEN", "piping", "nl"),
("VANALAR", "piping", "tr"),
("WALLS", "architecture", "en"),
("MUREN", "architecture", "nl"),
("СТЕНЫ", "architecture", "ru"),
("EQUIPMENT", "equipment", "en"),
("POMPALAR", "equipment", "tr"),
("DIM", "annotation", "en"),
("ÖLÇÜ", "annotation", "tr"),
("KOLONLAR", "structure", "tr"),
],
)
def test_discipline_layers(name, discipline, language):
row = classify_layer(name)
assert (row["discipline"], row["service"], row["confidence"], row["language"]) == (
discipline,
"unknown",
0.9,
language,
)
def test_a_short_keyword_must_start_a_word():
assert classify_layer("STAIRS")["service"] != "compressed_air"
assert classify_layer("CHAIRS")["discipline"] == "unknown"
def test_a_layer_with_no_keyword():
assert classify_layer("0") == {
"discipline": "unknown",
"service": "unknown",
"confidence": 0.0,
"keyword": None,
"language": None,
}
assert classify_layer("Defpoints")["discipline"] == "unknown"
@pytest.mark.parametrize(
("text", "direction"),
[
("ПОДАЧА", "supply"),
("ВОЗВРАТ", "return"),
("обратка CIP", "return"), # обратка
("BESLEME", "supply"),
("DÖNÜŞ", "return"),
("donus hatti", "return"),
("CIP SUPPLY", "supply"),
("return line", "return"),
("AANVOER", "supply"),
("RETOUR", "return"),
("Vorlauf", "supply"),
("RÜCKLAUF", "return"),
],
)
def test_supply_and_return_words_in_five_languages(text, direction):
assert supply_return(text) == direction
def test_a_text_naming_both_or_neither_direction_is_none():
assert supply_return("CIP SUPPLY / RETURN") is None
assert supply_return("Ø51") is None
assert supply_return(None) is None
@pytest.mark.parametrize(
("text", "expected"),
[
("ROOM 1 PROCESS HALL", {"number": "1", "name": "PROCESS HALL", "language": "en"}),
("ODA 2", {"number": "2", "name": None, "language": "tr"}),
("MAHAL 1.05 SOĞUK ODA", {"number": "1.05", "name": "SOĞUK ODA", "language": "tr"}),
(
"ПОМЕЩЕНИЕ 2 МОЙКА",
{"number": "2", "name": "МОЙКА", "language": "ru"},
),
("RUIMTE 3 opslag", {"number": "3", "name": "opslag", "language": "nl"}),
("Raum 4", {"number": "4", "name": None, "language": "de"}),
],
)
def test_room_labels_in_five_languages(text, expected):
assert room_label(text) == expected
def test_a_text_without_a_room_word_is_not_a_room_label():
assert room_label("T101") is None
assert room_label("PROCESS HALL") is None
assert room_label("") is None
@pytest.mark.parametrize(
("block", "kind", "language"),
[
("KLEP_VLINDER", "valve", "nl"),
("VERLOOPSTUK", "reducer", "nl"),
("REDUCER_CONC", "reducer", "en"),
("TANK_V", "tank", "en"),
("POMP", "pump", "nl"),
("SCHAKELKAST", "panel", "nl"),
("НАСОС", "pump", "ru"),
("VANA_KELEBEK", "valve", "tr"),
],
)
def test_block_names_say_their_kind(block, kind, language):
row = equipment_kind(block)
assert row["kind"] == kind and row["language"] == language
def test_an_anonymous_block_has_no_kind():
assert equipment_kind("*U12") is None
assert equipment_kind(None) is None
Generated
+28 -1
View File
@@ -82,9 +82,13 @@ com = [
]
full = [
{ name = "matplotlib" },
{ name = "openpyxl" },
{ name = "pillow" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
]
office = [
{ name = "openpyxl" },
]
pdf = [
{ name = "matplotlib" },
]
@@ -103,13 +107,15 @@ requires-dist = [
{ name = "fastmcp", specifier = ">=3.4.5,<4" },
{ name = "matplotlib", marker = "extra == 'full'", specifier = ">=3.8" },
{ name = "matplotlib", marker = "extra == 'pdf'", specifier = ">=3.8" },
{ name = "openpyxl", marker = "extra == 'full'", specifier = ">=3.1" },
{ name = "openpyxl", marker = "extra == 'office'", specifier = ">=3.1" },
{ name = "pillow", marker = "extra == 'com'", specifier = ">=10.0" },
{ name = "pillow", marker = "extra == 'full'", specifier = ">=10.0" },
{ name = "pydantic", specifier = ">=2.0,<3" },
{ name = "pywin32", marker = "sys_platform == 'win32' and extra == 'com'", specifier = ">=306" },
{ name = "pywin32", marker = "sys_platform == 'win32' and extra == 'full'", specifier = ">=306" },
]
provides-extras = ["com", "pdf", "full"]
provides-extras = ["com", "pdf", "office", "full"]
[package.metadata.requires-dev]
dev = [
@@ -592,6 +598,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@@ -1344,6 +1359,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.44.0"