from __future__ import annotations """Safe DXF inspection, preview rendering, and master-data candidate building. This module deliberately stops at reviewable candidates. A drawing is evidence, not authority for quantities, standard time, or production-resource assignments. """ from collections import Counter from datetime import datetime, timezone from hashlib import sha256 from html import escape import math from pathlib import Path import re from typing import Any, Iterable MAX_DXF_BYTES = 25 * 1024 * 1024 MAX_ENTITIES = 20_000 MAX_TEXTS = 1_000 MAX_DIMENSIONS = 1_000 MAX_BLOCK_REFERENCES = 1_000 MAX_SVG_BYTES = 5 * 1024 * 1024 PREVIEW_ENTITY_LIMIT = 12_000 _DRAWING_NAME_RE = re.compile( r"^(?P[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+)-(?P[A-Za-z])(?:\(\d+\))?$" ) _ITEM_REF_RE = re.compile(r"^(?:[A-Z]?\d{2,}[.]\d{2,}[A-Z]?|\d{6,}(?:-\d+)?)$", re.I) _SCALE_RE = re.compile(r"(?:SCALE\s*)?(\d+\s*:\s*\d+)", re.I) def _utc_now() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def _stable_id(kind: str, digest: str, suffix: str = "") -> str: seed = f"{kind}:{digest}:{suffix}".encode("utf-8") return f"{kind}_{sha256(seed).hexdigest()[:20]}" def _safe_path(path: str | Path) -> Path: p = Path(path).expanduser().resolve(strict=True) if not p.is_file() or p.suffix.lower() != ".dxf": raise ValueError("Only an existing .dxf file can be inspected") size = p.stat().st_size if size <= 0: raise ValueError("DXF file is empty") if size > MAX_DXF_BYTES: raise ValueError(f"DXF file exceeds {MAX_DXF_BYTES} bytes") return p def _file_digest(path: Path) -> str: h = sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def _filename_metadata(path: Path) -> dict[str, str | None]: match = _DRAWING_NAME_RE.match(path.stem) if not match: return {"drawingNumber": path.stem, "revision": None} return { "drawingNumber": match.group("drawing_no"), "revision": match.group("revision").lower(), } def _clean_text(value: Any) -> str: text = str(value or "") text = text.replace("\\P", "\n").replace("%%d", "°") text = re.sub(r"[{}]", "", text) return "\n".join(part.strip() for part in text.splitlines() if part.strip()) def _point(value: Any) -> list[float] | None: try: return [round(float(value[0]), 6), round(float(value[1]), 6)] except (TypeError, ValueError, IndexError): return None def _entity_evidence(entity: Any, text: str | None = None) -> dict[str, Any]: payload: dict[str, Any] = { "entityHandle": str(getattr(entity.dxf, "handle", "") or ""), "entityType": entity.dxftype(), "layer": str(getattr(entity.dxf, "layer", "0") or "0"), } insert = _point(getattr(entity.dxf, "insert", None)) if insert: payload["position"] = insert if text is not None: payload["rawText"] = text return payload def _bbox_payload(doc: Any, entities: Iterable[Any]) -> dict[str, Any] | None: try: from ezdxf import bbox box = bbox.extents(entities, fast=True) if not box.has_data: return None return { "min": [round(box.extmin.x, 6), round(box.extmin.y, 6)], "max": [round(box.extmax.x, 6), round(box.extmax.y, 6)], "width": round(box.size.x, 6), "height": round(box.size.y, 6), } except Exception: return None def _field_candidate( field: str, value: Any, confidence: float, evidence: dict[str, Any], *, review_required: bool = False, ) -> dict[str, Any]: return { "field": field, "value": value, "confidence": round(max(0.0, min(float(confidence), 1.0)), 3), "reviewRequired": bool(review_required), "evidence": evidence, } def inspect_dxf(path: str | Path) -> dict[str, Any]: """Parse a DXF into a stable, JSON-serializable evidence contract.""" p = _safe_path(path) digest = _file_digest(p) try: import ezdxf from ezdxf.lldxf.const import acad_release except ImportError as exc: # pragma: no cover - environment guard raise RuntimeError("DXF parsing requires the 'ezdxf' package") from exc try: doc = ezdxf.readfile(p) except Exception as exc: raise ValueError(f"Invalid or unsupported DXF: {exc}") from exc modelspace = doc.modelspace() if len(modelspace) > MAX_ENTITIES: raise ValueError(f"DXF contains more than {MAX_ENTITIES} modelspace entities") entity_counts: Counter[str] = Counter() layer_counts: Counter[str] = Counter() texts: list[dict[str, Any]] = [] dimensions: list[dict[str, Any]] = [] block_refs: list[dict[str, Any]] = [] for entity in modelspace: kind = entity.dxftype() layer = str(getattr(entity.dxf, "layer", "0") or "0") entity_counts[kind] += 1 layer_counts[layer] += 1 if kind in {"TEXT", "MTEXT", "ATTRIB"} and len(texts) < MAX_TEXTS: raw = entity.text if kind == "MTEXT" else getattr(entity.dxf, "text", "") clean = _clean_text(raw) if clean: texts.append({ "id": _stable_id("text", digest, str(getattr(entity.dxf, "handle", ""))), "text": clean, "evidence": _entity_evidence(entity, str(raw)), }) elif kind == "DIMENSION" and len(dimensions) < MAX_DIMENSIONS: try: measurement = entity.get_measurement() measurement = round(float(measurement), 6) except Exception: measurement = None dimensions.append({ "id": _stable_id("dimension", digest, str(getattr(entity.dxf, "handle", ""))), "measurement": measurement, "dimensionType": int(getattr(entity.dxf, "dimtype", 0) or 0) & 15, "textOverride": _clean_text(getattr(entity.dxf, "text", "")) or None, "evidence": _entity_evidence(entity), }) elif kind == "INSERT" and len(block_refs) < MAX_BLOCK_REFERENCES: attrs = [] for attrib in getattr(entity, "attribs", []): attrs.append({ "tag": str(getattr(attrib.dxf, "tag", "") or ""), "value": _clean_text(getattr(attrib.dxf, "text", "")), "handle": str(getattr(attrib.dxf, "handle", "") or ""), }) block_refs.append({ "id": _stable_id("blockref", digest, str(getattr(entity.dxf, "handle", ""))), "name": str(getattr(entity.dxf, "name", "") or ""), "insert": _point(getattr(entity.dxf, "insert", None)), "rotation": round(float(getattr(entity.dxf, "rotation", 0.0) or 0.0), 6), "attributes": attrs, "evidence": _entity_evidence(entity), }) name_meta = _filename_metadata(p) fields = [ _field_candidate( "drawingNumber", name_meta["drawingNumber"], 0.99, {"source": "filename", "filename": p.name}, ) ] if name_meta["revision"]: fields.append(_field_candidate( "revision", name_meta["revision"], 0.98, {"source": "filename", "filename": p.name}, )) all_text = [row["text"] for row in texts] scale_match = next((_SCALE_RE.search(text) for text in all_text if _SCALE_RE.search(text)), None) if scale_match: fields.append(_field_candidate( "scale", scale_match.group(1).replace(" ", ""), 0.82, {"source": "entityText", "matchedText": scale_match.group(0)}, review_required=True, )) item_refs: list[dict[str, Any]] = [] seen_refs: set[str] = set() for row in texts: for line in row["text"].splitlines(): token = line.strip() if _ITEM_REF_RE.fullmatch(token) and token not in seen_refs: seen_refs.add(token) item_refs.append({ "reference": token, "confidence": 0.72, "reviewRequired": True, "evidence": row["evidence"], }) layers = [] for layer in doc.layers: name = str(layer.dxf.name) layers.append({ "name": name, "color": int(getattr(layer.dxf, "color", 7) or 7), "linetype": str(getattr(layer.dxf, "linetype", "CONTINUOUS") or "CONTINUOUS"), "entityCount": int(layer_counts.get(name, 0)), "off": bool(layer.is_off()), "frozen": bool(layer.is_frozen()), }) header = { "acadVersion": str(doc.dxfversion), "release": str(acad_release.get(doc.dxfversion, "unknown")), "encoding": str(doc.encoding), "units": int(doc.header.get("$INSUNITS", 0) or 0), } asset_id = _stable_id("drawing", digest) parsed = { "contractVersion": "drawing-dxf.v1", "asset": { "id": asset_id, "kind": "DXF", "filename": p.name, "sourcePath": str(p), "sizeBytes": p.stat().st_size, "sha256": digest, "mediaType": "image/vnd.dxf", "idempotencyKey": f"drawing:{digest}", }, "drawing": { "drawingNumber": name_meta["drawingNumber"], "revision": name_meta["revision"], "metadata": header, "bbox": _bbox_payload(doc, modelspace), "modelspaceEntityCount": len(modelspace), "layerCount": len(layers), "blockDefinitionCount": len(doc.blocks), }, "layers": sorted(layers, key=lambda item: item["name"].casefold()), "entityStatistics": dict(sorted(entity_counts.items())), "texts": texts, "dimensions": dimensions, "blockReferences": block_refs, "fieldCandidates": fields, "itemReferences": item_refs, "warnings": [ "DXF extraction produces reviewable evidence only; it does not prove BOM quantity, material specification, standard time, or resource assignment." ], "parsedAt": _utc_now(), } from server.aps_domain.drawing_process import recognize_process_candidates process = recognize_process_candidates(parsed) for detail in process.get("details") or []: parsed["fieldCandidates"].append(_field_candidate( detail["field"], detail["value"], float(detail["confidence"]), detail["evidence"], review_required=True, )) return parsed def _svg_num(value: Any) -> str: number = float(value) if not math.isfinite(number): raise ValueError("non-finite coordinate") return f"{number:.4f}".rstrip("0").rstrip(".") or "0" def render_dxf_preview(path: str | Path) -> str: """Render a bounded standalone SVG; no scripts, external links, or foreignObject.""" p = _safe_path(path) try: import ezdxf from ezdxf.lldxf.const import acad_release except ImportError as exc: # pragma: no cover raise RuntimeError("DXF preview requires the 'ezdxf' package") from exc try: doc = ezdxf.readfile(p) except Exception as exc: raise ValueError(f"Invalid or unsupported DXF: {exc}") from exc modelspace = doc.modelspace() box = _bbox_payload(doc, modelspace) if not box: raise ValueError("DXF has no renderable extents") min_x, min_y = box["min"] width = max(float(box["width"]), 1.0) height = max(float(box["height"]), 1.0) stroke = max(width, height) / 2500.0 parts = [ '', '', f'', ] rendered = 0 for entity in modelspace: if rendered >= PREVIEW_ENTITY_LIMIT: break try: kind = entity.dxftype() handle = escape(str(getattr(entity.dxf, "handle", "") or ""), quote=True) layer = escape(str(getattr(entity.dxf, "layer", "0") or "0"), quote=True) attrs = f' data-handle="{handle}" data-layer="{layer}"' if kind == "LINE": a, b = entity.dxf.start, entity.dxf.end parts.append(f'') elif kind == "CIRCLE": c = entity.dxf.center parts.append(f'') elif kind == "ARC": c, r = entity.dxf.center, abs(float(entity.dxf.radius)) a1, a2 = math.radians(float(entity.dxf.start_angle)), math.radians(float(entity.dxf.end_angle)) x1, y1 = c.x + r * math.cos(a1), c.y + r * math.sin(a1) x2, y2 = c.x + r * math.cos(a2), c.y + r * math.sin(a2) sweep = (float(entity.dxf.end_angle) - float(entity.dxf.start_angle)) % 360.0 large = 1 if sweep > 180.0 else 0 parts.append(f'') elif kind == "LWPOLYLINE": points = list(entity.get_points("xy"))[:5000] if len(points) >= 2: pts = " ".join(f"{_svg_num(x)},{_svg_num(y)}" for x, y in points) tag = "polygon" if entity.closed else "polyline" parts.append(f'<{tag}{attrs} points="{pts}"/>') elif kind == "POINT": c = entity.dxf.location parts.append(f'') else: continue rendered += 1 except (AttributeError, TypeError, ValueError, OverflowError): continue if sum(len(part) for part in parts) > MAX_SVG_BYTES - 256: break parts.append("") parts.append(f'renderedEntities={rendered}; sourceSha256={_file_digest(p)}') parts.append("") svg = "".join(parts) if len(svg.encode("utf-8")) > MAX_SVG_BYTES: raise ValueError("Generated SVG exceeds safe output limit") return svg def build_drawing_master_candidates(parsed: dict[str, Any]) -> dict[str, Any]: """Build non-authoritative material, BOM-reference, and routing candidates.""" asset = parsed.get("asset") or {} drawing = parsed.get("drawing") or {} digest = str(asset.get("sha256") or "") if not digest or len(digest) != 64: raise ValueError("parsed drawing is missing a valid sha256") drawing_no = str(drawing.get("drawingNumber") or "").strip() if not drawing_no: raise ValueError("parsed drawing is missing drawingNumber") revision = drawing.get("revision") evidence_base = { "drawingAssetId": asset.get("id"), "drawingNumber": drawing_no, "revision": revision, "sourceSha256": digest, } material = { "candidateId": _stable_id("materialCandidate", digest, drawing_no), "candidateType": "MATERIAL", "code": drawing_no, "name": f"图纸件 {drawing_no}", "drawingNumber": drawing_no, "revision": revision, "confidence": 0.76, "reviewRequired": True, "unknownFields": ["materialType", "baseUnit", "specification"], "evidence": evidence_base, } bom_candidates = [] for row in parsed.get("itemReferences") or []: reference = str(row.get("reference") or "").strip() if not reference: continue bom_candidates.append({ "candidateId": _stable_id("bomCandidate", digest, reference), "candidateType": "BOM_REFERENCE", "parentMaterialCode": drawing_no, "componentReference": reference, "componentMaterialCode": None, "quantity": None, "unit": None, "confidence": round(min(float(row.get("confidence") or 0.0), 0.79), 3), "reviewRequired": True, "resolutionStatus": "UNRESOLVED", "unknownFields": ["componentMaterialCode", "quantity", "unit"], "evidence": {**evidence_base, "entity": row.get("evidence")}, }) from server.aps_domain.drawing_process import recognize_process_candidates process = recognize_process_candidates(parsed) operation_hints: list[tuple[str, str, float]] = [ (row["operationCode"], row["operationName"], float(row["confidence"])) for row in (process.get("operations") or []) ] if parsed.get("dimensions"): operation_hints.append(("DIMENSION_INSPECTION", "尺寸检验", 0.70)) if not operation_hints: operation_hints.append(("ENGINEERING_REVIEW", "工程评审", 0.45)) routing_candidates = [] for sequence, (code, name, confidence) in enumerate(operation_hints, start=10): routing_candidates.append({ "candidateId": _stable_id("routingCandidate", digest, code), "candidateType": "ROUTING_OPERATION", "productCode": drawing_no, "sequenceHint": sequence, "operationCode": code, "operationName": name, "standardTime": None, "resourceCode": None, "confidence": confidence, "reviewRequired": True, "unknownFields": ["standardTime", "resourceCode"], "evidence": evidence_base, }) return { "contractVersion": "drawing-master-candidates.v1", "changeSetId": _stable_id("drawingChangeSet", digest), "idempotencyKey": f"drawing-master:{digest}", "source": evidence_base, "status": "PENDING_REVIEW", "reviewRequired": True, "materials": [material], "bomReferences": bom_candidates, "routingOperations": routing_candidates, "processDetails": process.get("details") or [], "processBlocks": process.get("blocks") or [], "processAssemblies": process.get("assemblies") or [], "processLocations": process.get("locations") or [], "processSequenceSteps": process.get("sequenceSteps") or [], "prohibitedAutoFill": ["BOM.quantity", "BOM.unit", "routing.standardTime", "routing.resourceCode"], "warnings": [ "Candidates must pass engineering review/P2 approval before master-data write.", "No BOM quantity, standard time, or production resource was inferred from geometry alone.", ], } __all__ = ["inspect_dxf", "render_dxf_preview", "build_drawing_master_candidates"]