Files
cdsl-cad/backend/engine/cdsl_engine/convert_to_cdsl.py
T
2026-08-19 19:34:30 +08:00

1124 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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_contextCDSL 只保留类型签名。
"""
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 + centermin_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()