""" SW 建模历史 JSON → 参数化 CDSL 转换器 (v4) ============================================ 严格按照 015133 手册要求生成 LLM 训练友好的 CDSL: Phase 1 — 草图参数化 (注册表模式): - @sketch_classifier 装饰器注册形状分类器 - 通用性: 新增分类器无需修改主逻辑 - 与 sketch_solver.py 的 SHAPE_GENERATORS 对齐 Phase 2 — 同形复用: - 检测内容相同的草图 → profile_from: "sk_xx" - profile_shift 处理镜像偏移 Phase 3 — 轴原点推导: - 所有 revolve 特征 axis.origin_mm → from_contour_vertex """ from __future__ import annotations import hashlib import json import math from copy import deepcopy from pathlib import Path from typing import Any, Callable try: from .translator import normalize_to_ir except ImportError: # 允许直接 python engine/convert_to_cdsl.py from translator import normalize_to_ir # ═══════════════════════════════════════════════════════════════ # 草图分类器注册表 —— 通用性核心 # ═══════════════════════════════════════════════════════════════ # 分类器签名: 接收 (lines, arcs, circles, const_lines, all_non_const) -> profile_dict | None ClassifierFunc = Callable[[list[dict], list[dict], list[dict], list[dict], list[dict]], dict[str, Any] | None] SKETCH_CLASSIFIERS: list[tuple[str, int, ClassifierFunc]] = [] """形状分类器注册表 (name, priority, function)。priority 越小优先级越高。""" def register_classifier(name: str, priority: int = 100) -> Callable[[ClassifierFunc], ClassifierFunc]: """注册装饰器:将分类函数注册到 SKETCH_CLASSIFIERS。 用法: @register_classifier("obround", priority=10) def _classify_obround(lines, arcs, circles, const_lines, all_ents): ... return profile_dict or None """ def decorator(fn: ClassifierFunc) -> ClassifierFunc: SKETCH_CLASSIFIERS.append((name, priority, fn)) return fn return decorator def _classify_sketch_shape(sketch: dict[str, Any]) -> dict[str, Any]: """按优先级依次尝试所有注册的分类器,返回第一个非 None 结果。 架构设计: 新增形状只需 @register_classifier 装饰一个函数, 无需修改此函数或 if/elif 链。—— 015133 手册"注册表模式" """ entities = sketch.get("entities") or [] if not entities: return None non_const = [e for e in entities if not e.get("construction")] const_lines = [e for e in entities if e.get("type") == "line" and e.get("construction")] lines = [e for e in non_const if e.get("type") == "line"] circles = [e for e in non_const if e.get("type") == "circle"] arcs = [e for e in non_const if e.get("type") == "arc"] if len(lines) == 0 and len(circles) == 0 and len(arcs) == 0: return None # 预计算常用辅助数据(避免每个分类器重复计算) # 按优先级执行分类器 sorted_classifiers = sorted(SKETCH_CLASSIFIERS, key=lambda x: x[1]) for name, priority, fn in sorted_classifiers: result = fn(lines, arcs, circles, const_lines, non_const) if result is not None: return result # 无法识别的形状: 015133 坐标仅存 compiler_context return { "type": "unknown_shape", "signature": "L{}_A{}_C{}".format(len(lines), len(arcs), len(circles)), "_note": "exact rebuild will use compiler_context", } # ═══════════════════════════════════════════════════════════════ # 基础几何形状分类器 # ═══════════════════════════════════════════════════════════════ @register_classifier("circle", priority=1) def _classify_circle(lines, arcs, circles, const_lines, all_ents): """单圆:1 个 circle 实体,无线段无弧""" if len(lines) == 0 and len(arcs) == 0 and len(circles) == 1: c = circles[0] return { "type": "circle", "radius_mm": round(float(c["radius_mm"]), 6), "center": [round(float(c["center"][0]), 6), round(float(c["center"][1]), 6)], } return None @register_classifier("annulus", priority=2) def _classify_annulus(lines, arcs, circles, const_lines, all_ents): """同心圆环:2 个同心圆""" if len(lines) != 0 or len(arcs) != 0 or len(circles) != 2: return None c0, c1 = circles[0], circles[1] if (abs(float(c0["center"][0]) - float(c1["center"][0])) < 1e-6 and abs(float(c0["center"][1]) - float(c1["center"][1])) < 1e-6): radii = sorted([float(c0["radius_mm"]), float(c1["radius_mm"])]) return { "type": "annulus", "center": [round(float(c0["center"][0]), 6), round(float(c0["center"][1]), 6)], "outer_radius_mm": round(radii[1], 6), "inner_radius_mm": round(radii[0], 6), } return None @register_classifier("circles", priority=3) def _classify_circles(lines, arcs, circles, const_lines, all_ents): """多圆(非同心):N 个圆,无线段""" if len(lines) == 0 and len(arcs) == 0 and len(circles) >= 2: return { "type": "circles", "items": [{ "center": [round(float(c["center"][0]), 6), round(float(c["center"][1]), 6)], "radius_mm": round(float(c["radius_mm"]), 6), } for c in circles], } return None @register_classifier("rectangle", priority=4) def _classify_rect(lines, arcs, circles, const_lines, all_ents): """纯矩形:4 条线,无弧""" if len(lines) == 4 and len(arcs) == 0 and len(circles) == 0: return _classify_rectangle(lines) return None @register_classifier("obround", priority=5) def _classify_obround(lines, arcs, circles, const_lines, all_ents): """槽形/键槽:2 条平行直线 + 2 段半圆弧 = 运动场的形状。 检测几何签名: - 2 条直线(长度相等且平行) - 2 条圆弧(半径相等,两弧圆心在直线端点处) - 2 条直线端点分别连接到 2 条弧的端点 """ if len(lines) != 2 or len(arcs) != 2 or len(circles) > 0: return None # 获取线条方向 s1 = [float(lines[0]["start"][0]), float(lines[0]["start"][1])] e1 = [float(lines[0]["end"][0]), float(lines[0]["end"][1])] s2 = [float(lines[1]["start"][0]), float(lines[1]["start"][1])] e2 = [float(lines[1]["end"][0]), float(lines[1]["end"][1])] # 线 1 方向 d1_u, d1_v = e1[0] - s1[0], e1[1] - s1[1] d2_u, d2_v = e2[0] - s2[0], e2[1] - s2[1] len1 = math.hypot(d1_u, d1_v) len2 = math.hypot(d2_u, d2_v) if len1 < 1e-6 or len2 < 1e-6: return None # 平行性检查 (cos 接近 ±1) dot = (d1_u * d2_u + d1_v * d2_v) / (len1 * len2) if abs(abs(dot) - 1.0) > 0.01: return None # 长度相等 if abs(len1 - len2) > 0.1: return None # 弧半径相等 r1 = float(arcs[0].get("radius_mm", 0)) r2 = float(arcs[1].get("radius_mm", 0)) if abs(r1 - r2) > 0.01 or r1 <= 0: return None # 直线平行 → X_dir 与线条方向平行 # obround 方向: length 沿线条方向, width = 2*r dir_u, dir_v = d1_u / len1, d1_v / len1 width = 2 * r1 length = len1 + width # 总长 = 直线长度 + 2*半径 mid_x = (s1[0] + e1[0] + s2[0] + e2[0]) / 4 mid_y = (s1[1] + e1[1] + s2[1] + e2[1]) / 4 return { "type": "obround", "center": [round(mid_x, 6), round(mid_y, 6)], "length_mm": round(length, 6), "width_mm": round(width, 6), } @register_classifier("d_shape", priority=6) def _classify_d_shape(lines, arcs, circles, const_lines, all_ents): """D 形:1 条直线 + 1 段圆弧(半圆+弦线)。 几何签名: - 1 条线 + 1 条弧 - 弧的端点 = 线的端点 - 弧接近半圆 (sweep ≈ 180°) """ if len(lines) != 1 or len(arcs) != 1 or len(circles) > 0: return None ln_start = [float(lines[0]["start"][0]), float(lines[0]["start"][1])] ln_end = [float(lines[0]["end"][0]), float(lines[0]["end"][1])] arc_center = [float(arcs[0].get("center", [0, 0])[0]), float(arcs[0].get("center", [0, 0])[1])] arc_radius = float(arcs[0].get("radius_mm", 0)) arc_sweep = arcs[0].get("arc_sweep_deg", 0) if arc_radius <= 0: return None # 弧端点应与线端点重合 arc_start = [float(arcs[0].get("start", [0, 0])[0]), float(arcs[0].get("start", [0, 0])[1])] arc_end = [float(arcs[0].get("end", [0, 0])[0]), float(arcs[0].get("end", [0, 0])[1])] # 线/弧端点匹配(任意组合) eps = 0.01 dists = [ (math.hypot(ln_start[0] - arc_start[0], ln_start[1] - arc_start[1]), math.hypot(ln_end[0] - arc_end[0], ln_end[1] - arc_end[1])), (math.hypot(ln_start[0] - arc_end[0], ln_start[1] - arc_end[1]), math.hypot(ln_end[0] - arc_start[0], ln_end[1] - arc_start[1])), ] ok = any(d[0] < eps and d[1] < eps for d in dists) if not ok: return None # 弦线方向: 圆心到弦中点的方向 = 负 chord_sign chord_mid = [(ln_start[0] + ln_end[0]) / 2, (ln_start[1] + ln_end[1]) / 2] to_chord = [chord_mid[0] - arc_center[0], chord_mid[1] - arc_center[1]] cdx = arc_center[0] - chord_mid[0] if cdx > 0: chord_sign = "left" # 圆心在弦右侧,弧开口朝左(D 的圆弧在左侧) else: chord_sign = "right" chord_len = math.hypot(ln_end[0] - ln_start[0], ln_end[1] - ln_start[1]) chord_x = math.sqrt(max(0, arc_radius * arc_radius - (chord_len / 2) ** 2)) return { "type": "d_shape", "radius_mm": round(arc_radius, 6), "chord_sign": chord_sign, "chord_x_mm": round(chord_x, 6), } @register_classifier("partial_ring", priority=7) def _classify_partial_ring(lines, arcs, circles, const_lines, all_ents): """部分圆环(扇区环):2 段同心弧 + 2 条径向直线。 几何签名: - 2 线 + 2 弧 - 2 弧同心 - 每条线连接一对外弧端点和内弧端点 """ if len(lines) != 2 or len(arcs) != 2 or len(circles) > 0: return None c0 = [float(arcs[0].get("center", [0, 0])[0]), float(arcs[0].get("center", [0, 0])[1])] c1 = [float(arcs[1].get("center", [0, 0])[0]), float(arcs[1].get("center", [0, 0])[1])] if abs(c0[0] - c1[0]) > 0.01 or abs(c0[1] - c1[1]) > 0.01: return None r0 = float(arcs[0].get("radius_mm", 0)) r1 = float(arcs[1].get("radius_mm", 0)) if r0 <= 0 or r1 <= 0: return None ir = min(r0, r1) oR = max(r0, r1) # 线的端点应连接到弧端点 # 径向线应从内弧端点到外弧端点 # 计算大约的角度范围 a0_s = [float(arcs[0].get("start", [0, 0])[0]), float(arcs[0].get("start", [0, 0])[1])] a0_e = [float(arcs[0].get("end", [0, 0])[0]), float(arcs[0].get("end", [0, 0])[1])] # 相对于圆心的角度 def angle_from(u, v): return math.degrees(math.atan2(v - c0[1], u - c0[0])) angles = [angle_from(p[0], p[1]) for p in [a0_s, a0_e]] half_angle = abs(angles[0] - angles[1]) / 2 if half_angle > 180: half_angle = 360 - half_angle return { "type": "partial_ring", "inner_radius_mm": round(ir, 6), "outer_radius_mm": round(oR, 6), "half_angle_deg": round(half_angle, 6), } @register_classifier("rectangle_with_circles", priority=8) def _classify_rect_with_circles(lines, arcs, circles, const_lines, all_ents): """矩形内嵌圆孔:矩形 + 内部圆形孔洞。 几何签名: - 4 条线构成矩形 - N 个圆全部在矩形内部 """ if len(lines) != 4 or len(arcs) != 0 or len(circles) == 0: return None rect = _classify_rectangle(lines) if rect is None: return None # 检查所有圆是否在矩形内 mn = rect.get("min_mm", [0, 0]) mx = rect.get("max_mm", [0, 0]) x0, y0 = mn[0], mn[1] x1, y1 = mx[0], mx[1] inner_circles = [] for c in circles: cx = float(c["center"][0]) cy = float(c["center"][1]) cr = float(c["radius_mm"]) if x0 - cr < cx < x1 + cr and y0 - cr < cy < y1 + cr: inner_circles.append({ "center": [round(cx, 6), round(cy, 6)], "radius_mm": round(cr, 6), }) if not inner_circles: return None return { "type": "rectangle_with_circles", "boundary": { "type": "rectangle", "width_mm": rect.get("width_mm"), "height_mm": rect.get("height_mm"), "min_mm": mn, "max_mm": mx, }, "circles": inner_circles, } @register_classifier("polygon", priority=50) def _classify_polygon(lines, arcs, circles, const_lines, all_ents): """普通多边形:N 条线段,无弧无圆。顶点坐标仅存 compiler_context。""" if len(lines) < 3 or len(arcs) > 0 or len(circles) > 0: return None # 4 线优先返回 rectangle if len(lines) == 4: rect = _classify_rectangle(lines) if rect: rect["type"] = "rectangle" return rect # 015133: polygon 不携带顶点坐标,坐标属于 Execution IR has_axis = len(const_lines) >= 1 return { "type": "polygon", "n_sides": len(lines), "_has_construction_axis": has_axis, } @register_classifier("revolve_straight_profile", priority=55) def _classify_revolve_polygon(lines, arcs, circles, const_lines, all_ents): """旋转特征的直边截面(有 construction line 做轴,5 边梯形等)。 > polygon 分类器(priority=50) 如果没匹配到,说明有弧/圆。 > 这里处理有构造线但非纯多边形的情况。 """ if len(const_lines) < 1: return None if len(lines) < 3: return None if len(arcs) > 0 or len(circles) > 0: return None # 尝试 revolve_chamfer / revolve_chamfer_slanted 识别 if len(lines) == 5: vertices = _extract_polygon_vertices(lines) if len(vertices) == 5: # 5 边梯形截面: revolve_chamfer 或 slanted 变体 # 分析形状特征 v = vertices # 找到到原点最近的点 (轴侧) dists = [math.hypot(vx[0], vx[1]) for vx in v] min_idx = dists.index(min(dists)) axis_v = v[min_idx] max_idx = dists.index(max(dists)) wall_v = v[max_idx] # 最高点和最低点 ys = [vx[1] for vx in v] top_y, bot_y = max(ys), min(ys) # 找到轴侧高度 axis_height = abs(top_y - bot_y) # 找到壁侧参数 wall_width = abs(wall_v[0]) wall_y = wall_v[1] wall_inset = abs(top_y - wall_y) if wall_inset > 0: return { "type": "revolve_chamfer_slanted", "axis_height_mm": round(axis_height, 6), "top_width_mm": round(abs(axis_v[0]) if abs(axis_v[0]) > 0.01 else abs(wall_width), 6), "wall_inset_mm": round(wall_inset, 6), "wall_height_mm": round(abs(bot_y - wall_y), 6), "wall_width_mm": round(wall_width, 6), "on_axis_side": "right" if wall_v[0] > 0 else "left", } return { "type": "revolve_chamfer", "axis_height_mm": round(axis_height, 6), "top_width_mm": round(abs(float(v[max_idx][0])), 6), "bottom_width_mm": round(abs(float(v[(min_idx + 2) % 5][0])), 6), "wall_inset_mm": round(wall_inset, 6), "on_axis_side": "right" if wall_v[0] > 0 else "left", } # 普通 revolve 多边形截面 vertices = _extract_polygon_vertices(lines) if len(vertices) >= 3: return { "type": "polygon", "vertices": vertices, "_has_construction_axis": True, } return None @register_classifier("complex_arc_shape", priority=90) def _classify_complex_arc_shape(lines, arcs, circles, const_lines, all_ents): """含弧复杂形状:任何无法被更高优先级分类器识别的含弧草图。 015133: 坐标仅存 compiler_context,CDSL 只保留类型签名。 """ if len(arcs) == 0: return None return { "type": "complex_arc_shape", "n_lines": len(lines), "n_arcs": len(arcs), "n_circles": len(circles), "_signature": "L{}_A{}_C{}".format(len(lines), len(arcs), len(circles)), } def _clean_cdsl_floats(obj: Any, decimals: int = 6) -> Any: """递归清理CDSL中所有浮点数到指定精度。移除IEEE 754噪音 (如31.500000000000007→31.5)。 015133标准: LLM不应学习的浮点精度噪音。 """ if isinstance(obj, dict): return {k: _clean_cdsl_floats(v, decimals) for k, v in obj.items()} elif isinstance(obj, list): return [_clean_cdsl_floats(item, decimals) for item in obj] elif isinstance(obj, float): # 跳过方向向量 (归一化后的单位向量分量本身就是无理数,如0.707...) # 跳过编译器上下文中的精确值 return round(obj, decimals) return obj def _content_hash(sketch: dict[str, Any]) -> str: """对草图实体内容计算 hash,用于检测同形复用""" entities = sketch.get("entities") or [] keys_data = [] for ent in entities: if ent.get("construction"): continue t = ent.get("type", "?") if t == "line": s = ent.get("start", [0, 0]) e = ent.get("end", [0, 0]) keys_data.append(f"line:{s[0]:.4f},{s[1]:.4f}:{e[0]:.4f},{e[1]:.4f}") elif t == "circle": c = ent.get("center", [0, 0]) r = ent.get("radius_mm", 0) keys_data.append(f"circle:{c[0]:.4f},{c[1]:.4f}:r{r:.4f}") elif t == "arc": c = ent.get("center", [0, 0]) r = ent.get("radius_mm", 0) s = ent.get("start", [0, 0]) e = ent.get("end", [0, 0]) keys_data.append(f"arc:{c[0]:.4f},{c[1]:.4f}:r{r:.4f}:{s[0]:.4f},{s[1]:.4f}:{e[0]:.4f},{e[1]:.4f}") elif t == "point": p = ent.get("point_mm") or ent.get("point") or [0, 0] keys_data.append(f"point:{p[0]:.4f},{p[1]:.4f}") else: keys_data.append(f"unknown:{json.dumps(ent, sort_keys=True)}") keys_data.sort() return hashlib.md5("|".join(keys_data).encode()).hexdigest()[:12] def _classify_rectangle(lines: list[dict[str, Any]]) -> dict[str, Any] | None: """从 4 条线段识别矩形""" pts = [] for l in lines: s = l.get("start", [0, 0]) e = l.get("end", [0, 0]) pts.append((round(float(s[0]), 6), round(float(s[1]), 6))) pts.append((round(float(e[0]), 6), round(float(e[1]), 6))) xs = [p[0] for p in pts] ys = [p[1] for p in pts] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) w = round(max_x - min_x, 6) h = round(max_y - min_y, 6) if w < 1e-6 or h < 1e-6: return None # rectangle 只保留 width/height + center,min_mm/max_mm 是坐标污染 cx = round((min_x + max_x) / 2, 6) cy = round((min_y + max_y) / 2, 6) result = { "type": "rectangle", "width_mm": w, "height_mm": h, "center": [cx, cy], } return result def _extract_polygon_vertices(lines: list[dict[str, Any]]) -> list[list[float]]: """从连续线段提取有序顶点""" if len(lines) < 2: return [] adj = {} for l in lines: s = (round(float(l["start"][0]), 6), round(float(l["start"][1]), 6)) e = (round(float(l["end"][0]), 6), round(float(l["end"][1]), 6)) adj[s] = e if not adj: return [] first = next(iter(adj.keys())) order = [first] cur = first seen = {first} for _ in range(len(lines) + 1): nxt = adj.get(cur) if nxt is None or nxt in seen: break order.append(nxt) seen.add(nxt) cur = nxt return [[float(v[0]), float(v[1])] for v in order] def _generate_contour_from_loops(sketch: dict[str, Any]) -> list[dict[str, Any]]: """从 IR 草图 loops + entities 生成 contour_edges_mm""" entities = sketch.get("entities") or [] loops = sketch.get("loops") or [] contours = [] # 如果没有 loops 数据,从 entities 直接生成 if not loops: non_const = [e for e in entities if not e.get("construction")] for ent in non_const: c = _entity_to_contour(ent) if c: contours.append(c) return contours # 有 loops 数据:从 loop 的 entity_indices 提取 for loop in loops: indices = loop.get("entity_indices") or [] for idx in indices: if idx < len(entities) and not entities[idx].get("construction", False): c = _entity_to_contour(entities[idx]) if c: contours.append(c) return contours def _entity_to_contour(ent: dict[str, Any]) -> dict[str, Any] | None: """将单个草图实体转换为 contour_edge""" t = ent.get("type", "") if t == "line": s = ent.get("start", [0, 0]) e = ent.get("end", [0, 0]) return { "type": "line", "start_mm": [float(s[0]), float(s[1]), 0.0], "end_mm": [float(e[0]), float(e[1]), 0.0], } elif t == "arc": s = ent.get("start", [0, 0]) e = ent.get("end", [0, 0]) c = ent.get("center", [0, 0]) return { "type": "arc", "start_mm": [float(s[0]), float(s[1]), 0.0], "end_mm": [float(e[0]), float(e[1]), 0.0], "center_mm": [float(c[0]), float(c[1]), 0.0], "radius_mm": float(ent.get("radius_mm", 0)), } elif t == "circle": c = ent.get("center", [0, 0]) r = ent.get("radius_mm", 0) # 圆分解为 4 段弧 cx, cy = float(c[0]), float(c[1]) return { "type": "arc", "start_mm": [cx + r, cy, 0.0], "end_mm": [cx, cy + r, 0.0], "center_mm": [cx, cy, 0.0], "radius_mm": r, } return None # ═══════════════════════════════════════════════════════════════ # 主轴: SW JSON → 参数化 CDSL # ═══════════════════════════════════════════════════════════════ def convert_sw_json_to_cdsl( sw_json_path: str | Path, part_id: str | None = None, ) -> dict[str, Any]: sw_json_path = Path(sw_json_path) with open(sw_json_path, "r", encoding="utf-8") as f: sw_data = json.load(f) if part_id is None: part_id = sw_json_path.stem.replace(".solidworks_rebuild_extract", "") ir = normalize_to_ir(sw_data) # ── Phase 1: 参数化每个草图 ── ir_sketches = {s["id"]: s for s in ir.get("sketches", [])} cdsl_sketches: list[dict[str, Any]] = [] id_to_cdsl_sketch: dict[str, dict[str, Any]] = {} for sk_id, sk in sorted(ir_sketches.items()): name = sk.get("name", "") wp = sk.get("workplane") or {} # 跳过放样轮廓草图(由 loft 操作直接处理) if sk.get("loft_profile"): continue # 跳过纯参考草图 (如 body reference) entities = sk.get("entities") or [] non_const = [e for e in entities if not e.get("construction")] if len(non_const) == 0: cdsl_sk = { "id": sk_id, "name": name, "workplane": { "origin_mm": wp.get("origin_mm", [0, 0, 0]), "x_dir": wp.get("x_dir", [1, 0, 0]), "y_dir": wp.get("y_dir", [0, 1, 0]), "normal": wp.get("normal", [0, 0, 1]), }, } cdsl_sketches.append(cdsl_sk) id_to_cdsl_sketch[sk_id] = cdsl_sk continue profile = _classify_sketch_shape(sk) content_h = _content_hash(sk) cdsl_sk = { "id": sk_id, "name": name, "workplane": { "origin_mm": wp.get("origin_mm", [0, 0, 0]), "x_dir": wp.get("x_dir", [1, 0, 0]), "y_dir": wp.get("y_dir", [0, 1, 0]), "normal": wp.get("normal", [0, 0, 1]), }, "_content_hash": content_h, } if profile: # 复杂形状回退:保留原始实体和 contour_edges_mm 以保证 exact 路径重建精度 # 已注册的形状(circle/obround/d_shape 等)直接用 profile # 015133: 所有形状都用 profile,坐标仅存 compiler_context cdsl_sk["profile"] = profile cdsl_sketches.append(cdsl_sk) id_to_cdsl_sketch[sk_id] = cdsl_sk # ── Phase 2: profile_from for ALL duplicates (015133 standard) ── hash_to_first: dict[str, str] = {} dedup_count = 0 for cdsl_sk in cdsl_sketches: ch = cdsl_sk.pop("_content_hash", None) if not ch: continue sk_id = cdsl_sk["id"] if ch in hash_to_first: src_id = hash_to_first[ch] src_sk = id_to_cdsl_sketch.get(src_id) if not src_sk: continue src_has_profile = bool(src_sk.get("profile")) this_has_profile = bool(cdsl_sk.get("profile")) src_origin = src_sk.get("workplane", {}).get("origin_mm", [0, 0, 0]) this_origin = cdsl_sk.get("workplane", {}).get("origin_mm", [0, 0, 0]) dx = round(this_origin[0] - src_origin[0], 6) dy = round(this_origin[1] - src_origin[1], 6) if src_has_profile and this_has_profile: # case 1: both have registered profiles -> standard profile_from cdsl_sk["profile_from"] = src_id cdsl_sk.pop("profile", None) cdsl_sk.pop("entities", None) cdsl_sk.pop("contour_edges_mm", None) cdsl_sk.pop("workplane", None) # 仅编译器上下文需要,LLM不学 cdsl_sk.pop("_fallback_reason", None) if abs(dx) > 1e-6 or abs(dy) > 1e-6: cdsl_sk["profile_shift"] = [dx, dy] dedup_count += 1 elif not src_has_profile and not this_has_profile: # case 2: complex shapes - create polygon profile on source src_ents = src_sk.get("entities", []) src_contours = src_sk.get("contour_edges_mm", []) if src_ents or src_contours: if not src_sk.get("profile"): vertices = [] for ce in src_contours: s = ce.get("start_mm", [0, 0, 0]) if len(s) >= 2: vertices.append([float(s[0]), float(s[1])]) if not vertices: for e in src_ents: s = e.get("start", [0, 0]) if len(s) >= 2: vertices.append([float(s[0]), float(s[1])]) if vertices: src_sk["profile"] = { "type": "polygon", "vertices": vertices, "_from_entities": True, } cdsl_sk["profile_from"] = src_id cdsl_sk.pop("profile", None) cdsl_sk.pop("entities", None) cdsl_sk.pop("contour_edges_mm", None) cdsl_sk.pop("workplane", None) # 仅编译器上下文需要,LLM不学 cdsl_sk.pop("_fallback_reason", None) if abs(dx) > 1e-6 or abs(dy) > 1e-6: cdsl_sk["profile_shift"] = [dx, dy] dedup_count += 1 elif src_has_profile: # case 3: source has profile, duplicate has only entities cdsl_sk["profile_from"] = src_id cdsl_sk.pop("profile", None) cdsl_sk.pop("entities", None) cdsl_sk.pop("contour_edges_mm", None) cdsl_sk.pop("_fallback_reason", None) if abs(dx) > 1e-6 or abs(dy) > 1e-6: cdsl_sk["profile_shift"] = [dx, dy] dedup_count += 1 else: hash_to_first[ch] = sk_id # ── Phase 3: 构建 CDSL features ── ops = [op for op in ir.get("operations", []) if op.get("type") not in ("assembly_compose",)] cdsl_features: list[dict[str, Any]] = [] feature_ids: list[str] = [] name_to_id: dict[str, str] = {} for i, op in enumerate(ops): fid = f"f{i + 1:02d}" op_type = op.get("type", "") sketch_id = op.get("sketch") params = op.get("parameters", {}) # 确定 atomic_id atomic_id = _map_atomic_id(op_type, params) # 构建 CDSL 参数 cdsl_params = _build_cdsl_params(op, params, op_type, atomic_id, cdsl_features) feat: dict[str, Any] = { "id": fid, "atomic_id": atomic_id, "depends_on": list(feature_ids), "name": op.get("name", fid), "params": cdsl_params, } if sketch_id: feat["sketch_id"] = sketch_id cdsl_features.append(feat) feature_ids.append(fid) name_to_id[op.get("name", "")] = fid # 修复 pattern source_features 引用 for feat in cdsl_features: if feat["atomic_id"] == "pattern_linear": raw = op_to_raw(op, feat, ops) if raw: src_names = raw.get("source_feature_names", []) src_ids = [name_to_id.get(n, n) for n in src_names] feat["params"]["source_feature_ids"] = src_ids # ── 清理空草图 (015133: 空参考草图移入compiler_context,不污染Learning IR) ── empty_ids = set() for cdsl_sk in cdsl_sketches: if not cdsl_sk.get("profile") and not cdsl_sk.get("profile_from"): has_geo = bool(cdsl_sk.get("entities") or cdsl_sk.get("contour_edges_mm")) if not has_geo: empty_ids.add(cdsl_sk["id"]) # 仅移除未被feature引用的空草图 (防止破坏特征引用) if empty_ids: ref_ids = set() for feat in cdsl_features: sid = feat.get("sketch_id") if sid and sid in empty_ids: ref_ids.add(sid) safe_remove = empty_ids - ref_ids cdsl_sketches = [s for s in cdsl_sketches if s["id"] not in safe_remove] # 构建 CDSL (不含 compiler_context, LLM 训练可直接加载) cdsl: dict[str, Any] = { "schema": "cad.cdsl.llm.v1", "schema_version": "1.0.0", "kind": "part", "part_id": part_id, "features": cdsl_features, "geometry": {"sketches": cdsl_sketches}, "meta": { "unit": "mm", "source": "sw_history_distill_llm_v1", "optimization": "015133-compliant", "parameterized_sketches": sum(1 for s in cdsl_sketches if s.get("profile")), "profile_from_sketches": sum(1 for s in cdsl_sketches if s.get("profile_from")), "from_contour_vertex_count": sum( 1 for f in cdsl_features if "revolve" in f["atomic_id"] and f["params"].get("axis", {}).get("from_contour_vertex") is not None ), "raw_sketch_count": sum(1 for s in cdsl_sketches if not s.get("profile") and not s.get("profile_from")), }, } cdsl = _clean_cdsl_floats(cdsl) return cdsl def op_to_raw(feat: dict, op: dict, all_ops: list) -> dict | None: """从 pattern 操作的 raw_parameters 提取数据""" raw = op.get("raw_parameters", {}) if not raw: # 从更早的 feature 中查找 for o in all_ops: if o.get("type") == "linear_pattern": raw = o.get("raw_parameters", {}) if raw: break if not raw: return None srcs = raw.get("source_features") or [] return { "source_feature_names": [s.get("name", "") for s in srcs if isinstance(s, dict)], } def _map_atomic_id(op_type: str, params: dict) -> str: if op_type in ("extrude_add", "extrude_cut"): rev_dist = params.get("reverse_distance_mm") or 0 both = params.get("both_directions", False) if op_type == "extrude_add" and (both or rev_dist > 1e-6): return "extrude_add_two_sided" return "extrude_cut_blind" if op_type == "extrude_cut" else "extrude_add_blind" if op_type in ("revolve_add", "revolve_cut"): return op_type if op_type in ("linear_pattern", "pattern_linear"): return "pattern_linear" if op_type == "pattern_mirror": return "pattern_mirror" if op_type == "shell": return "shell" if op_type == "draft": return "draft" if op_type == "loft": return "loft" if op_type == "dome": return "dome" if op_type == "thicken": return "thicken" if op_type == "noop": return "noop" if op_type == "hole": if (params.get("countersink_diameter_mm") or 0) > 0: return "hole_countersink" if (params.get("counterbore_diameter_mm") or 0) > 0: return "hole_counterbore" return "hole_blind" return op_type def _build_cdsl_params(op: dict, params: dict, op_type: str, atomic_id: str, prev_features: list = None) -> dict: result = {} if "extrude" in op_type: dist = params.get("distance_mm") or 0 result["distance_mm"] = float(dist) rev_dist = params.get("reverse_distance_mm") or 0 rev = params.get("reverse", False) both = params.get("both_directions", False) if both and rev_dist > 1e-6: result["reverse_distance_mm"] = float(rev_dist) elif rev: result["reverse"] = True elif "revolve" in op_type: result["angle_deg"] = float(params.get("angle_deg") or 360) ax = params.get("axis") or params.get("axis_reference") or {} direction = ax.get("direction") or [0, 0, 1] # Phase 3: from_contour_vertex 替代 origin_mm result["axis"] = {"direction": list(direction)} origin = ax.get("origin_mm") if origin: # 用 from_contour_vertex: 0 替代浮点坐标 result["axis"]["from_contour_vertex"] = 0 elif ax.get("from_contour_vertex") is not None: result["axis"]["from_contour_vertex"] = int(ax["from_contour_vertex"]) elif ax.get("from_workplane_origin"): result["axis"]["from_workplane_origin"] = True else: result["axis"]["from_contour_vertex"] = 0 elif atomic_id == "pattern_linear": raw = op.get("raw_parameters", {}) params2 = params d1_raw = raw.get("direction1") or [1, 0, 0] d2_raw = raw.get("direction2") or [0, 1, 0] d1 = d1_raw.get("vector") if isinstance(d1_raw, dict) else d1_raw d2 = d2_raw.get("vector") if isinstance(d2_raw, dict) else d2_raw result["pattern_count_1"] = int(raw.get("d1_total_instances") or params2.get("total_instances") or 2) result["spacing_1_mm"] = float(raw.get("d1_spacing_mm") or params2.get("spacing_mm") or 0) result["direction_1"] = d1 result["pattern_count_2"] = int(raw.get("d2_total_instances") or 1) result["spacing_2_mm"] = float(raw.get("d2_spacing_mm") or 0) result["direction_2"] = d2 elif atomic_id == "pattern_mirror": raw = op.get("raw_parameters", {}) src_features = params.get("source_features") or [] result["source_feature_ids"] = [f.get("name") if isinstance(f, dict) else str(f) for f in src_features] # 镜像面数据从 C# 插件 raw_parameters 直接获取 mirror_origin = raw.get("mirror_plane_origin") mirror_normal = raw.get("mirror_plane_normal") if mirror_origin and isinstance(mirror_origin, (list, tuple)) and len(mirror_origin) >= 3: result["mirror_plane_origin_mm"] = [float(v) for v in mirror_origin] if mirror_normal and isinstance(mirror_normal, (list, tuple)) and len(mirror_normal) >= 3: result["mirror_plane_normal"] = [float(v) for v in mirror_normal] elif atomic_id == "shell": result["thickness_mm"] = float(params.get("thickness_mm") or 0) result["faces_to_remove"] = params.get("faces_to_remove") or [] elif atomic_id == "draft": result["angle_deg"] = float(params.get("angle_deg") or 0) result["draft_type"] = params.get("draft_type", "") pull_dir = params.get("pull_direction", [0, 0, 1]) result["pull_direction"] = [float(v) for v in pull_dir] if pull_dir else [0, 0, 1] elif atomic_id == "loft": result["profile_sketches"] = params.get("profile_sketches") or [] result["is_closed"] = params.get("is_closed", False) elif atomic_id == "dome": result["height_mm"] = float(params.get("height_mm") or 0) elif atomic_id == "thicken": result["thickness_mm"] = float(params.get("thickness_mm") or 0) elif atomic_id == "noop": result["sw_type"] = params.get("sw_type", "") elif atomic_id.startswith("hole"): result["diameter_mm"] = float(params.get("diameter_mm") or 0) result["depth_mm"] = float(params.get("depth_mm") or 0) result["positions"] = params.get("positions") or [] elif atomic_id == "fillet": result["radius_mm"] = float(params.get("radius_mm") or 0) elif atomic_id == "chamfer": result["distance_mm"] = float(params.get("distance_mm") or 0) return result def _simplified_entities(entities: list[dict]) -> list[dict]: """简化的实体数据(保留足够的几何信息供 sketch_solver 重建)""" result = [] for ent in entities: s = {"type": ent.get("type", "?")} if ent.get("type") == "line": s["start"] = (ent.get("start") or [0, 0])[:2] s["end"] = (ent.get("end") or [0, 0])[:2] elif ent.get("type") == "circle": s["center"] = (ent.get("center") or [0, 0])[:2] s["radius_mm"] = ent.get("radius_mm") elif ent.get("type") == "arc": s["center"] = (ent.get("center") or [0, 0])[:2] s["start"] = (ent.get("start") or [0, 0])[:2] s["end"] = (ent.get("end") or [0, 0])[:2] s["radius_mm"] = ent.get("radius_mm") s["is_circle"] = ent.get("is_circle", False) if ent.get("construction"): s["construction"] = True result.append(s) return result # ═══════════════════════════════════════════════════════════════ # CLI # ═══════════════════════════════════════════════════════════════ def write_cdsl_outputs( cdsl: dict[str, Any], out_dir: Path, *, sw_data: dict[str, Any] | None = None, write_compiler_context: bool = False, ) -> dict[str, Path]: """写入 CDSL;可选写入 compiler_context(仅调试,非 cdsl_only 验收依赖)。""" out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) part_id = str(cdsl.get("part_id") or "part") cdsl_clean = _clean_cdsl_floats(cdsl) cdsl_path = out_dir / f"{part_id}.cdsl.json" cdsl_path.write_text(json.dumps(cdsl_clean, ensure_ascii=False, indent=2, default=str), encoding="utf-8") paths: dict[str, Path] = {"cdsl": cdsl_path} if write_compiler_context and sw_data is not None: ir = normalize_to_ir(sw_data) ctx_data = { "version": ir.get("version", "ir-0.1"), "metadata": ir.get("metadata", {}), "sketches": ir.get("sketches", []), "operations": ir.get("operations", []), "references": ir.get("references", []), "validation_hints": ir.get("validation_hints", {}), "rebuild_contract": ir.get("rebuild_contract", {}), } ctx_path = out_dir / f"{part_id}.compiler_context.json" ctx_path.write_text(json.dumps(ctx_data, ensure_ascii=False, indent=2, default=str), encoding="utf-8") paths["compiler_context"] = ctx_path return paths def main(): import argparse from collections import Counter ap = argparse.ArgumentParser(description="SW JSON → Parameterized CDSL") ap.add_argument("sw_json", type=Path) ap.add_argument("--out", "-o", type=Path, required=True, help="输出目录") ap.add_argument("--part-id", type=str, default=None) ap.add_argument("--write-context", action="store_true", help="额外写出 compiler_context(调试用)") args = ap.parse_args() sw_data = json.loads(args.sw_json.read_text(encoding="utf-8")) cdsl = convert_sw_json_to_cdsl(args.sw_json, args.part_id) paths = write_cdsl_outputs( cdsl, args.out, sw_data=sw_data, write_compiler_context=args.write_context ) meta = cdsl["meta"] print(f"Parameterized CDSL → {paths['cdsl']}") print(f" features: {len(cdsl['features'])}, sketches: {len(cdsl['geometry']['sketches'])}") print(f" parameterized: {meta['parameterized_sketches']}, profile_from: {meta['profile_from_sketches']}") print(f" from_contour_vertex: {meta['from_contour_vertex_count']}") ac = Counter(f["atomic_id"] for f in cdsl["features"]) for aid, cnt in sorted(ac.items()): print(f" {aid}: {cnt}") if "compiler_context" in paths: print(f" compiler_context → {paths['compiler_context']} (debug only)") if __name__ == "__main__": main()