"""Deterministic runtime-only 2D CAD adapter with a closed declarative recipe.""" from __future__ import annotations import hashlib import json import math import os import sys from collections import Counter from copy import deepcopy from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable ADAPTER_VERSION = "1.0.0" PILLOW_VERSION = "12.0.0" MAX_REQUEST_BYTES = 524_288 MAX_ENTITIES = 1024 LAYERS = ( "A-WALL-EXT", "A-WALL-INT", "A-DOOR", "A-WINDOW", "A-AREA", "A-FURN", "A-EQUIP", "A-SAFE", "A-DIMS", "A-TEXT", "A-HATCH", "A-FRAME", ) SHEET_MM = {"A4": (210, 297), "A3": (297, 420), "A2": (420, 594), "A1": (594, 841), "A0": (841, 1189)} OBJECT_DEFAULTS = { "desk": (1400, 700), "chair": (500, 500), "cabinet": (900, 450), "lab_bench": (1800, 750), "fume_hood": (1500, 850), "machine": (1800, 1200), "tank": (1200, 1200), "safety_exit": (900, 300), "extinguisher": (300, 300), } OUTPUT_MEDIA = { "drawing.dxf": ("drawing_dxf", "image/vnd.dxf"), "drawing.svg": ("drawing_svg", "image/svg+xml"), "drawing.png": ("drawing_png", "image/png"), "drawing.pdf": ("drawing_pdf", "application/pdf"), "drawing-recipe.json": ("drawing_recipe", "application/json"), "drawing-manifest.json": ("drawing_manifest", "application/json"), "validation-report.json": ("validation_report", "application/json"), "provenance.json": ("provenance", "application/json"), } class DrawingError(ValueError): def __init__(self, code: str, detail: str = "") -> None: super().__init__(code if not detail else f"{code}: {detail}") self.code = code def _atomic_bytes(path: Path, data: bytes) -> None: temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) try: with temporary.open("wb") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def _atomic_json(path: Path, value: Any) -> None: _atomic_bytes(path, (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + "\n").encode()) def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _artifact(path: Path) -> dict[str, Any]: artifact_id, media_type = OUTPUT_MEDIA[path.name] return {"artifact_id": artifact_id, "filename": path.name, "media_type": media_type, "size_bytes": path.stat().st_size, "sha256": _sha256(path)} def _terminal(record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], code: str = "", detail: str = "") -> dict[str, Any]: return {"job_id": record.get("job_id", ""), "lease_id": record.get("lease_id", ""), "request_digest": record.get("request_digest", ""), "status": status, "artifacts": artifacts, "error": {} if not code else {"code": code, "detail": detail[:500]}, "finished_at": datetime.now(timezone.utc).isoformat()} def _point(value: Iterable[float]) -> tuple[float, float]: x, y = value result = (float(x), float(y)) if not all(math.isfinite(item) and abs(item) <= 1_000_000 for item in result): raise DrawingError("CAD2D_COORDINATE_INVALID") return result def _distance(a: tuple[float, float], b: tuple[float, float]) -> float: return math.hypot(b[0] - a[0], b[1] - a[1]) def _cross(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float]) -> float: return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) def _segments_intersect(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> bool: def orientation(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> int: value = _cross(p, q, r) return 1 if value > 1e-8 else -1 if value < -1e-8 else 0 def on_segment(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> bool: return min(p[0], r[0]) - 1e-8 <= q[0] <= max(p[0], r[0]) + 1e-8 and min(p[1], r[1]) - 1e-8 <= q[1] <= max(p[1], r[1]) + 1e-8 first, second = orientation(a, b, c), orientation(a, b, d) third, fourth = orientation(c, d, a), orientation(c, d, b) if first != second and third != fourth: return True return ((first == 0 and on_segment(a, c, b)) or (second == 0 and on_segment(a, d, b)) or (third == 0 and on_segment(c, a, d)) or (fourth == 0 and on_segment(c, b, d))) def _polygon_area(points: list[tuple[float, float]]) -> float: return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(points, points[1:]))) / 2 def _point_in_polygon(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool: inside = False x, y = point for a, b in zip(polygon, polygon[1:]): if (a[1] > y) != (b[1] > y): intersection = (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] if x < intersection: inside = not inside return inside def _rotated_rectangle(position: tuple[float, float], size: tuple[float, float], rotation: float) -> list[tuple[float, float]]: x, y = position width, height = size angle = math.radians(rotation) cos_a, sin_a = math.cos(angle), math.sin(angle) points = [] for dx, dy in ((-width / 2, -height / 2), (width / 2, -height / 2), (width / 2, height / 2), (-width / 2, height / 2)): points.append((x + dx * cos_a - dy * sin_a, y + dx * sin_a + dy * cos_a)) return points + [points[0]] def _bbox(points: Iterable[tuple[float, float]]) -> list[float]: values = list(points) return [min(p[0] for p in values), min(p[1] for p in values), max(p[0] for p in values), max(p[1] for p in values)] def _bbox_overlap(a: list[float], b: list[float]) -> bool: return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1] def _validate_and_normalize(drawing: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]], dict[str, Any]]: value = deepcopy(drawing) arrays = [value[name] for name in ("walls", "openings", "areas", "objects", "dimensions", "notes")] if sum(len(items) for items in arrays) > MAX_ENTITIES: raise DrawingError("CAD2D_ENTITY_LIMIT_EXCEEDED") identifiers = [item["id"] for items in arrays for item in items] if len(identifiers) != len(set(identifiers)): raise DrawingError("CAD2D_DUPLICATE_ID") walls: dict[str, dict[str, Any]] = {} wall_boxes: list[list[float]] = [] for wall in value["walls"]: start, end = _point(wall["start"]), _point(wall["end"]) length = _distance(start, end) if length <= 1e-6: raise DrawingError("CAD2D_ZERO_LENGTH_WALL", wall["id"]) if not 50 <= float(wall["thickness"]) <= 2000: raise DrawingError("CAD2D_WALL_THICKNESS_INVALID", wall["id"]) wall["start"], wall["end"] = list(start), list(end) wall["length"] = round(length, 6) walls[wall["id"]] = wall half = float(wall["thickness"]) / 2 wall_boxes.append([min(start[0], end[0]) - half, min(start[1], end[1]) - half, max(start[0], end[0]) + half, max(start[1], end[1]) + half]) openings_by_wall: dict[str, list[tuple[float, float, str]]] = {} for opening in value["openings"]: wall = walls.get(opening["wall_id"]) if wall is None: raise DrawingError("CAD2D_WALL_REFERENCE_MISSING", opening["id"]) start = float(opening["offset"]) end = start + float(opening["width"]) if start < 0 or end > wall["length"] + 1e-6: raise DrawingError("CAD2D_OPENING_OUTSIDE_WALL", opening["id"]) intervals = openings_by_wall.setdefault(opening["wall_id"], []) if any(start < old_end - 1e-6 and end > old_start + 1e-6 for old_start, old_end, _ in intervals): raise DrawingError("CAD2D_OPENINGS_OVERLAP", opening["id"]) intervals.append((start, end, opening["id"])) area_values: dict[str, float] = {} all_points: list[tuple[float, float]] = [] warnings: list[dict[str, str]] = [] for area in value["areas"]: points = [_point(point) for point in area["boundary"]] if _distance(points[0], points[-1]) > 1e-6: raise DrawingError("CAD2D_AREA_NOT_CLOSED", area["id"]) for index, (a, b) in enumerate(zip(points, points[1:])): for other, (c, d) in enumerate(zip(points, points[1:])): if abs(index - other) <= 1 or {index, other} == {0, len(points) - 2}: continue if _segments_intersect(a, b, c, d): raise DrawingError("CAD2D_AREA_SELF_INTERSECTION", area["id"]) size = _polygon_area(points) if size <= 1e-6: raise DrawingError("CAD2D_AREA_ZERO", area["id"]) if size < 500_000 or size > 1_000_000_000: warnings.append({"code": "CAD2D_AREA_SIZE_UNUSUAL", "entity_id": area["id"], "detail": "区域面积异常,请复核 Recipe。"}) area_values[area["id"]] = round(size, 3) area["boundary"] = [list(point) for point in points] all_points.extend(points) label_position = _point(area["label_position"]) all_points.append(label_position) if not _point_in_polygon(label_position, points): warnings.append({"code": "CAD2D_LABEL_OUTSIDE_AREA", "entity_id": area["id"], "detail": "区域标签位于边界之外。"}) object_boxes: dict[str, list[float]] = {} for item in value["objects"]: position = _point(item["position"]) kind = item["type"] if kind == "custom_circle": radius = float(item.get("radius", 0)) if radius <= 0 or "size" in item: raise DrawingError("CAD2D_OBJECT_SIZE_INVALID", item["id"]) box = [position[0] - radius, position[1] - radius, position[0] + radius, position[1] + radius] else: if kind == "custom_rectangle" and "size" not in item: raise DrawingError("CAD2D_OBJECT_SIZE_INVALID", item["id"]) size = tuple(float(v) for v in item.get("size", OBJECT_DEFAULTS.get(kind, (0, 0)))) if len(size) != 2 or min(size) <= 0 or "radius" in item: raise DrawingError("CAD2D_OBJECT_SIZE_INVALID", item["id"]) item["size"] = list(size) box = _bbox(_rotated_rectangle(position, size, float(item.get("rotation", 0)))) item["position"] = list(position) object_boxes[item["id"]] = box all_points.extend(((box[0], box[1]), (box[2], box[3]))) if any(_bbox_overlap(box, wall_box) for wall_box in wall_boxes): warnings.append({"code": "CAD2D_OBJECT_OVERLAPS_WALL", "entity_id": item["id"], "detail": "对象与墙体包围范围相交。"}) for opening in value["openings"]: if "door" not in opening["type"]: continue wall = walls[opening["wall_id"]] a, b = _point(wall["start"]), _point(wall["end"]) ratio = float(opening["offset"]) / wall["length"] hinge = (a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio) radius = float(opening["width"]) swing_box = [hinge[0] - radius, hinge[1] - radius, hinge[0] + radius, hinge[1] + radius] for object_id, box in object_boxes.items(): if _bbox_overlap(swing_box, box): warnings.append({"code": "CAD2D_DOOR_SWING_OBJECT_COLLISION", "entity_id": opening["id"], "detail": f"门扇可能与对象 {object_id} 碰撞。"}) for wall in walls.values(): all_points.extend((_point(wall["start"]), _point(wall["end"]))) for note in value["notes"]: all_points.append(_point(note["position"])) if not all_points: raise DrawingError("CAD2D_EMPTY_DRAWING") bounds = _bbox(all_points) sheet = value["sheet"] paper = SHEET_MM[sheet["size"]] if sheet["orientation"] == "landscape": paper = (paper[1], paper[0]) usable = ((paper[0] - 20) * sheet["scale"], (paper[1] - 20) * sheet["scale"]) if bounds[2] - bounds[0] > usable[0] + 1e-6 or bounds[3] - bounds[1] > usable[1] + 1e-6: raise DrawingError("CAD2D_DRAWING_OUTSIDE_SHEET") if len(identifiers) > 700: warnings.append({"code": "CAD2D_INFORMATION_DENSE", "entity_id": "drawing", "detail": "图纸信息密度较高,建议人工检查可读性。"}) for dimension in value["dimensions"]: source_points = dimension["points"] if "points" in dimension else [dimension["start"], dimension["end"]] points = [_point(p) for p in source_points] offset = float(dimension["offset"]) all_points.extend(points) if dimension["type"] == "vertical": all_points.extend((point[0] + offset, point[1]) for point in points) elif dimension["type"] in {"horizontal", "chain", "overall"}: all_points.extend((point[0], point[1] + offset) for point in points) else: all_points.extend((point[0] - abs(offset), point[1] - abs(offset)) for point in points) all_points.extend((point[0] + abs(offset), point[1] + abs(offset)) for point in points) if len(points) > 12: warnings.append({"code": "CAD2D_DIMENSION_TEXT_MAY_OVERLAP", "entity_id": dimension["id"], "detail": "连续尺寸较密,文字可能重叠。"}) return value, warnings, {"bbox": [round(v, 3) for v in bounds], "areas_mm2": area_values} def _add(entities: list[dict[str, Any]], kind: str, layer: str, **values: Any) -> None: entities.append({"kind": kind, "layer": layer, **values}) def _entities(drawing: dict[str, Any], metrics: dict[str, Any]) -> list[dict[str, Any]]: entities: list[dict[str, Any]] = [] for wall in drawing["walls"]: layer = "A-WALL-EXT" if wall["kind"] == "exterior" else "A-WALL-INT" a, b = _point(wall["start"]), _point(wall["end"]) length, half = wall["length"], float(wall["thickness"]) / 2 nx, ny = -(b[1] - a[1]) / length * half, (b[0] - a[0]) / length * half _add(entities, "polyline", layer, points=[(a[0]+nx,a[1]+ny),(b[0]+nx,b[1]+ny),(b[0]-nx,b[1]-ny),(a[0]-nx,a[1]-ny)], closed=True) for opening in drawing["openings"]: wall = next(item for item in drawing["walls"] if item["id"] == opening["wall_id"]) a, b = _point(wall["start"]), _point(wall["end"]) ux, uy = (b[0]-a[0])/wall["length"], (b[1]-a[1])/wall["length"] start = (a[0]+ux*opening["offset"], a[1]+uy*opening["offset"]) end = (start[0]+ux*opening["width"], start[1]+uy*opening["width"]) layer = "A-DOOR" if "door" in opening["type"] else "A-WINDOW" _add(entities, "line", layer, start=start, end=end) if "door" in opening["type"]: _add(entities, "arc", layer, center=start, radius=opening["width"], start_angle=0, end_angle=90) for area in drawing["areas"]: _add(entities, "polyline", "A-AREA", points=[_point(p) for p in area["boundary"][:-1]], closed=True) label = area["name"] + (f" {metrics['areas_mm2'][area['id']]/1_000_000:.2f} m²" if area["show_area"] else "") _add(entities, "text", "A-TEXT", point=_point(area["label_position"]), text=label, height=250) for item in drawing["objects"]: layer = "A-SAFE" if item["type"] in {"safety_exit", "extinguisher"} else "A-FURN" if item["type"] in {"desk", "chair", "cabinet"} else "A-EQUIP" position = _point(item["position"]) if item["type"] == "custom_circle": _add(entities, "circle", layer, center=position, radius=item["radius"]) else: _add(entities, "polyline", layer, points=_rotated_rectangle(position, tuple(item["size"]), float(item.get("rotation", 0)))[:-1], closed=True) if item.get("label"): _add(entities, "text", "A-TEXT", point=position, text=item["label"], height=220) for dimension in drawing["dimensions"]: source_points = dimension["points"] if "points" in dimension else [dimension["start"], dimension["end"]] points = [_point(p) for p in source_points] pairs = list(zip(points, points[1:])) if dimension["type"] == "overall": pairs = [(points[0], points[-1])] for a, b in pairs: offset = float(dimension["offset"]) if dimension["type"] == "vertical": aa, bb = (a[0]+offset,a[1]), (b[0]+offset,b[1]); distance = abs(b[1]-a[1]) elif dimension["type"] == "horizontal": aa, bb = (a[0],a[1]+offset), (b[0],b[1]+offset); distance = abs(b[0]-a[0]) else: length = _distance(a, b); nx, ny = (-(b[1]-a[1])/length, (b[0]-a[0])/length) aa, bb = (a[0]+nx*offset,a[1]+ny*offset), (b[0]+nx*offset,b[1]+ny*offset); distance = length _add(entities, "line", "A-DIMS", start=aa, end=bb) _add(entities, "text", "A-DIMS", point=((aa[0]+bb[0])/2,(aa[1]+bb[1])/2), text=f"{distance:.0f}", height=180) for note in drawing["notes"]: _add(entities, "text", "A-TEXT", point=_point(note["position"]), text=note["text"], height=220, rotation=float(note.get("rotation", 0))) bounds = metrics["bbox"] margin = max(500.0, max(bounds[2]-bounds[0], bounds[3]-bounds[1]) * 0.03) _add(entities, "polyline", "A-FRAME", points=[(bounds[0]-margin,bounds[1]-margin),(bounds[2]+margin,bounds[1]-margin),(bounds[2]+margin,bounds[3]+margin),(bounds[0]-margin,bounds[3]+margin)], closed=True) return entities def _dxf_pair(code: int, value: Any) -> str: if isinstance(value, float): value = format(value, ".9f").rstrip("0").rstrip(".") or "0" return f"{code}\r\n{value}\r\n" def _write_dxf(path: Path, entities: list[dict[str, Any]]) -> None: parts = [_dxf_pair(0,"SECTION"),_dxf_pair(2,"HEADER"),_dxf_pair(9,"$ACADVER"),_dxf_pair(1,"AC1027"),_dxf_pair(9,"$INSUNITS"),_dxf_pair(70,4),_dxf_pair(0,"ENDSEC"), _dxf_pair(0,"SECTION"),_dxf_pair(2,"TABLES"),_dxf_pair(0,"TABLE"),_dxf_pair(2,"LAYER"),_dxf_pair(70,len(LAYERS))] colors = [1, 8, 2, 4, 3, 6, 5, 1, 7, 7, 9, 7] for layer, color in zip(LAYERS, colors): parts += [_dxf_pair(0,"LAYER"),_dxf_pair(2,layer),_dxf_pair(70,0),_dxf_pair(62,color),_dxf_pair(6,"CONTINUOUS")] parts += [_dxf_pair(0,"ENDTAB"),_dxf_pair(0,"ENDSEC"),_dxf_pair(0,"SECTION"),_dxf_pair(2,"ENTITIES")] for entity in entities: kind, layer = entity["kind"], entity["layer"] if kind == "line": a,b=entity["start"],entity["end"]; parts += [_dxf_pair(0,"LINE"),_dxf_pair(8,layer),_dxf_pair(10,a[0]),_dxf_pair(20,a[1]),_dxf_pair(11,b[0]),_dxf_pair(21,b[1])] elif kind == "polyline": parts += [_dxf_pair(0,"LWPOLYLINE"),_dxf_pair(8,layer),_dxf_pair(90,len(entity["points"])),_dxf_pair(70,1 if entity.get("closed") else 0)] for point in entity["points"]: parts += [_dxf_pair(10,point[0]),_dxf_pair(20,point[1])] elif kind == "circle": c=entity["center"]; parts += [_dxf_pair(0,"CIRCLE"),_dxf_pair(8,layer),_dxf_pair(10,c[0]),_dxf_pair(20,c[1]),_dxf_pair(40,entity["radius"])] elif kind == "arc": c=entity["center"]; parts += [_dxf_pair(0,"ARC"),_dxf_pair(8,layer),_dxf_pair(10,c[0]),_dxf_pair(20,c[1]),_dxf_pair(40,entity["radius"]),_dxf_pair(50,entity["start_angle"]),_dxf_pair(51,entity["end_angle"])] else: p=entity["point"]; parts += [_dxf_pair(0,"TEXT"),_dxf_pair(8,layer),_dxf_pair(10,p[0]),_dxf_pair(20,p[1]),_dxf_pair(40,entity["height"]),_dxf_pair(1,entity["text"]),_dxf_pair(50,entity.get("rotation",0))] parts += [_dxf_pair(0,"ENDSEC"),_dxf_pair(0,"EOF")] _atomic_bytes(path, "".join(parts).encode("utf-8")) def _reopen_dxf(path: Path) -> dict[str, Any]: lines = path.read_text(encoding="utf-8").splitlines() if len(lines) % 2 or "AC1027" not in lines or lines[-1] != "EOF": raise DrawingError("CAD2D_DXF_REOPEN_FAILED") pairs = [(lines[i].strip(), lines[i+1]) for i in range(0, len(lines), 2)] layers = {value for index,(code,value) in enumerate(pairs) if code == "0" and value == "LAYER" for next_code,value in pairs[index+1:index+4] if next_code == "2"} entity_names = {"LINE","LWPOLYLINE","CIRCLE","ARC","TEXT"} kinds = Counter(value for code,value in pairs if code == "0" and value in entity_names) if layers != set(LAYERS) or not kinds: raise DrawingError("CAD2D_DXF_STRUCTURE_INVALID") points: list[tuple[float, float]] = [] for index, (code, name) in enumerate(pairs): if code != "0" or name not in entity_names: continue block: list[tuple[str, str]] = [] for pair in pairs[index + 1:]: if pair[0] == "0": break block.append(pair) def values(group: str) -> list[float]: return [float(value) for item_code, value in block if item_code == group] xs, ys = values("10"), values("20") if name == "LINE": xs += values("11"); ys += values("21") if name in {"CIRCLE", "ARC"} and xs and ys: radius = values("40")[0] points.extend(((xs[0] - radius, ys[0] - radius), (xs[0] + radius, ys[0] + radius))) else: points.extend(zip(xs, ys)) if not points: raise DrawingError("CAD2D_DXF_BOUNDS_INVALID") return {"version": "R2013", "unit": "mm", "layers": sorted(layers), "entity_types": dict(sorted(kinds.items())), "entity_count": sum(kinds.values()), "bbox": [round(value, 3) for value in _bbox(points)]} def _entity_bbox(entities: list[dict[str, Any]]) -> list[float]: points: list[tuple[float, float]] = [] for entity in entities: if entity["kind"] == "line": points.extend((entity["start"], entity["end"])) elif entity["kind"] == "polyline": points.extend(entity["points"]) elif entity["kind"] in {"circle", "arc"}: x, y = entity["center"]; radius = float(entity["radius"]) points.extend(((x - radius, y - radius), (x + radius, y + radius))) else: points.append(entity["point"]) return [round(value, 3) for value in _bbox(points)] def _xml(value: str) -> str: return value.replace("&","&").replace("<","<").replace(">",">").replace('"',""") def _svg(path: Path, entities: list[dict[str, Any]], bounds: list[float]) -> None: width,height=max(1,bounds[2]-bounds[0]),max(1,bounds[3]-bounds[1]); stroke=max(width,height)/1200 parts=[f'', '', ''%stroke] for e in entities: if e["kind"]=="line": parts.append(f'') elif e["kind"]=="polyline": parts.append(''%' '.join(f'{p[0]},{-p[1]}' for p in e["points"])) elif e["kind"]=="circle": parts.append(f'') elif e["kind"]=="arc": parts.append(f'') parts.append('') for e in entities: if e["kind"]=="text": parts.append(f'{_xml(str(e["text"]))}') parts.append('') _atomic_bytes(path, ("\n".join(parts)+"\n").encode()) def _font_path() -> Path: root = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" for name in ("msyh.ttc", "simhei.ttf"): candidate = root / name if candidate.is_file(): return candidate raise DrawingError("CAD2D_CHINESE_FONT_UNAVAILABLE") def _raster(path: Path, entities: list[dict[str, Any]], bounds: list[float]) -> None: from PIL import Image, ImageDraw, ImageFont width,height=1600,1100; margin=60; sx=(width-2*margin)/max(1,bounds[2]-bounds[0]); sy=(height-2*margin)/max(1,bounds[3]-bounds[1]); scale=min(sx,sy) def pixel(point: tuple[float,float]) -> tuple[int,int]: return (round(margin+(point[0]-bounds[0])*scale),round(height-margin-(point[1]-bounds[1])*scale)) image=Image.new("RGB",(width,height),"white"); draw=ImageDraw.Draw(image); font=ImageFont.truetype(str(_font_path()),18) for e in entities: if e["kind"]=="line": draw.line([pixel(e["start"]),pixel(e["end"])],fill="#111111",width=2) elif e["kind"]=="polyline": points=[pixel(p) for p in e["points"]]; draw.line(points+[points[0]],fill="#111111",width=3 if "WALL" in e["layer"] else 1) elif e["kind"]=="circle": c=pixel(e["center"]); r=max(1,round(e["radius"]*scale)); draw.ellipse((c[0]-r,c[1]-r,c[0]+r,c[1]+r),outline="#111111",width=2) elif e["kind"]=="arc": c=pixel(e["center"]); r=max(1,round(e["radius"]*scale)); draw.arc((c[0]-r,c[1]-r,c[0]+r,c[1]+r),180,270,fill="#333333",width=2) else: p=pixel(e["point"]); draw.text(p,str(e["text"]),font=font,fill="#111111",anchor="mm") image.save(path, format="PNG", optimize=False, compress_level=9) def _pdf_from_png(png: Path, pdf: Path) -> None: import time from PIL import Image with Image.open(png) as image: fixed_time = time.gmtime(0) image.convert("RGB").save( pdf, format="PDF", resolution=150.0, title="zcbot 2D CAD drawing", creationDate=fixed_time, modDate=fixed_time, ) def _record(job_dir: Path) -> dict[str, Any]: raw = (job_dir / "request" / "request.json").read_bytes() if len(raw) > MAX_REQUEST_BYTES + 131_072: raise DrawingError("CAD2D_REQUEST_TOO_LARGE") value = json.loads(raw) if not isinstance(value, dict) or not isinstance(value.get("request"), dict): raise DrawingError("CAD2D_JOB_REQUEST_INVALID") return value def run(job_dir: Path) -> list[dict[str, Any]]: job_dir=job_dir.resolve(strict=True); record=_record(job_dir); request=record["request"] if request.get("inputs"): raise DrawingError("CAD2D_INPUTS_MUST_BE_EMPTY") if request.get("outputs"): raise DrawingError("CAD2D_OUTPUTS_MUST_BE_EMPTY") drawing,warnings,metrics=_validate_and_normalize(request["operation"]["drawing"]) output=(job_dir/"output").resolve(); output.mkdir(exist_ok=True) if not output.is_relative_to(job_dir): raise DrawingError("CAD2D_OUTPUT_PATH_ESCAPES_JOB") metadata=output/".meta"; metadata.mkdir(exist_ok=True) entities=_entities(drawing,metrics) dxf=output/"drawing.dxf"; svg=output/"drawing.svg"; png=output/"drawing.png"; pdf=output/"drawing.pdf" _write_dxf(dxf,entities); reopened=_reopen_dxf(dxf) if reopened["entity_count"] != len(entities): raise DrawingError("CAD2D_ENTITY_COUNT_MISMATCH") if reopened["bbox"] != _entity_bbox(entities): raise DrawingError("CAD2D_DXF_BOUNDS_MISMATCH") bounds=metrics["bbox"]; padding=max(500.0,max(bounds[2]-bounds[0],bounds[3]-bounds[1])*.05); render_bounds=[bounds[0]-padding,bounds[1]-padding,bounds[2]+padding,bounds[3]+padding] _svg(svg,entities,render_bounds); _raster(png,entities,render_bounds); _pdf_from_png(png,pdf) recipe_path=output/"drawing-recipe.json"; report_path=output/"validation-report.json" _atomic_json(recipe_path,{"schema_version":1,"drawing":drawing}) observed_layer_counts=Counter(e["layer"] for e in entities) layer_counts={layer: observed_layer_counts.get(layer, 0) for layer in LAYERS} report={"passed":True,"errors":[],"warnings":warnings,"checks":{"dxf_reopened":True,"version":"R2013","unit":"mm","layers":list(LAYERS),"entity_count":len(entities),"bbox":bounds,"dxf_bbox":reopened["bbox"],"areas_mm2":metrics["areas_mm2"]}} _atomic_json(report_path,report) summaries=[] for path in (dxf,svg,png,pdf,recipe_path,report_path): summaries.append({"filename":path.name,"size_bytes":path.stat().st_size,"sha256":_sha256(path)}) manifest={"adapter_version":ADAPTER_VERSION,"recipe_version":1,"drawing_type":drawing["type"],"unit":"mm","sheet":drawing["sheet"],"bbox":bounds,"areas_mm2":metrics["areas_mm2"],"layers":list(LAYERS),"layer_entity_counts":layer_counts,"entity_count":len(entities),"warnings":warnings,"artifacts":summaries} manifest_path=output/"drawing-manifest.json"; _atomic_json(manifest_path,manifest) provenance_path=metadata/"provenance.json"; _atomic_json(provenance_path,{"adapter":"cad.drawing.author@v1","adapter_version":ADAPTER_VERSION,"request_digest":record.get("request_digest"),"recipe_sha256":_sha256(recipe_path),"manifest_sha256":_sha256(manifest_path),"renderer":{"font":_font_path().name,"png":"Pillow","svg":"builtin","pdf":"Pillow","dxf":"builtin-r2013"}}) paths=[dxf,svg,png,pdf,recipe_path,manifest_path,report_path,provenance_path] if any(not p.is_file() or p.stat().st_size==0 for p in paths): raise DrawingError("CAD2D_REQUIRED_OUTPUT_MISSING") return [_artifact(path) for path in paths] def probe() -> int: payload={"adapter_version":ADAPTER_VERSION,"software":"二维 CAD 工程制图","software_version":None,"health":"unavailable","detail":""} try: from PIL import __version__ as pillow_version if pillow_version != PILLOW_VERSION: raise DrawingError("CAD2D_PILLOW_VERSION_UNSUPPORTED", f"expected {PILLOW_VERSION}, found {pillow_version}") font=_font_path(); payload.update({"software_version":f"Pillow {pillow_version}","health":"ready","detail":f"Runtime-only renderer ready; Chinese font: {font.name}; DXF R2013 reopen validator ready"}) except (ImportError,OSError,DrawingError) as exc: payload["detail"]=str(exc)[:500] print(json.dumps(payload,ensure_ascii=False)); return 0 def main() -> int: if sys.argv[1:]==["--probe"]: return probe() if len(sys.argv)!=2: print("[ERR] Usage: worker.py ",file=sys.stderr); return 2 job_dir=Path(sys.argv[1]); record:dict[str,Any]={} try: record=_record(job_dir); artifacts=run(job_dir); _atomic_json(job_dir/"artifacts.json",artifacts); _atomic_json(job_dir/"terminal.json",_terminal(record,"succeeded",artifacts)); print("[OK] 2D CAD drawing authored and verified."); return 0 except Exception as exc: code=exc.code if isinstance(exc,DrawingError) else "CAD2D_ADAPTER_FAILED" try: _atomic_json(job_dir/"terminal.json",_terminal(record,"failed",[],code,str(exc))) except OSError: pass print(f"[ERR] {code}: {exc}",file=sys.stderr); return 1 if __name__ == "__main__": raise SystemExit(main())