docs: refresh project architecture and repository rules

This commit is contained in:
Jerry
2026-07-27 17:31:21 +08:00
parent 550b339bcc
commit 8895178844
5 changed files with 211 additions and 452 deletions
+4
View File
@@ -1,2 +1,6 @@
# Normalize source and configuration files across operating systems.
* text=auto eol=lf
# STEP is an exchange artifact, not reviewable line-oriented source.
*.step binary
*.stp binary
+14 -15
View File
@@ -1,5 +1,9 @@
# Operating-system files
# Operating-system and editor metadata
.DS_Store
**/.idea/
**/.vscode/
*.swp
*.tmp
# Python environments and caches
**/.venv/
@@ -9,28 +13,23 @@
**/.mypy_cache/
*.py[cod]
# JavaScript dependencies and generated caches
# JavaScript dependencies and generated builds
**/node_modules/
**/.next/
**/.cache/
models/
**/dist/
# Undownloaded upstream Git LFS demo/catalog pointers
# Local frontend runtime data
cad-agent-studio/config/llm.config.yaml
cad-agent-studio/data/
# Generated and upstream-heavy CAD assets
/models/
text-to-cad/assets/
text-to-cad/benchmarks/*.gif
text-to-cad/models/
# Local editors and temporary files
**/.idea/
**/.vscode/
*.swp
*.tmp
# Local frontend configuration and task artifacts
cad-agent-studio/config/llm.config.yaml
cad-agent-studio/data/
# Protected DesignIR runtime data
# DesignIR teacher data and runtime artifacts
designir-pipeline/workspace/teacher/inbox/*
!designir-pipeline/workspace/teacher/inbox/.gitkeep
designir-pipeline/workspace/teacher/evidence/*
+190 -17
View File
@@ -1,24 +1,197 @@
# CadSet
CAD Router provides three STEP-centered workflows:
CadSet 是一个面向机械零件的 STEP-first 参数化 CAD 系统。它覆盖自然语言与图文生成、上传 STEP 的独立参数化重建、模型修改、几何验收,以及从批量 STEP 案例中蒸馏可复用建模经验。
- Generate editable CAD from natural-language requests.
- Generate or modify editable CAD from text plus reference images.
- Reconstruct uploaded STEP files into editable DesignIR 2.0 and modify them.
系统的统一设计源是自有的 **DesignIR 2.0**。STEP 是主要交换与验收格式,但上传的源 STEP 只作为教师证据和验收真值,不作为重建时的几何依赖。
For every request, CAD Router selects either text-to-cad or SimpleCADAPI as
the execution backend. The persistent contract is DesignIR 2.0 plus STEP.
## 产品范围
## Project layout
当前主线只包含三类工作流:
- `cad-agent-studio/`: standalone web frontend.
- `designir-pipeline/`: role-separated reconstruction, acceptance, and
experience-promotion system.
- `text-to-cad/`: CAD Router, Build123d generation skill, Viewer, and shared
CAD runtime.
- `SimpleCADAPI/`: the second CAD execution backend.
- `llm.config.yaml`: private, Git-ignored provider configuration shared by the
standalone frontend.
1. 根据自然语言生成可编辑 CAD。
2. 根据文字和参考图片生成或修改可编辑 CAD。
3. 将上传的 STEP 重建为 DesignIR,独立生成新的 STEP,并继续参数化修改。
The uploaded teacher STEP is evidence only. A reconstruction must compile from
DesignIR after the teacher file has been removed from the build environment.
CAD Router 会在两个执行后端之间选择:
- **text-to-cad / Build123d**:通用机械零件、特征建模、几何验证和 STEP 输出。
- **SimpleCADAPI**:可重放操作图、语义化机械结构和专用机械零件能力。
## 系统架构
```text
┌────────────────────┐
Text / Image / STEP ──> │ CAD Agent Studio │
└─────────┬──────────┘
┌─────────▼──────────┐
│ CAD Router │
└──────┬───────┬─────┘
│ │
┌───────────▼─┐ ┌─▼──────────────┐
│ Build123d │ │ SimpleCADAPI │
└───────────┬─┘ └─┬──────────────┘
└────┬────┘
DesignIR 2.0 + STEP
┌───────────▼───────────┐
│ Viewer / Acceptance │
└───────────────────────┘
```
上传 STEP 的重建链路使用严格的教师隔离:
```text
Teacher STEP
-> private geometry evidence
-> Reconstruction Agent
-> DesignIR 2.0
-> isolated compiler without Teacher STEP access
-> rebuilt STEP
-> independent Acceptance Agent
-> deterministic experience promotion
```
## 仓库结构
| 路径 | 职责 |
| --- | --- |
| [`cad-agent-studio/`](cad-agent-studio/README.md) | 独立 Next.js 前端,负责对话、图片与 STEP 上传、任务管理和模型预览。 |
| [`designir-pipeline/`](designir-pipeline/README.md) | DesignIR Schema、双 Agent、隔离编译、验收和经验晋升。 |
| [`text-to-cad/`](text-to-cad/README.md) | CAD Router、Build123d CAD Skill、CAD Viewer 和共享 CAD 运行时。 |
| [`SimpleCADAPI/`](SimpleCADAPI/README.md) | 第二建模后端,提供可重放操作图和机械建模 API。 |
| [`llm.config.yaml`](llm.config.yaml) | 前端共享的模型供应商、模型和密钥配置。 |
依赖目录、虚拟环境、构建缓存和批量运行数据不会进入 Git。锁文件、源码、Schema、Agent 定义和确定性测试会进入版本控制。
## DesignIR 2.0
DesignIR 保存设计意图,而不是复制 STEP 的 B-Rep 或三角网格。主要结构包括:
- coordinate systems 与 datums
- editable parameters 与 expressions
- sketches 与 constraints
- ordered features、patterns 与 attachments
- construction stages
- edit interface
- validation and perturbation contracts
Schema 位于:
[`designir-pipeline/contracts/designir-2.0.schema.json`](designir-pipeline/contracts/designir-2.0.schema.json)
当前两个后端共同支持的首批操作包括:
- `extrude_circle`
- `extrude_rectangle`
- `add_cylinder`
- `through_hole`
- `polar_hole_pattern`
不支持的几何必须显式进入 quarantine,并记录缺失能力;系统禁止使用源 STEP、嵌入 B-Rep、完整网格替身或源拓扑引用绕过重建。
## STEP 蒸馏与验收
将待处理的 STEP/STP 文件放入:
```text
designir-pipeline/workspace/teacher/inbox/
```
完整运行目录:
| 阶段 | 路径 |
| --- | --- |
| 教师 STEP 输入 | `workspace/teacher/inbox/` |
| 私有几何证据 | `workspace/teacher/evidence/` |
| Reconstruction Agent 输入 | `workspace/reconstruction/inbox/` |
| 参数化重建结果 | `workspace/reconstruction/designir/` |
| 独立重建 STEP | `workspace/reconstruction/output/` |
| Acceptance Agent 输入 | `workspace/acceptance/inbox/` |
| 验收报告 | `workspace/acceptance/reports/` |
| 经验候选 | `workspace/promotion/candidates/` |
| 跨案例回放验证 | `workspace/promotion/replay_validated/` |
| 正式经验库 | `workspace/promotion/promoted/library.json` |
| 失败与能力缺口 | `workspace/quarantine/` |
详细说明见 [`designir-pipeline/workspace/README.md`](designir-pipeline/workspace/README.md)。
批量提取教师证据:
```bash
designir-pipeline/scripts/cad-experience extract-folder \
--input designir-pipeline/workspace/teacher/inbox \
--output designir-pipeline/workspace/teacher/evidence
```
蒸馏得到的经验必须同时通过几何一致性、特征语义、参数可编辑性和修改稳定性验收。两个 Agent 都无权直接发布经验,最终晋升由确定性策略执行。
## 本地开发
### 环境要求
- Node.js 22+
- Python 3.12
- OpenCascade/Build123d 所需的本地运行依赖
### 启动前端
```bash
cd cad-agent-studio
npm install
npm run dev -- -H 127.0.0.1 -p 53821
```
打开 `http://127.0.0.1:53821`
前端默认读取仓库根目录的 `llm.config.yaml`。也可以通过 `CAD_AGENT_STUDIO_CONFIG` 指向其他配置文件。
### DesignIR 命令
```bash
text-to-cad/.venv/bin/python \
designir-pipeline/scripts/designir_pipeline.py validate \
designir-pipeline/examples/raised_hub_flange.designir.json
text-to-cad/.venv/bin/python \
designir-pipeline/scripts/designir_pipeline.py isolated-rebuild \
designir-pipeline/examples/raised_hub_flange.designir.json \
--output /tmp/raised_hub_flange.step \
--backend build123d
```
`simplecadapi` 后端还会在 STEP 旁生成可重放的 `*.simplecad.model.json`
## 验证
前端:
```bash
cd cad-agent-studio
npm test
npm run build
```
DesignIR、经验库与 CAD Router
```bash
text-to-cad/.venv/bin/python -m unittest \
designir-pipeline/tests/test_designir_pipeline.py \
designir-pipeline/tests/test_experience_library.py \
text-to-cad/tests/python/skills/cad-router/test_route.py
```
经验库审计:
```bash
text-to-cad/.venv/bin/python \
designir-pipeline/skills/cad-experience-builder/scripts/cad_experience.py \
audit designir-pipeline/workspace/promotion/promoted/library.json
```
## 当前边界
DesignIR 当前优先覆盖规则机械零件,例如法兰、板件、支架、轴套、带孔块和基础壳体。自由曲面、多轨扫掠、复杂铸造过渡和高阶连续曲面仍需要扩展特征词汇。
工作区已经具备批量证据提取、角色隔离、独立重建、验收和经验晋升能力;批处理目前由命令或 Agent 任务触发,尚未提供常驻目录监听服务。
+3 -2
View File
@@ -8,8 +8,9 @@ CAD Agent Studio is a two-pane web app for agent-driven CAD work:
## Setup
The app first reads the private shared configuration at `../llm.config.yaml`.
That file is Git-ignored and may use a direct `apiKey` or an environment name
in `apiKeyEnv`. It falls back to the app-local example:
The repository currently versions that configuration for the private deployment.
It may use a direct `apiKey` or an environment name in `apiKeyEnv`, and falls
back to the app-local example:
```bash
cp config/llm.config.yaml.example ../llm.config.yaml
-418
View File
@@ -1,418 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from skimage import color, feature, measure, morphology, transform
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
@dataclass
class ImageFeatures:
file: str
stem: str
width: int
height: int
foreground_fraction: float
component_count: int
largest_component_fraction: float
hole_count: int
weighted_compactness: float
edge_density: float
color_bins: int
circular_signal: float
rectilinear_signal: float
thin_profile_signal: float
symmetry_signal: float
gear_signal: float
standard_signal: float
complexity_penalty: float
restorability_score: float
recommended_backend: str
confidence: str
reasons: str
def clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, value))
def percentile_score(value: float, p10: float, p90: float, invert: bool = False) -> float:
if p90 <= p10:
score = 0.5
else:
score = clamp((value - p10) / (p90 - p10))
return 1.0 - score if invert else score
def list_images(image_dir: Path) -> list[Path]:
return sorted(
p for p in image_dir.iterdir()
if p.is_file() and p.suffix.lower() in IMAGE_EXTS
)
def load_small_image(path: Path, max_width: int) -> tuple[np.ndarray, tuple[int, int]]:
image = Image.open(path).convert("RGB")
original_size = image.size
if image.width > max_width:
height = round(image.height * max_width / image.width)
image = image.resize((max_width, height), Image.Resampling.LANCZOS)
return np.asarray(image, dtype=np.uint8), original_size
def foreground_mask(rgb: np.ndarray) -> np.ndarray:
arr = rgb.astype(np.float32) / 255.0
hsv = color.rgb2hsv(arr)
value = hsv[..., 2]
saturation = hsv[..., 1]
# SOLIDWORKS screenshots have pale gradient backgrounds and many light-gray
# parts, so include medium-light gray entity pixels as well as colored parts.
mask = (value < 0.88) | ((saturation > 0.09) & (value < 0.99))
mask = morphology.closing(mask, morphology.disk(2))
mask = morphology.remove_small_objects(mask, max_size=max(32, mask.size // 9000))
mask = morphology.remove_small_holes(mask, max_size=max(32, mask.size // 14000))
return mask
def crop_component(mask: np.ndarray, label_image: np.ndarray, label_id: int) -> np.ndarray:
ys, xs = np.where(label_image == label_id)
if len(xs) == 0:
return np.zeros((1, 1), dtype=bool)
pad = 4
y0 = max(int(ys.min()) - pad, 0)
y1 = min(int(ys.max()) + pad + 1, mask.shape[0])
x0 = max(int(xs.min()) - pad, 0)
x1 = min(int(xs.max()) + pad + 1, mask.shape[1])
return label_image[y0:y1, x0:x1] == label_id
def symmetry_for(component: np.ndarray) -> float:
if component.size <= 1:
return 0.0
target = transform.resize(
component.astype(float),
(64, 64),
order=0,
preserve_range=True,
anti_aliasing=False,
) > 0.5
area = target.sum()
if area == 0:
return 0.0
horiz = np.logical_and(target, np.fliplr(target)).sum() / np.logical_or(target, np.fliplr(target)).sum()
vert = np.logical_and(target, np.flipud(target)).sum() / np.logical_or(target, np.flipud(target)).sum()
return float(max(horiz, vert))
def raw_features(path: Path, max_width: int) -> dict[str, float | int | str]:
rgb, (width, height) = load_small_image(path, max_width=max_width)
gray = color.rgb2gray(rgb)
mask = foreground_mask(rgb)
label_image = measure.label(mask)
props = [
prop for prop in measure.regionprops(label_image)
if prop.area >= max(48, mask.size // 4500)
]
props.sort(key=lambda item: item.area, reverse=True)
foreground_fraction = float(mask.mean())
component_count = len(props)
largest = props[0] if props else None
largest_component_fraction = float((largest.area / mask.size) if largest else 0.0)
total_area = sum(prop.area for prop in props) or 1
compactness_values = []
holes = 0
circular_signal = 0.0
rectilinear_signal = 0.0
thin_profile_signal = 0.0
gear_signal = 0.0
symmetry_signal = 0.0
for index, prop in enumerate(props[:16]):
area = max(float(prop.area), 1.0)
perimeter = max(float(prop.perimeter), 1.0)
compactness = perimeter * perimeter / (4.0 * math.pi * area)
compactness_values.append(compactness * (area / total_area))
component_holes = max(0, 1 - int(prop.euler_number))
holes += component_holes
minr, minc, maxr, maxc = prop.bbox
box_h = maxr - minr
box_w = maxc - minc
box_area = max(float(box_h * box_w), 1.0)
aspect = max(box_w / max(box_h, 1), box_h / max(box_w, 1))
extent = prop.area / box_area
solidity = float(getattr(prop, "solidity", 0.0))
eccentricity = float(getattr(prop, "eccentricity", 1.0))
circular = clamp((1.0 - eccentricity) * 1.7) * clamp(solidity * 1.25) * clamp(2.2 - compactness / 2.1)
rect = clamp((extent - 0.35) / 0.45) * clamp(solidity * 1.15)
thin = clamp((aspect - 3.0) / 8.0) * clamp(solidity * 1.2)
weight = area / total_area
circular_signal = max(circular_signal, circular)
rectilinear_signal = max(rectilinear_signal, rect)
thin_profile_signal = max(thin_profile_signal, thin)
if index < 4:
comp = crop_component(mask, label_image, prop.label)
symmetry_signal = max(symmetry_signal, symmetry_for(comp) * clamp(weight * 4.0))
teeth_like = clamp((compactness - 2.2) / 7.5)
gear_signal = max(
gear_signal,
clamp((1.0 - eccentricity) * 2.0) * teeth_like * clamp((component_holes + 1) / 3.0),
)
weighted_compactness = float(sum(compactness_values))
edges = feature.canny(gray, sigma=1.1)
edge_density = float(edges.mean())
object_pixels = rgb[mask]
if len(object_pixels) == 0:
color_bins = 0
else:
quantized = (object_pixels // 32).astype(np.uint8)
color_bins = int(len(np.unique(quantized.reshape(-1, 3), axis=0)))
return {
"file": str(path),
"stem": path.stem,
"width": width,
"height": height,
"foreground_fraction": foreground_fraction,
"component_count": component_count,
"largest_component_fraction": largest_component_fraction,
"hole_count": holes,
"weighted_compactness": weighted_compactness,
"edge_density": edge_density,
"color_bins": color_bins,
"circular_signal": float(circular_signal),
"rectilinear_signal": float(rectilinear_signal),
"thin_profile_signal": float(thin_profile_signal),
"symmetry_signal": float(symmetry_signal),
"gear_signal": float(gear_signal),
}
def score_rows(rows: list[dict[str, float | int | str]]) -> list[ImageFeatures]:
compactness = np.asarray([float(row["weighted_compactness"]) for row in rows])
edge_density = np.asarray([float(row["edge_density"]) for row in rows])
components = np.asarray([float(row["component_count"]) for row in rows])
color_bins = np.asarray([float(row["color_bins"]) for row in rows])
holes = np.asarray([float(row["hole_count"]) for row in rows])
p = {
"compactness": np.percentile(compactness, [10, 90]),
"edge": np.percentile(edge_density, [10, 90]),
"components": np.percentile(components, [10, 90]),
"colors": np.percentile(color_bins, [10, 90]),
"holes": np.percentile(holes, [50, 95]),
}
scored: list[ImageFeatures] = []
for row in rows:
comp_score = percentile_score(float(row["weighted_compactness"]), *p["compactness"], invert=True)
edge_score = percentile_score(float(row["edge_density"]), *p["edge"], invert=True)
component_score = percentile_score(float(row["component_count"]), *p["components"], invert=True)
color_score = percentile_score(float(row["color_bins"]), *p["colors"], invert=True)
many_hole_penalty = percentile_score(float(row["hole_count"]), *p["holes"])
circular = float(row["circular_signal"])
rect = float(row["rectilinear_signal"])
thin = float(row["thin_profile_signal"])
symmetry = float(row["symmetry_signal"])
gear = float(row["gear_signal"])
holes_good = 1.0 if 1 <= int(row["hole_count"]) <= 12 else 0.35 if int(row["hole_count"]) == 0 else 0.0
standard = max(circular, rect, thin, gear)
complexity_penalty = (
0.34 * (1.0 - comp_score)
+ 0.28 * (1.0 - edge_score)
+ 0.25 * (1.0 - component_score)
+ 0.13 * (1.0 - color_score)
)
special_family = (
gear >= 0.80
and circular >= 0.40
and float(row["edge_density"]) >= 0.048
)
score = (
25.0
+ 16.0 * comp_score
+ 12.0 * edge_score
+ 8.0 * component_score
+ 4.0 * color_score
+ 12.0 * standard
+ 5.0 * symmetry
+ 4.0 * holes_good
+ (7.0 * gear if special_family else 0.0)
- 10.0 * many_hole_penalty
)
score = max(0.0, min(100.0, score))
if special_family:
backend = "simplecadapi"
elif score >= 72.0:
backend = "build123d"
elif score >= 58.0:
backend = "build123d_or_manual_review"
else:
backend = "manual_review_before_modeling"
if score >= 82:
confidence = "high"
elif score >= 68:
confidence = "medium"
else:
confidence = "low"
reasons = []
if special_family:
reasons.append("gear/ring/rack-like standard mechanical family")
if circular >= 0.45:
reasons.append("circular or coaxial feature signal")
if rect >= 0.55:
reasons.append("simple plate/block extrusion signal")
if thin >= 0.45:
reasons.append("shaft/rail/thin extrusion signal")
if symmetry >= 0.55:
reasons.append("strong silhouette symmetry")
if holes_good >= 1.0:
reasons.append("moderate visible hole count")
if complexity_penalty >= 0.55:
reasons.append("penalized for dense edges/components/colors")
if not reasons:
reasons.append("generic low-complexity B-Rep signal")
scored.append(
ImageFeatures(
restorability_score=round(score, 3),
recommended_backend=backend,
confidence=confidence,
standard_signal=round(standard, 4),
complexity_penalty=round(complexity_penalty, 4),
reasons="; ".join(reasons),
**{
key: row[key] for key in (
"file",
"stem",
"width",
"height",
"foreground_fraction",
"component_count",
"largest_component_fraction",
"hole_count",
"weighted_compactness",
"edge_density",
"color_bins",
"circular_signal",
"rectilinear_signal",
"thin_profile_signal",
"symmetry_signal",
"gear_signal",
)
},
)
)
return sorted(scored, key=lambda item: item.restorability_score, reverse=True)
def write_csv(rows: list[ImageFeatures], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fields = list(asdict(rows[0]).keys()) if rows else []
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
for row in rows:
writer.writerow(asdict(row))
def write_json(rows: list[ImageFeatures], path: Path) -> None:
path.write_text(
json.dumps([asdict(row) for row in rows], ensure_ascii=False, indent=2),
encoding="utf-8",
)
def make_contact_sheet(rows: list[ImageFeatures], output: Path, limit: int, title: str) -> None:
selected = rows[:limit]
if not selected:
return
thumb_w, thumb_h = 220, 316
label_h = 42
cols = 8
rows_count = math.ceil(len(selected) / cols)
sheet = Image.new("RGB", (cols * thumb_w, rows_count * (thumb_h + label_h) + 28), "white")
draw = ImageDraw.Draw(sheet)
draw.text((8, 8), title, fill=(0, 0, 0))
y_offset = 28
for index, item in enumerate(selected):
path = Path(item.file)
image = Image.open(path).convert("RGB")
image.thumbnail((thumb_w, thumb_h), Image.Resampling.LANCZOS)
col = index % cols
row = index // cols
x = col * thumb_w + (thumb_w - image.width) // 2
y = y_offset + row * (thumb_h + label_h)
sheet.paste(image, (x, y))
label = f"{index + 1:02d} {item.stem} {item.restorability_score:.1f}"
draw.text((col * thumb_w + 4, y + thumb_h + 3), label, fill=(0, 0, 0))
draw.text((col * thumb_w + 4, y + thumb_h + 20), item.recommended_backend[:28], fill=(70, 70, 70))
output.parent.mkdir(parents=True, exist_ok=True)
sheet.save(output, quality=90)
def main() -> int:
parser = argparse.ArgumentParser(description="Rank SOLIDWORKS screenshot restorability for cad-router backends.")
parser.add_argument("image_dir", type=Path)
parser.add_argument("--out-dir", type=Path, default=Path("models/sldprt_screen_rank"))
parser.add_argument("--max-width", type=int, default=420)
parser.add_argument("--contact-limit", type=int, default=80)
parser.add_argument("--skip-contact", action="store_true", help="Only write CSV/JSON rankings.")
args = parser.parse_args()
paths = list_images(args.image_dir)
if not paths:
raise SystemExit(f"No images found in {args.image_dir}")
raw = []
for index, path in enumerate(paths, start=1):
raw.append(raw_features(path, max_width=args.max_width))
if index % 100 == 0:
print(f"processed={index}/{len(paths)}", flush=True)
ranked = score_rows(raw)
args.out_dir.mkdir(parents=True, exist_ok=True)
write_csv(ranked, args.out_dir / "ranking.csv")
write_json(ranked, args.out_dir / "ranking.json")
if not args.skip_contact:
make_contact_sheet(ranked, args.out_dir / "top_80_contact_sheet.jpg", args.contact_limit, "Top restorability candidates")
make_contact_sheet(list(reversed(ranked)), args.out_dir / "bottom_40_contact_sheet.jpg", 40, "Lowest restorability candidates")
print(f"images={len(ranked)}")
print(f"csv={args.out_dir / 'ranking.csv'}")
print(f"json={args.out_dir / 'ranking.json'}")
print(f"top_sheet={args.out_dir / 'top_80_contact_sheet.jpg'}")
print("top10=")
for item in ranked[:10]:
print(f"{item.stem},{item.restorability_score:.1f},{item.recommended_backend},{item.reasons}")
return 0
if __name__ == "__main__":
raise SystemExit(main())