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:
co-authored by
Claude Opus 5.5
parent
f0c50d772f
commit
9ab0ae1773
+2
-2
@@ -85,8 +85,8 @@ ENABLE_3D=false
|
|||||||
# These four are read by config.py but were missing from this file, which made
|
# 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.
|
# 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.
|
# Reject DXF input larger than this many bytes (512 MB). 0 disables the check.
|
||||||
MAX_DXF_BYTES=52428800
|
MAX_DXF_BYTES=536870912
|
||||||
|
|
||||||
# Upper bound on list/selection response sizes.
|
# Upper bound on list/selection response sizes.
|
||||||
MAX_LIST_LIMIT=5000
|
MAX_LIST_LIMIT=5000
|
||||||
|
|||||||
@@ -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 |
|
| `ALLOWED_PATHS` | *(empty)* | Comma-separated absolute paths the server may access |
|
||||||
| `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots |
|
| `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots |
|
||||||
| `EZDXF_UNDO_DEPTH` | `0` | Headless undo history depth; `0` disables it |
|
| `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 |
|
| `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes |
|
||||||
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (s); `0` disables |
|
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (s); `0` disables |
|
||||||
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless timeout (s); `0` disables |
|
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless timeout (s); `0` disables |
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from pathlib import Path
|
|||||||
#: CAD this project is developed and tested against.
|
#: CAD this project is developed and tested against.
|
||||||
DEFAULT_CAD_PROGID = "AutoCAD.Application"
|
DEFAULT_CAD_PROGID = "AutoCAD.Application"
|
||||||
|
|
||||||
|
#: Default ``MAX_DXF_BYTES``: 512 MB.
|
||||||
|
DEFAULT_MAX_DXF_BYTES = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
"""Server configuration loaded from environment variables."""
|
"""Server configuration loaded from environment variables."""
|
||||||
@@ -62,9 +65,12 @@ class Settings:
|
|||||||
os.environ.get("DANGEROUS_COMMANDS_ENABLED", "false").lower().strip() == "true"
|
os.environ.get("DANGEROUS_COMMANDS_ENABLED", "false").lower().strip() == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reject opening DXF files larger than this many bytes (default 50 MB).
|
# Reject opening DXF files larger than this many bytes (default 512 MB).
|
||||||
# Set to 0 to disable the check.
|
# Set to 0 to disable the check. It was 50 MB until v1.6's track H: a
|
||||||
self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(50 * 1024 * 1024)))
|
# 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.
|
# 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"))
|
self.max_list_limit: int = int(os.environ.get("MAX_LIST_LIMIT", "5000"))
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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]}
|
||||||
@@ -88,6 +88,12 @@ pdf = [
|
|||||||
# Also satisfies ezdxf's hard Pillow requirement, transitively.
|
# Also satisfies ezdxf's hard Pillow requirement, transitively.
|
||||||
"matplotlib>=3.8",
|
"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 = [
|
full = [
|
||||||
# Same marker as `com` above, and for the same reason: `[full]` is the
|
# Same marker as `com` above, and for the same reason: `[full]` is the
|
||||||
# extra this project recommends to contributors, and it was
|
# extra this project recommends to contributors, and it was
|
||||||
@@ -95,6 +101,7 @@ full = [
|
|||||||
'pywin32>=306; sys_platform == "win32"',
|
'pywin32>=306; sys_platform == "win32"',
|
||||||
"Pillow>=10.0",
|
"Pillow>=10.0",
|
||||||
"matplotlib>=3.8",
|
"matplotlib>=3.8",
|
||||||
|
"openpyxl>=3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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",)
|
||||||
@@ -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
|
||||||
@@ -82,9 +82,13 @@ com = [
|
|||||||
]
|
]
|
||||||
full = [
|
full = [
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
|
office = [
|
||||||
|
{ name = "openpyxl" },
|
||||||
|
]
|
||||||
pdf = [
|
pdf = [
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
]
|
]
|
||||||
@@ -103,13 +107,15 @@ requires-dist = [
|
|||||||
{ name = "fastmcp", specifier = ">=3.4.5,<4" },
|
{ name = "fastmcp", specifier = ">=3.4.5,<4" },
|
||||||
{ name = "matplotlib", marker = "extra == 'full'", specifier = ">=3.8" },
|
{ name = "matplotlib", marker = "extra == 'full'", specifier = ">=3.8" },
|
||||||
{ name = "matplotlib", marker = "extra == 'pdf'", 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 == 'com'", specifier = ">=10.0" },
|
||||||
{ name = "pillow", marker = "extra == 'full'", specifier = ">=10.0" },
|
{ name = "pillow", marker = "extra == 'full'", specifier = ">=10.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0,<3" },
|
{ 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 == 'com'", specifier = ">=306" },
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32' and extra == 'full'", 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]
|
[package.metadata.requires-dev]
|
||||||
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.3.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "opentelemetry-api"
|
name = "opentelemetry-api"
|
||||||
version = "1.44.0"
|
version = "1.44.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user