673 lines
28 KiB
Python
673 lines
28 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 工程图纸 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
|