aps-agent/server/aps_domain/dxf_drawing.py

673 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 工程图纸 DXF 自动识别(moduleId: domain-dxf-drawing, 可重生 ✅)
# 图纸识别 → 主数据候选的安全管线(只读解析 + 候选生成 + SVG 预览):
# - parse_dxf():DXF → ParsedDrawing(版本/范围/图层/实体统计/标题栏/
# 位置号/技术说明),全部字段携带 source 与 confidence(0-1)。
# - 标题栏双通道:①块属性(ATTDEF/ATTRIB,如 MTU SF_STD_A 的
# ZEICHNUNGSNUMMER/BENENNUNG/WERKSTOFF)置信 0.9;②右下角文字区
# 中文标签(图号/名称/材料/比例…)兜底置信 0.6;③文件名兜底置信 0.5。
# - drawing_to_master_candidates():物料/BOM/工艺路线候选——只生成图纸
# 确实承载的信息;材质/板厚、数量、工序、工时、外协等图纸无法可靠
# 获得的字段一律标记 missing + requiresConfirm,绝不编造(fail closed)。
# - dxf_to_svg():模型空间 → SVG(ezdxf drawing 插件,白底),供前端预览。
# - discover_dxf_files():项目工程目录 DXF 发现(只读扫描)。
# 写入边界:本模块绝不写世界状态;写入一律走 /api/dxf/stage →
# master.*.upsert 的既有 P2 确认卡(/api/actions/confirm 唯一执行路径)。
# ============================================================
from __future__ import annotations # 前向类型引用
import hashlib
import logging
import os
import re
import tempfile
from typing import Any
World = dict[str, Any] # 世界状态类型别名
_log = logging.getLogger(__name__) # 模块日志(几何降级等诊断)
# 空值占位符:MTU 等标题栏用 "."/"-" 表示未填写
_EMPTY_TOKENS = {"", ".", "-", "--", "---", "/", "—"}
# 位置号(Positionsnummer):如 14.001 / 20.027 / 15.001M(装配图明细索引)
_POSITION_RE = re.compile(r"^\d{1,3}\.\d{1,3}[A-Za-z]?$")
# 文件名中的 图号-张次-版本:5060102101-001-e(1).dxf → number/sheet/rev
_FILENAME_RE = re.compile(
r"^(?P<number>\d{6,})-(?P<sheet>\d{1,4})(?:-(?P<rev>[A-Za-z]))?(?:\(\d+\))?$")
# 中文标题栏标签 → 标准字段(右下角文字区兜底通道)
_CN_LABEL_MAP: tuple[tuple[re.Pattern[str], str], ...] = tuple(
(re.compile(pat), field) for pat, field in (
(r"^(?:图号|图纸编号|图样编号)\s*[::]?\s*(.+)$", "drawingNumber"),
(r"^(?:名称|零件名称|产品名称)\s*[::]?\s*(.+)$", "title"),
(r"^(?:材料|材质)\s*[::]?\s*(.+)$", "materialSpec"),
(r"^(?:板厚|厚度)\s*[::]?\s*(.+)$", "thickness"),
(r"^(?:数量|单件数量)\s*[::]?\s*(.+)$", "quantity"),
(r"^比例\s*[::]?\s*(.+)$", "scale"),
(r"^(?:重量|质量)\s*[::]?\s*(.+)$", "mass"),
(r"^(?:设计|制图|绘制)\s*[::]?\s*(.+)$", "drawnBy"),
(r"^校对\s*[::]?\s*(.+)$", "checkedBy"),
(r"^审核\s*[::]?\s*(.+)$", "approvedBy"),
(r"^日期\s*[::]?\s*(.+)$", "drawnDate"),
(r"^(?:单位|公司名称)\s*[::]?\s*(.+)$", "company"),
)
)
# 块属性标签 → 标准字段(MTU/西门子系德文标题栏 + 常见英文标签)
# 值为标签前缀(GEN-TITLE-SCA{5.42} 这类带后缀的用前缀匹配)
_ATTR_TAG_MAP: tuple[tuple[str, str], ...] = (
("ZEICHNUNGSNUMMER", "drawingNumber"),
("DRAWING", "drawingNumber"),
("BLATTNUMMER", "sheet"),
("SHEET", "sheet"),
("FORMAT", "format"),
("GEN-TITLE-SCA", "scale"),
("MASSSTAB", "scale"),
("SCALE", "scale"),
("BENENNUNG", "titlePart"), # BENENNUNG1..6 逐段拼接
("TITLE", "titlePart"),
("ZEICHNUNGSART", "drawingType"),
("WERKSTOFF", "materialSpec"),
("MATERIAL", "materialSpec"),
("HALBZEUG", "semiFinished"),
("MASSE", "mass"),
("WEIGHT", "mass"),
("ERSTELLER", "drawnBy"),
("DRAWN", "drawnBy"),
("ERSTELLDATUM", "drawnDate"),
("PRUEFBEARBEITER", "checkedBy"),
("CHECKED", "checkedBy"),
("PRUEFDATUM", "checkedDate"),
("NORMBEARBEITER", "approvedBy"),
("NORMDATUM", "approvedDate"),
("VERWENDBARKEIT", "applicableTo"),
("APPLICABLE", "applicableTo"),
("AUFTRAGSNUMMER", "orderNo"),
("REFERENZNUMMER", "referenceNo"),
("B_FREI", "releaseLetter"), # 版本发放字母(freigabe)
("AE_FREI", "releaseNote"),
)
# 至少命中 N 个已知标签才认定为标题栏块
_TITLE_BLOCK_MIN_HITS = 3
# 技术说明判定:MTEXT 长文本 / 含换行;或明确技术要求前缀
_NOTE_MIN_LEN = 40
_NOTE_PREFIX_RE = re.compile(
r"技术要求|技术条件|notes?\s*[::]|hinweis|specification", re.IGNORECASE)
_PARSE_CACHE: dict[str, dict[str, Any]] = {} # drawingId → 解析缓存
_CACHE_MAX = 8
def _clean(value: Any) -> str:
"""标题栏取值归一化:去空白;占位符(. / - 等)视为空。"""
text = str(value or "").strip()
return "" if text in _EMPTY_TOKENS else text
def _mtext_plain(text: str) -> str:
"""MTEXT 去格式码:{...} 分组、\\P 换行、\\A; 对齐码等。"""
out = re.sub(r"[{}]", "", str(text or ""))
out = out.replace("\\P", "\n")
out = re.sub(r"\\[ACcFfHhQqTtWw][^;\\]*;?", "", out)
out = re.sub(r"\\[pX]", "", out)
out = re.sub(r"%%d", "°", out)
out = re.sub(r"%%[cu]", "", out)
return out.strip()
def _filename_meta(filename: str) -> dict[str, str]:
"""文件名线索:图号/张次/版本(5060102101-001-e(1) → 三段)。"""
stem = os.path.splitext(os.path.basename(filename))[0]
m = _FILENAME_RE.match(stem)
if not m:
return {"stem": stem}
return {
"stem": stem,
"drawingNumberNormalized": m.group("number"),
"sheet": m.group("sheet"),
"revision": (m.group("rev") or "").lower(),
}
def _entity_insert_xy(entity: Any) -> tuple[float, float]:
try:
ins = entity.dxf.insert
return float(ins.x), float(ins.y)
except AttributeError:
return 0.0, 0.0
def _collect_layout_texts(layout: Any, *, cap: int) -> list[dict[str, Any]]:
"""收集布局内 TEXT/MTEXT 文字(坐标/图层/字高/来源实体类型)。"""
texts: list[dict[str, Any]] = []
for e in layout.query("TEXT MTEXT"):
if len(texts) >= cap:
break
if e.dxftype() == "TEXT":
value = str(e.dxf.text or "").strip()
height = float(getattr(e.dxf, "height", 0.0) or 0.0)
else:
value = _mtext_plain(e.text)
height = float(getattr(e.dxf, "char_height", 0.0) or 0.0)
if not value:
continue
x, y = _entity_insert_xy(e)
texts.append({
"text": value, "x": round(x, 3), "y": round(y, 3),
"height": round(height, 2), "layer": str(e.dxf.layer),
"entityType": e.dxftype(),
})
return texts
def _title_block_from_attribs(doc: Any) -> dict[str, Any]:
"""通道①:布局内带 ATTRIB 的 INSERT → 标题栏字段(置信 0.9)。
遍历所有布局(模型/图纸空间),按已知标签命中数挑选最像标题栏的块引用;
字段全部标注来源 ATTRIB 标签,未填写的占位字段保持空(不编造)。
"""
best: dict[str, Any] | None = None
for layout in doc.layouts:
for ins in layout.query("INSERT"):
attribs = [(str(a.dxf.tag).upper(), a.dxf.text) for a in ins.attribs]
if not attribs:
continue
hits = sum(1 for tag, _ in attribs
if any(tag.startswith(prefix) for prefix, _ in _ATTR_TAG_MAP))
if hits < _TITLE_BLOCK_MIN_HITS:
continue
if best is None or hits > best["tagHits"]:
best = {
"tagHits": hits, "layout": layout.name,
"blockName": str(ins.dxf.name), "attribs": attribs,
}
if best is None:
return {"found": False, "source": "block-attribs", "fields": {}}
fields: dict[str, dict[str, Any]] = {}
title_parts: list[str] = []
for tag, raw in best["attribs"]:
value = _clean(raw)
mapped = next((field for prefix, field in _ATTR_TAG_MAP
if tag.startswith(prefix)), None)
if mapped is None:
continue
if mapped == "titlePart":
if value:
title_parts.append(value)
continue
if not value:
fields.setdefault(mapped, {
"value": "", "confidence": 0.0,
"source": f"ATTRIB {tag}(未填写)", "missing": True})
continue
fields[mapped] = {"value": value, "confidence": 0.9,
"source": f"ATTRIB {tag}", "missing": False}
if title_parts:
fields["title"] = {"value": " / ".join(title_parts), "confidence": 0.9,
"source": "ATTRIB BENENNUNG1..n", "missing": False}
return {
"found": True, "source": "block-attribs",
"blockName": best["blockName"], "layout": best["layout"],
"fields": fields,
}
def _title_block_from_cn_texts(texts: list[dict[str, Any]],
extents: dict[str, float]) -> dict[str, Any]:
"""通道②:右下角文字区中文标签兜底(置信 0.6,必须人工确认)。"""
if not texts:
return {"found": False, "source": "cn-labels", "fields": {}}
width = max(1e-6, extents["maxX"] - extents["minX"])
height = max(1e-6, extents["maxY"] - extents["minY"])
fields: dict[str, dict[str, Any]] = {}
for t in texts:
rel_x = (t["x"] - extents["minX"]) / width
rel_y = (t["y"] - extents["minY"]) / height
if rel_x < 0.5 or rel_y > 0.5: # 标题栏通常在右下象限
continue
line = t["text"].split("\n", 1)[0].strip()
for pattern, field in _CN_LABEL_MAP:
m = pattern.match(line)
if m and field not in fields:
fields[field] = {
"value": _clean(m.group(1)), "confidence": 0.6,
"source": f"TEXT ({t['x']:.0f},{t['y']:.0f}) layer={t['layer']}",
"missing": False}
break
return {"found": bool(fields), "source": "cn-labels", "fields": fields}
def _merge_title_block(primary: dict[str, Any],
fallback: dict[str, Any]) -> dict[str, Any]:
"""通道合并:属性通道优先,中文标签通道补缺(不覆盖已有字段)。"""
if not primary.get("found"):
return fallback
if not fallback.get("found"):
return primary
merged = dict(primary)
fields = dict(primary.get("fields") or {})
for key, val in (fallback.get("fields") or {}).items():
cur = fields.get(key)
if cur is None or cur.get("missing"):
fields[key] = val
merged["fields"] = fields
merged["fallbackSource"] = fallback.get("source")
return merged
def _position_numbers(texts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""装配位置号聚类:值 + 出现次数 + 图层(BOM 行候选的索引,不含名称/数量)。"""
found: dict[str, dict[str, Any]] = {}
for t in texts:
value = t["text"].strip().replace("oder", "").strip()
if not _POSITION_RE.match(value):
continue
row = found.setdefault(value, {"position": value, "count": 0, "layers": []})
row["count"] += 1
if t["layer"] not in row["layers"]:
row["layers"].append(t["layer"])
return sorted(found.values(), key=lambda r: r["position"])
def _technical_notes(texts: list[dict[str, Any]], *, cap: int = 30) -> list[dict[str, Any]]:
"""技术说明:多行/长文本/明确技术要求前缀(原文保留,仅参考,不推断工艺)。"""
notes: list[dict[str, Any]] = []
for t in texts:
text = t["text"]
if len(text) < _NOTE_MIN_LEN and not _NOTE_PREFIX_RE.search(text):
continue
if _POSITION_RE.match(text.strip()):
continue
notes.append({"text": text[:500], "layer": t["layer"],
"x": t["x"], "y": t["y"]})
if len(notes) >= cap:
break
return notes
def _extents_of(msp: Any) -> dict[str, float]:
"""模型空间几何范围(ezdxf bbox;空图回退 0)。"""
try:
import ezdxf.bbox
box = ezdxf.bbox.extents(msp)
if box.has_data:
return {"minX": round(box.extmin.x, 3), "minY": round(box.extmin.y, 3),
"maxX": round(box.extmax.x, 3), "maxY": round(box.extmax.y, 3)}
except Exception as exc: # noqa: BLE001 — ezdxf 几何异常类型不固定,降级不阻断
_log.debug("DXF 几何范围计算降级(不影响文字识别):%s", exc)
return {"minX": 0.0, "minY": 0.0, "maxX": 0.0, "maxY": 0.0}
def parse_dxf(path: str, *, max_texts: int = 400,
display_name: str | None = None) -> dict[str, Any]:
"""DXF → ParsedDrawing(只读解析,绝不写世界状态)。
display_name:上传场景的真实文件名(临时文件路径不具备文件名线索)。
失败语义(fail closed):文件不存在/不可读/非 DXF → 显式 ValueError;
几何范围异常仅降级为空范围,不阻断标题栏与文字识别。
"""
if not os.path.isfile(path):
raise ValueError(f"DXF 文件不存在:{path}")
try:
import ezdxf
except ImportError as exc: # 依赖缺失显式报错,不静默降级
raise ValueError("缺少 ezdxf 依赖,无法解析 DXF(pip install ezdxf)") from exc
try:
doc = ezdxf.readfile(path)
except Exception as exc:
raise ValueError(f"DXF 解析失败:{os.path.basename(path)}({exc})") from exc
with open(path, "rb") as fh:
raw = fh.read()
sha = hashlib.sha256(raw).hexdigest()
filename = display_name or os.path.basename(path)
file_meta = _filename_meta(filename)
msp = doc.modelspace()
entity_stats: dict[str, int] = {}
layer_counts: dict[str, int] = {}
for e in msp:
entity_stats[e.dxftype()] = entity_stats.get(e.dxftype(), 0) + 1
layer = str(getattr(e.dxf, "layer", "0"))
layer_counts[layer] = layer_counts.get(layer, 0) + 1
layers = [{"name": layer.dxf.name, "color": int(layer.dxf.color),
"entityCount": layer_counts.get(layer.dxf.name, 0)}
for layer in doc.layers]
texts = _collect_layout_texts(msp, cap=max_texts)
extents = _extents_of(msp)
title_block = _merge_title_block(
_title_block_from_attribs(doc),
_title_block_from_cn_texts(texts, extents))
fields = dict(title_block.get("fields") or {})
# 文件名交叉校验:图号数字一致则提置信,不一致仅提示(不覆盖属性值)
warnings: list[str] = []
fn_number = file_meta.get("drawingNumberNormalized", "")
dn = fields.get("drawingNumber")
if dn and not dn.get("missing"):
normalized = re.sub(r"\s+", "", dn["value"])
dn["normalized"] = normalized
if fn_number and fn_number == normalized:
dn["confidence"] = 0.95
dn["source"] += " + 文件名一致"
elif fn_number:
warnings.append(
f"标题栏图号 {normalized} 与文件名图号 {fn_number} 不一致,需人工核对")
elif fn_number:
fields["drawingNumber"] = {
"value": fn_number, "normalized": fn_number, "confidence": 0.7,
"source": "文件名(标题栏未找到图号)", "missing": False}
title_block["found"] = True
if not fields.get("title") or fields["title"].get("missing"):
fields.setdefault("title", {
"value": file_meta.get("stem", filename), "confidence": 0.5,
"source": "文件名(标题栏未找到名称)", "missing": False})
# 版本:文件名后缀优先与标题栏发放字母互证
rev = file_meta.get("revision", "")
release = fields.get("releaseLetter", {}).get("value", "")
if rev and release and rev == release.lower():
fields["revision"] = {"value": rev, "confidence": 0.9,
"source": "文件名后缀 + ATTRIB B_FREI 一致", "missing": False}
elif rev:
fields["revision"] = {"value": rev, "confidence": 0.7,
"source": "文件名后缀", "missing": False}
elif release:
fields["revision"] = {"value": release, "confidence": 0.7,
"source": "ATTRIB B_FREI", "missing": False}
title_block["fields"] = fields
drawing_id = sha[:16]
parsed = {
"drawingId": drawing_id,
"filename": filename,
"path": os.path.abspath(path),
"sizeBytes": len(raw),
"sha256": sha,
"dxfVersion": str(doc.dxfversion),
"encoding": str(doc.encoding),
"extents": extents,
"layers": layers,
"entityStats": entity_stats,
"textCount": len(texts),
"texts": texts,
"titleBlock": title_block,
"positionNumbers": _position_numbers(texts),
"technicalNotes": _technical_notes(texts),
"fileMeta": file_meta,
"warnings": warnings,
}
_cache_put(drawing_id, {"parsed": parsed, "path": os.path.abspath(path)})
return parsed
def _cache_put(drawing_id: str, entry: dict[str, Any]) -> None:
"""解析缓存:上限 _CACHE_MAX 份(先进先出,避免大图纸堆积内存)。
上传场景产生的临时文件(tempPath)随缓存淘汰一并清理。
"""
if drawing_id in _PARSE_CACHE:
_cache_evict(drawing_id)
while len(_PARSE_CACHE) >= _CACHE_MAX:
_cache_evict(next(iter(_PARSE_CACHE)))
_PARSE_CACHE[drawing_id] = entry
def _cache_evict(drawing_id: str) -> None:
entry = _PARSE_CACHE.pop(drawing_id, None)
tmp = (entry or {}).get("tempPath")
if tmp:
try:
os.unlink(tmp)
except OSError:
pass
def get_cached_drawing(drawing_id: str) -> dict[str, Any] | None:
"""取解析缓存(SVG/确认卡复用,避免重复解析大文件)。"""
return _PARSE_CACHE.get(drawing_id)
def _field_value(fields: dict[str, Any], name: str, default: str = "") -> str:
f = fields.get(name) or {}
if f.get("missing"):
return default
return str(f.get("value") or default)
def _field_conf(fields: dict[str, Any], name: str, default: float = 0.0) -> float:
f = fields.get(name) or {}
return float(f.get("confidence", default))
def _infer_material_type(fields: dict[str, Any]) -> tuple[str, float, str]:
"""由图纸种类推断物料类型(推断值置信封顶 0.6,必须人工确认)。"""
kind = _field_value(fields, "drawingType").upper()
title = _field_value(fields, "title").lower()
if any(k in kind for k in ("EB", "INSTALLATION", "ZK", "ASSEMBLY", "GESAMT")) \
or any(k in title for k in ("installation", "assembly", "安装", "总装")):
return "FINISHED_PRODUCT", 0.6, "图纸种类为安装/装配图(推断,需确认)"
if any(k in kind for k in ("ET", "TEIL", "PART")) \
or any(k in title for k in ("零件", "part")):
return "SEMI_FINISHED", 0.6, "图纸种类为零件图(推断,需确认)"
return "SEMI_FINISHED", 0.4, "图纸种类未知(默认半成品,需确认)"
def drawing_to_master_candidates(parsed: dict[str, Any]) -> dict[str, Any]:
"""ParsedDrawing → 物料/BOM/工艺路线候选(只生成图纸承载的信息)。
安全边界(fail closed,不编造):
- 物料:编码/名称来自标题栏;类型/单位为推断值(置信 ≤0.6);
- BOM:仅有装配位置号,无明细表时名称/数量/子件编码全部 missing;
- 工艺路线:图纸无工艺信息 → steps 空 + complete False;
- 材质/板厚/采购属性/标准工时/外协:图纸未承载 → 显式 missing。
"""
fields = (parsed.get("titleBlock") or {}).get("fields") or {}
file_meta = parsed.get("fileMeta") or {}
stem = file_meta.get("stem") or parsed.get("filename", "")
normalized = _field_value(fields, "drawingNumber")
normalized = re.sub(r"\s+", "", normalized) if normalized else ""
sheet = _field_value(fields, "sheet")
# 张次格式对齐文件名习惯:001 vs 1 数值一致时沿用文件名写法
fn_sheet = str(file_meta.get("sheet") or "")
if sheet and fn_sheet and sheet.isdigit() and fn_sheet.isdigit() \
and int(sheet) == int(fn_sheet):
sheet = fn_sheet
if normalized and sheet:
code = f"{normalized}-{sheet}"
elif normalized:
code = normalized
else:
code = stem
name = _field_value(fields, "title") or stem
mtype, _type_conf, type_source = _infer_material_type(fields)
spec_parts = []
if _field_value(fields, "applicableTo"):
spec_parts.append(f"适用 {_field_value(fields, 'applicableTo')}")
if _field_value(fields, "scale"):
spec_parts.append(f"比例 {_field_value(fields, 'scale')}")
if _field_value(fields, "revision"):
spec_parts.append(f"版本 {_field_value(fields, 'revision')}")
if _field_value(fields, "drawingType"):
spec_parts.append(_field_value(fields, "drawingType"))
material_missing: list[str] = []
if not code:
material_missing.append("code")
if not name or name == stem and _field_conf(fields, "title") < 0.6:
material_missing.append("name(标题栏未识别,需确认)")
material_conf = round(min(
_field_conf(fields, "drawingNumber", 0.5),
_field_conf(fields, "title", 0.5),
0.95), 4)
material = {
"code": code, "name": name, "type": mtype, "unit": "件",
"spec": " · ".join(spec_parts),
"confidence": material_conf,
"requiresConfirm": True,
"missingFields": material_missing,
"fieldSources": {
"code": (fields.get("drawingNumber") or {}).get("source", "文件名"),
"name": (fields.get("title") or {}).get("source", "文件名"),
"type": type_source,
"unit": "默认值「件」(图纸未承载计量单位,需确认)",
},
"notFromDrawing": [
"材质/板厚(WERKSTOFF 未填写或图纸未标注)",
"库存/安全库存/采购前置期/采购属性",
],
"materialSpec": _field_value(fields, "materialSpec"),
"thickness": _field_value(fields, "thickness"),
}
bom_items = [{
"position": p["position"],
"materialCode": "", "name": "", "quantity": None,
"confidence": 0.2,
"missing": ["materialCode", "name", "quantity"],
"source": f"图纸位置号(图层 {','.join(p['layers'])} · 出现 {p['count']} 次)",
} for p in parsed.get("positionNumbers") or []]
bom = {
"productCode": code,
"version": "V1.0",
"items": bom_items,
"complete": False,
"requiresConfirm": True,
"note": ("图纸仅含装配位置号、无明细表(Stückliste):"
"子件编码/名称/数量均需人工补全后才能建 BOM"
if bom_items else "图纸未识别到装配位置号,BOM 需人工编制"),
}
routing = {
"productCode": code,
"version": "V1.0",
"steps": [],
"complete": False,
"requiresConfirm": True,
"note": "图纸不承载工艺信息:工序/顺序/标准工时/外协判断均需人工确认后编制",
}
return {
"drawingId": parsed.get("drawingId"),
"filename": parsed.get("filename"),
"material": material,
"bom": bom,
"routing": routing,
"boundary": ("候选仅含图纸可识别信息;标注 missing/requiresConfirm 的字段"
"必须经 P2 人工确认,确认前不写正式主数据"),
}
def dxf_to_svg(path: str, *, background: str = "white",
include_layers: list[str] | None = None) -> str:
"""模型空间 → SVG 预览(白底;几何渲染异常显式报错,不返回半成品)。
include_layers:只渲染指定图层(前端图层开关);None 渲染全部。
图层关闭仅作用于本次渲染的临时 doc(每次都重新读文件,不污染源文件)。
"""
if not os.path.isfile(path):
raise ValueError(f"DXF 文件不存在:{path}")
import ezdxf
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.config import BackgroundPolicy, Configuration
from ezdxf.addons.drawing.layout import Page, Settings
from ezdxf.addons.drawing.svg import SVGBackend
doc = ezdxf.readfile(path)
if include_layers is not None:
wanted = set(include_layers)
for layer in doc.layers:
if layer.dxf.name not in wanted:
layer.off()
msp = doc.modelspace()
policy = (BackgroundPolicy.WHITE if background == "white"
else BackgroundPolicy.BLACK)
ctx = RenderContext(doc)
backend = SVGBackend()
config = Configuration(background_policy=policy)
Frontend(ctx, backend, config=config).draw_layout(msp, finalize=True)
page = Page.from_dxf_layout(msp)
return backend.get_string(page, settings=Settings(fit_page=True))
_SVG_VARIANT_MAX = 16
def dxf_svg_cached(drawing_id: str, include_layers: list[str] | None = None) -> str:
"""按 drawingId 生成/复用 SVG(缓存内取路径,避免重复解析大文件)。
图层过滤变体按排序后图层键缓存(上限 _SVG_VARIANT_MAX,防组合爆炸)。
"""
entry = get_cached_drawing(drawing_id)
if entry is None:
raise ValueError(f"图纸未解析或缓存已淘汰:{drawing_id}(请先调用解析接口)")
if include_layers is None:
if "svg" not in entry:
entry["svg"] = dxf_to_svg(entry["path"])
return entry["svg"]
key = ",".join(sorted(set(include_layers)))
variants: dict[str, str] = entry.setdefault("svgVariants", {})
if key not in variants:
while len(variants) >= _SVG_VARIANT_MAX:
variants.pop(next(iter(variants)))
variants[key] = dxf_to_svg(entry["path"], include_layers=include_layers)
return variants[key]
_DXF_EXT_RE = re.compile(r"\.dxf$", re.IGNORECASE)
def discover_dxf_files(root: str, *, recursive: bool = True,
limit: int = 200) -> dict[str, Any]:
"""项目工程目录 DXF 发现(只读扫描;目录不存在显式报错)。"""
if not os.path.isdir(root):
raise ValueError(f"工程目录不存在:{root}")
files: list[dict[str, Any]] = []
if recursive:
walker = os.walk(root)
entries = (os.path.join(dir_path, name)
for dir_path, _, names in walker for name in names)
else:
entries = (os.path.join(root, name) for name in os.listdir(root))
for path in entries:
if not _DXF_EXT_RE.search(path):
continue
try:
stat = os.stat(path)
except OSError:
continue
files.append({
"path": os.path.abspath(path),
"filename": os.path.basename(path),
"sizeBytes": stat.st_size,
"modifiedAt": int(stat.st_mtime),
"fileMeta": _filename_meta(path),
})
if len(files) >= limit:
break
files.sort(key=lambda f: f["filename"])
return {"root": os.path.abspath(root), "count": len(files),
"truncated": len(files) >= limit, "files": files}
def parse_dxf_bytes(filename: str, data: bytes) -> dict[str, Any]:
"""字节流解析入口(上传场景):写临时文件后走 parse_dxf。
临时文件随缓存保留(SVG 复用需要真实路径),缓存淘汰时一并清理。
"""
suffix = os.path.splitext(filename)[1] or ".dxf"
fd, tmp = tempfile.mkstemp(prefix="aps-dxf-", suffix=suffix)
with os.fdopen(fd, "wb") as fh:
fh.write(data)
parsed = parse_dxf(tmp, display_name=filename)
# 路径保留临时文件(SVG 复用),缓存淘汰时一并清理
entry = get_cached_drawing(parsed["drawingId"])
if entry is not None:
entry["tempPath"] = tmp # 缓存淘汰时清理由 _cache_evict 负责
return parsed