From b5738e9109dd7e868e74287ba5759f92731710bf Mon Sep 17 00:00:00 2001 From: Jerry <99706807@qq.com> Date: Tue, 28 Jul 2026 14:41:26 +0800 Subject: [PATCH] feat: unify CAD workflows on DesignIR 3.0 --- .gitignore | 1 + README.md | 21 +- cad-agent-studio/README.md | 18 +- cad-agent-studio/src/lib/cad-generator.ts | 428 +- cad-agent-studio/src/lib/chat.ts | 194 +- designir-pipeline/README.md | 219 +- designir-pipeline/agents/acceptance-agent.md | 30 +- .../agents/reconstruction-agent.md | 51 +- .../config/promotion-policy.json | 21 +- .../contracts/designir-2.0.schema.json | 46 +- .../contracts/designir-3.0.schema.json | 334 ++ .../examples/raised_hub_flange.designir.json | 2 +- .../output/distillation-report.json | 327 ++ designir-pipeline/output/library.json | 389 +- .../scripts/designir_pipeline.py | 509 +- .../scripts/surfaceir_pipeline.py | 4516 +++++++++++++++++ .../scripts/trajectory_pipeline.mjs | 735 +++ .../references/experience-library-schema.md | 11 + .../references/llm-review-contract.md | 15 +- .../scripts/cad_experience.py | 202 +- .../scripts/step_to_case.py | 210 +- .../tests/test_designir_pipeline.py | 88 + .../tests/test_experience_library.py | 146 +- .../tests/test_surfaceir_pipeline.py | 248 + docs/CADSET_PROJECT_GUIDE.zh-CN.md | 1124 ---- text-to-cad/README.md | 18 +- text-to-cad/skills/cad-router/SKILL.md | 40 +- .../skills/cad-router/agents/openai.yaml | 2 +- .../cad-router/references/backend-contract.md | 17 +- .../cad-router/references/capabilities.md | 2 +- .../cad-router/references/designir-2.0.md | 4 + .../cad-router/references/designir-3.0.md | 129 + .../cad-router/scripts/reconstruct_step.py | 661 +++ .../skills/cad-router/scripts/route.py | 133 +- .../cad-router/test_reconstruct_step.py | 82 + .../python/skills/cad-router/test_route.py | 22 +- 36 files changed, 9690 insertions(+), 1305 deletions(-) create mode 100644 designir-pipeline/contracts/designir-3.0.schema.json create mode 100644 designir-pipeline/output/distillation-report.json create mode 100644 designir-pipeline/scripts/surfaceir_pipeline.py create mode 100644 designir-pipeline/scripts/trajectory_pipeline.mjs create mode 100644 designir-pipeline/tests/test_surfaceir_pipeline.py delete mode 100644 docs/CADSET_PROJECT_GUIDE.zh-CN.md create mode 100644 text-to-cad/skills/cad-router/references/designir-3.0.md create mode 100644 text-to-cad/skills/cad-router/scripts/reconstruct_step.py create mode 100644 text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py diff --git a/.gitignore b/.gitignore index 6728941..458e4bd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ # Local frontend runtime data cad-agent-studio/config/llm.config.yaml cad-agent-studio/data/ +cad-agent-studio/models/ # Generated and upstream-heavy CAD assets /models/ diff --git a/README.md b/README.md index 6197cc9..0ae9bed 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,7 @@ CadSet 是一个面向机械零件的 STEP-first 参数化 CAD 系统。它覆盖自然语言与图文生成、上传 STEP 的独立参数化重建、模型修改、几何验收,以及从批量 STEP 案例中蒸馏可复用建模经验。 -系统的统一设计源是自有的 **DesignIR 2.0**。STEP 是主要交换与验收格式,但上传的源 STEP 只作为教师证据和验收真值,不作为重建时的几何依赖。 - -完整的安装、使用、架构和蒸馏说明见 -[`docs/CADSET_PROJECT_GUIDE.zh-CN.md`](docs/CADSET_PROJECT_GUIDE.zh-CN.md)。 +系统的统一设计源是自有的 **DesignIR 3.0**。文字/图片生成使用语义参数化层,上传 STEP 使用 SurfaceIR 层;STEP 是主要交换与验收格式,但上传的源 STEP 只作为教师证据和验收真值,不作为重建时的几何依赖。 ## 产品范围 @@ -36,7 +33,7 @@ CAD Router 会在两个执行后端之间选择: └───────────┬─┘ └─┬──────────────┘ └────┬────┘ │ - DesignIR 2.0 + STEP + DesignIR 3.0 + STEP │ ┌───────────▼───────────┐ │ Viewer / Acceptance │ @@ -49,7 +46,7 @@ CAD Router 会在两个执行后端之间选择: Teacher STEP -> private geometry evidence -> Reconstruction Agent - -> DesignIR 2.0 + -> DesignIR 3.0 / SurfaceIR -> isolated compiler without Teacher STEP access -> rebuilt STEP -> independent Acceptance Agent @@ -68,9 +65,15 @@ Teacher STEP 依赖目录、虚拟环境、构建缓存和批量运行数据不会进入 Git。锁文件、源码、Schema、Agent 定义和确定性测试会进入版本控制。 -## DesignIR 2.0 +## DesignIR 3.0 -DesignIR 保存设计意图,而不是复制 STEP 的 B-Rep 或三角网格。主要结构包括: +DesignIR 3.0 使用统一外壳和三种明确模式: + +- `fully_semantic_parametric`:文字/图片生成的设计意图与特征程序。 +- `surface_parametric`:上传 STEP 推断出的独立 SurfaceIR 重建程序。 +- `hybrid_semantic_surface_parametric`:语义程序加生成 STEP 的 SurfaceIR 快照,语义层仍是修改权威。 + +语义层保存设计意图,而不是复制教师 STEP 的 B-Rep 或三角网格。主要结构包括: - coordinate systems 与 datums - editable parameters 与 expressions @@ -82,7 +85,7 @@ DesignIR 保存设计意图,而不是复制 STEP 的 B-Rep 或三角网格。 Schema 位于: -[`designir-pipeline/contracts/designir-2.0.schema.json`](designir-pipeline/contracts/designir-2.0.schema.json) +[`designir-pipeline/contracts/designir-3.0.schema.json`](designir-pipeline/contracts/designir-3.0.schema.json) 当前两个后端共同支持的首批操作包括: diff --git a/cad-agent-studio/README.md b/cad-agent-studio/README.md index 7b1e71e..0b67bc9 100644 --- a/cad-agent-studio/README.md +++ b/cad-agent-studio/README.md @@ -34,10 +34,20 @@ Open `http://127.0.0.1:53821`. Uploads and generated tasks are stored under `data/tasks/`. -The model authors DesignIR 2.0, CAD Router selects text-to-cad or SimpleCADAPI, -and the DesignIR pipeline performs a teacher-free isolated STEP rebuild. -Uploaded STEP is teacher and acceptance truth; it is never imported by the -reconstruction compiler. +For new text/image models, the model authors semantic DesignIR 3.0 and CAD +Router selects text-to-cad or SimpleCADAPI. The generated STEP is materialized +as a non-authoritative SurfaceIR snapshot while the semantic layer remains the +edit source. For uploaded STEP, chat calls the deterministic +`reconstruct_uploaded_step` tool, which executes CAD Router's DesignIR 3.0 / +SurfaceIR adapter, independently rebuilds STEP, runs geometry acceptance, and +publishes only perturbation-validated editable parameters. Uploaded STEP is +teacher and acceptance truth; it is never imported by the reconstruction +compiler. + +Follow-up chat edits on a reconstructed task use +`edit_designir3_parameter`. The tool accepts only a parameter and value already +covered by the published validation range, reruns teacher-side semantic edit +acceptance, and publishes a new DesignIR 3.0 plus STEP revision. CAD Agent Studio does not start the standalone CAD Viewer service. Generated models keep DesignIR as the editable contract and STEP as the geometry artifact. diff --git a/cad-agent-studio/src/lib/cad-generator.ts b/cad-agent-studio/src/lib/cad-generator.ts index e7d2401..b9dd056 100644 --- a/cad-agent-studio/src/lib/cad-generator.ts +++ b/cad-agent-studio/src/lib/cad-generator.ts @@ -4,7 +4,13 @@ import path from "node:path"; import { promisify } from "node:util"; import { artifactUrl, createStepGlbPreview, readArtifact } from "@/lib/preview-artifacts"; import { enginePath } from "@/lib/paths"; -import { ensureTask, sanitizeFilename, upsertManifest } from "@/lib/task-store"; +import { + ensureTask, + readManifest, + sanitizeFilename, + taskDir, + upsertManifest, +} from "@/lib/task-store"; const execFileAsync = promisify(execFile); @@ -21,6 +27,13 @@ export type CadGenerationResult = { summary: string; }; +type UploadedStepReconstructionInput = { + prompt: string; + teacherTaskId: string; + teacherPath: string; + teacherName?: string; +}; + function ensureRelativeFilename(filename: string, extension: string, fallback: string) { const safeName = sanitizeFilename(filename || fallback); return safeName.toLowerCase().endsWith(extension) ? safeName : `${safeName}${extension}`; @@ -39,13 +52,33 @@ function routerScript() { return enginePath("text-to-cad", "skills", "cad-router", "scripts", "route.py"); } -async function runJson(command: string, args: string[], cwd: string) { +function surfaceirScript() { + return enginePath("designir-pipeline", "scripts", "surfaceir_pipeline.py"); +} + +async function runJson( + command: string, + args: string[], + cwd: string, + timeout = 120_000, +) { const { stdout } = await execFileAsync(command, args, { cwd, - timeout: 120_000, + timeout, maxBuffer: 1024 * 1024 * 8, }); - return JSON.parse(stdout) as Record; + const lines = stdout.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + if (!lines[index]?.trim().startsWith("{")) { + continue; + } + try { + return JSON.parse(lines.slice(index).join("\n")) as Record; + } catch { + // An OCCT log line may contain a brace; continue to the enclosing object. + } + } + throw new Error("CAD worker did not return a valid JSON result."); } export async function executeDesignIR({ @@ -91,6 +124,20 @@ export async function executeDesignIR({ backend_hint: selectedBackend, }; await fs.writeFile(sourceAbsolutePath, `${JSON.stringify(normalizedDesignIR, null, 2)}\n`); + const migration = await runJson( + pythonExecutable(), + [ + pipelineScript(), + "migrate", + sourceAbsolutePath, + "--output", + sourceAbsolutePath, + ], + task.dir, + ); + if (migration.valid !== true || migration.schema_version !== "3.0") { + throw new Error("DesignIR 3.0 normalization failed."); + } const antiCheat = await runJson( pythonExecutable(), @@ -112,18 +159,37 @@ export async function executeDesignIR({ selectedBackend, ], task.dir, + 900_000, ); if (rebuild.valid !== true) { throw new Error("Isolated DesignIR rebuild failed."); } + const materialized = await runJson( + pythonExecutable(), + [ + pipelineScript(), + "materialize-hybrid", + sourceAbsolutePath, + "--step", + artifactAbsolutePath, + "--output", + sourceAbsolutePath, + ], + task.dir, + 900_000, + ); + if (materialized.valid !== true) { + throw new Error("DesignIR 3.0 SurfaceIR materialization failed."); + } const validation: string[] = [ "CAD Router selected the backend before compilation.", - "DesignIR anti-cheat validation passed.", + "DesignIR 3.0 semantic-layer anti-cheat validation passed.", "STEP rebuilt in an isolated workspace without teacher STEP access.", + "Generated STEP was materialized as a non-authoritative SurfaceIR snapshot.", ]; const artifacts: Array<{ path: string; role: string; kind?: string }> = [ - { path: sourcePath, role: "editable_contract", kind: "designir-2.0" }, + { path: sourcePath, role: "editable_contract", kind: "designir-3.0" }, { path: artifactPath, role: "primary", kind: "step" }, ]; if (typeof rebuild.model_json === "string" && rebuild.model_json) { @@ -180,10 +246,10 @@ export async function executeDesignIR({ }, source: { path: sourcePath, - format: "designir-2.0", + format: "designir-3.0", backend: selectedBackend, source_editability: "editable_source", - generated_by: "llm_designir_tool_call", + generated_by: "llm_semantic_designir3_tool_call", }, artifacts, assumptions, @@ -207,7 +273,351 @@ export async function executeDesignIR({ viewerAssetPath, viewerAssetUrl: artifactUrl(task.taskId, viewerAssetPath, artifactVersion), sourcePath, - summary: summary || "CAD model generated from executable DesignIR 2.0.", + summary: summary || "CAD model generated from semantic DesignIR 3.0.", + }; +} + +export async function reconstructUploadedStep({ + prompt, + teacherTaskId, + teacherPath, + teacherName, +}: UploadedStepReconstructionInput): Promise { + const { absolutePath: teacherAbsolutePath } = await readArtifact( + teacherTaskId, + teacherPath, + ); + const task = await ensureTask(); + const route = await runJson( + pythonExecutable(), + [ + routerScript(), + prompt || "重建上传的 STEP,并返回可修改参数", + "--edit-source", + teacherAbsolutePath, + "--task-dir", + task.dir, + "--execute", + "--maximum-parameters", + "8", + "--timeout-seconds", + "180", + ], + task.dir, + 900_000, + ); + const execution = ( + route.execution && typeof route.execution === "object" + ? route.execution + : {} + ) as Record; + if ( + execution.status !== "accepted" + && execution.status !== "accepted_empty_source" + ) { + throw new Error( + `DesignIR 3.0 reconstruction was not accepted: ${JSON.stringify(execution)}`, + ); + } + + const relativeArtifact = (value: unknown) => { + const absolute = path.resolve(String(value || "")); + const relative = path.relative(task.dir, absolute); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`CAD Router returned an artifact outside the task: ${absolute}`); + } + return relative; + }; + const sourcePath = relativeArtifact(execution.designir); + const artifactPath = relativeArtifact(execution.rebuilt_step); + const parametersPath = relativeArtifact(execution.parameters); + const geometryReportPath = relativeArtifact(execution.geometry_report); + const editReportPath = relativeArtifact(execution.edit_report); + const parameterCatalog = JSON.parse( + await fs.readFile(path.join(task.dir, parametersPath), "utf8"), + ) as Record; + const existingManifest = JSON.parse( + await fs.readFile(path.join(task.dir, "cad-task.json"), "utf8"), + ) as Record; + + const viewerAssetPath = await createStepGlbPreview({ + taskId: task.taskId, + stepPath: artifactPath, + }); + const artifactVersion = Date.now().toString(36); + const experience = ( + execution.experience && typeof execution.experience === "object" + ? execution.experience + : {} + ) as Record; + await upsertManifest(task.taskId, { + request: prompt, + route: { + selected_backend: String(route.selected_backend || "build123d"), + runner_skill: "cad-router", + project: "text-to-cad", + execution_adapter: "DesignIR 3.0 / SurfaceIR", + fallback_order: Array.isArray(route.fallback_order) + ? route.fallback_order + : [], + workflow_profiles: Array.isArray(route.workflow_profiles) + ? route.workflow_profiles + : [], + }, + source: { + path: sourcePath, + format: "designir-3.0", + backend: "surfaceir_occt", + source_editability: "validated_parameter_source", + generated_by: "cad-router_uploaded_step_execute", + teacher_embedded: false, + }, + artifacts: [ + { path: sourcePath, role: "editable_contract", kind: "designir-3.0" }, + { path: artifactPath, role: "primary", kind: "step" }, + { path: parametersPath, role: "validated_parameters", kind: "json" }, + { path: geometryReportPath, role: "geometry_acceptance", kind: "json" }, + { path: editReportPath, role: "parameter_acceptance", kind: "json" }, + { path: viewerAssetPath, role: "viewer", kind: "glb" }, + ], + parameters: parameterCatalog, + experience: { + context_kind: "promoted_surfaceir_runtime", + ...experience, + }, + assumptions: [], + validation: [ + "Uploaded STEP was used only as extraction and acceptance truth.", + "DesignIR 3.0 anti-cheat validation passed.", + "STEP was independently rebuilt without passing the teacher path to the rebuild worker.", + "Independent geometry acceptance passed.", + `${Number(execution.validated_parameter_count || 0)} editable parameters passed isolated perturbation acceptance.`, + ], + studio: { + latestArtifact: artifactPath, + latestPreview: artifactPath, + latestViewerAsset: viewerAssetPath, + latestVersion: artifactVersion, + uploadReconstruction: { + teacherTaskId, + teacherPath, + teacherName: teacherName || path.basename(teacherPath), + sourceEmbedded: false, + }, + editCommandTemplate: existingManifest.edit_command_template || [], + }, + }); + + const stepUrl = artifactUrl(task.taskId, artifactPath, artifactVersion); + return { + handled: true, + taskId: task.taskId, + artifactPath, + artifactUrl: stepUrl, + previewPath: artifactPath, + previewUrl: stepUrl, + viewerAssetPath, + viewerAssetUrl: artifactUrl( + task.taskId, + viewerAssetPath, + artifactVersion, + ), + sourcePath, + summary: [ + `${teacherName || path.basename(teacherPath)} 已通过 DesignIR 3.0 / SurfaceIR 独立重建。`, + `几何验收通过,已验证可修改参数 ${Number(execution.validated_parameter_count || 0)} 个。`, + ].join(" "), + }; +} + +export async function editDesignIR3Parameter({ + prompt, + sourceTaskId, + parameter, + value, +}: { + prompt: string; + sourceTaskId: string; + parameter: string; + value: number; +}): Promise { + const sourceManifest = await readManifest(sourceTaskId); + if (!sourceManifest || sourceManifest.source?.format !== "designir-3.0") { + throw new Error("Current task is not an editable DesignIR 3.0 reconstruction."); + } + const sourcePath = String(sourceManifest.source.path || ""); + const sourceAbsolutePath = path.join(taskDir(sourceTaskId), sourcePath); + const sourceDesignIR = JSON.parse( + await fs.readFile(sourceAbsolutePath, "utf8"), + ) as Record; + const parameterRecord = sourceDesignIR.semantic_layer?.parameters?.[parameter]; + if ( + !parameterRecord + || parameterRecord.editable !== true + || parameterRecord.edit_state !== "validated_executable_binding" + ) { + throw new Error( + `${parameter} is not a perturbation-validated DesignIR 3.0 parameter.`, + ); + } + const range = parameterRecord.validated_range || {}; + const minimum = Number(range.minimum_tested_inclusive); + const maximum = Number(range.maximum_tested_inclusive); + if ( + !Number.isFinite(value) + || !Number.isFinite(minimum) + || !Number.isFinite(maximum) + || value < minimum + || value > maximum + ) { + throw new Error( + `${parameter} must remain within the validated range [${minimum}, ${maximum}].`, + ); + } + const uploadReconstruction = ( + sourceManifest.studio?.uploadReconstruction + && typeof sourceManifest.studio.uploadReconstruction === "object" + ? sourceManifest.studio.uploadReconstruction + : {} + ) as Record; + const teacherTaskId = String(uploadReconstruction.teacherTaskId || ""); + const teacherPath = String(uploadReconstruction.teacherPath || ""); + if (!teacherTaskId || !teacherPath) { + throw new Error( + "The DesignIR 3.0 task has no teacher reference for independent edit acceptance.", + ); + } + const { absolutePath: teacherAbsolutePath } = await readArtifact( + teacherTaskId, + teacherPath, + ); + const task = await ensureTask(); + const acceptanceDir = path.join(task.dir, "validation", "parameter-edit"); + const acceptance = await runJson( + pythonExecutable(), + [ + surfaceirScript(), + "validate-edit", + teacherAbsolutePath, + sourceAbsolutePath, + "--parameter", + parameter, + "--value", + String(value), + "--output-dir", + acceptanceDir, + ], + task.dir, + 300_000, + ); + if (acceptance.accepted !== true) { + throw new Error( + `DesignIR 3.0 parameter edit failed independent acceptance: ${String(acceptance.state || "rejected")}`, + ); + } + + const generatedStem = `${path.parse(sourcePath).name}.edited`; + const generatedDesignIR = path.join( + acceptanceDir, + `${generatedStem}.json`, + ); + const generatedStep = path.join( + acceptanceDir, + `${generatedStem}.step`, + ); + const safeParameter = sanitizeFilename(parameter); + const outputStem = `${path.parse(sourcePath).name}.${safeParameter}`; + const outputDesignIRPath = `${outputStem}.json`; + const outputStepPath = `${outputStem}.step`; + await fs.copyFile( + generatedDesignIR, + path.join(task.dir, outputDesignIRPath), + ); + await fs.copyFile(generatedStep, path.join(task.dir, outputStepPath)); + const editedDesignIR = JSON.parse( + await fs.readFile(path.join(task.dir, outputDesignIRPath), "utf8"), + ) as Record; + const updatedParameter = editedDesignIR.semantic_layer.parameters[parameter]; + const parameterCatalog = { + ...(sourceManifest.parameters || {}), + parameters: Array.isArray(sourceManifest.parameters?.parameters) + ? sourceManifest.parameters.parameters.map((item: any) => ( + item?.name === parameter + ? { ...item, value: updatedParameter.value } + : item + )) + : [], + }; + const viewerAssetPath = await createStepGlbPreview({ + taskId: task.taskId, + stepPath: outputStepPath, + }); + const artifactVersion = Date.now().toString(36); + await upsertManifest(task.taskId, { + request: prompt, + route: { + selected_backend: "build123d", + runner_skill: "cad-router", + project: "text-to-cad", + execution_adapter: "DesignIR 3.0 / SurfaceIR parameter edit", + fallback_order: ["simplecadapi"], + workflow_profiles: [], + }, + source: { + path: outputDesignIRPath, + format: "designir-3.0", + backend: "surfaceir_occt", + source_editability: "validated_parameter_source", + generated_by: "cad-router_designir3_parameter_edit", + parentTaskId: sourceTaskId, + teacher_embedded: false, + }, + artifacts: [ + { path: outputDesignIRPath, role: "editable_contract", kind: "designir-3.0" }, + { path: outputStepPath, role: "primary", kind: "step" }, + { + path: path.relative( + task.dir, + path.join(acceptanceDir, "acceptance-report.json"), + ), + role: "parameter_acceptance", + kind: "json", + }, + { path: viewerAssetPath, role: "viewer", kind: "glb" }, + ], + parameters: parameterCatalog, + experience: sourceManifest.experience || {}, + assumptions: [], + validation: [ + `Parameter ${parameter} changed to ${value} within its validated range.`, + "The edited STEP passed independent teacher-side semantic edit acceptance.", + "The output remains independently rebuildable from DesignIR 3.0.", + ], + studio: { + latestArtifact: outputStepPath, + latestPreview: outputStepPath, + latestViewerAsset: viewerAssetPath, + latestVersion: artifactVersion, + uploadReconstruction, + parentTaskId: sourceTaskId, + }, + }); + const stepUrl = artifactUrl(task.taskId, outputStepPath, artifactVersion); + return { + handled: true, + taskId: task.taskId, + artifactPath: outputStepPath, + artifactUrl: stepUrl, + previewPath: outputStepPath, + previewUrl: stepUrl, + viewerAssetPath, + viewerAssetUrl: artifactUrl( + task.taskId, + viewerAssetPath, + artifactVersion, + ), + sourcePath: outputDesignIRPath, + summary: `${parameter} 已修改为 ${value},并通过独立参数验收。`, }; } diff --git a/cad-agent-studio/src/lib/chat.ts b/cad-agent-studio/src/lib/chat.ts index 784d729..6f4ae5e 100644 --- a/cad-agent-studio/src/lib/chat.ts +++ b/cad-agent-studio/src/lib/chat.ts @@ -15,7 +15,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { z } from "zod/v4"; -import { executeDesignIR, type CadGenerationResult } from "@/lib/cad-generator"; +import { + editDesignIR3Parameter, + executeDesignIR, + reconstructUploadedStep, + type CadGenerationResult, +} from "@/lib/cad-generator"; import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "@/lib/config"; import { readManifest, taskDir } from "@/lib/task-store"; @@ -244,7 +249,7 @@ async function buildAttachmentUserContent(lastUserContent: string, attachments: "\nUploaded files available to this turn:", ...attachedFileLines, "Images are attached as model-visible image parts when the selected provider/model supports vision.", - "For STEP/STP or binary files, call inspect_uploaded_file with id/taskId/path before using their contents.", + "For STEP/STP reconstruction, call reconstruct_uploaded_step with id/taskId/path. Do not ask the model to author DesignIR from STEP text.", ].join("\n"), }); } @@ -307,7 +312,7 @@ function attachmentSummary(attachments: unknown[]) { return [ "Current uploaded attachment JSON available to the agent:", compactJson(attachments, 14000), - "If a STEP/STP attachment is present, inspect its deterministic evidence, then author DesignIR without reading or importing the teacher STEP.", + "If a STEP/STP attachment is present, call reconstruct_uploaded_step so CAD Router executes the deterministic DesignIR 3.0 / SurfaceIR pipeline.", ].join("\n"); } @@ -319,15 +324,20 @@ function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[], tas "Never return only code when the user asks to generate CAD. Use the generate_cad tool instead.", "Do not rely on hardcoded templates, canned examples, mock data, fake filenames, or imaginary viewer state.", "The generate_cad tool validates executable DesignIR, selects a backend through CAD Router, and rebuilds STEP in a teacher-free sandbox.", - "When STEP files are attached, the source is teacher and acceptance truth only. Reconstruction consumes deterministic evidence, never source B-Rep.", + "When STEP files are attached, the source is teacher and acceptance truth only. Use reconstruct_uploaded_step; never import the teacher as model geometry.", "All uploaded files are available to you. Images are attached directly to the latest user message when the selected provider/model supports vision. STEP/STP and binary files are available through inspect_uploaded_file.", "When the user asks to model from an uploaded image, inspect the image directly from the image part. If the selected provider/model cannot process images, say that clearly and ask the user to switch to a vision-capable OpenAI-compatible model.", - "When the user asks to use an uploaded STEP/STP or other non-image file, call inspect_uploaded_file before making claims about its geometry or contents.", + "When the user asks to reconstruct an uploaded STEP/STP, call reconstruct_uploaded_step directly. Use inspect_uploaded_file only for read-only questions that do not request reconstruction.", "When viewer context is present, use its cad-edit-intent.v1 or cad-ai-geometry-selection.v1 payload as precise geometry context.", "If viewer context selection.scope is selected_reference_only, modify only that selected topology/feature. Do not change a global pattern parameter or all repeated/symmetric features unless the user explicitly asks for all of them.", "For selected holes, use the selected reference center/surface/bbox/adjacent selectors to identify the individual feature. If the editable source models a repeated pattern with one shared diameter variable, split out or override the selected instance instead of changing the shared variable.", "Prefer editing the DesignIR recorded in cad-task.json before regenerating STEP and viewer assets.", - "For CAD generation, author complete DesignIR 2.0 with parameters, datums, features, constraints, edit interface, and validation perturbations.", + "If the current task is an uploaded-STEP DesignIR 3.0 surface_parametric reconstruction and the requested change matches a validated parameter, call edit_designir3_parameter.", + "If the current task is a text/image DesignIR 3.0 fully_semantic_parametric or hybrid_semantic_surface_parametric model, modify its authoritative semantic_layer and call generate_cad again; preserve unrelated parameters, constraints, and features.", + "For every new text/image CAD generation, author complete DesignIR 3.0 with designir_kind=independent_parametric_cad, reconstruction_mode=fully_semantic_parametric, authoring_mode=semantic_feature_program, and semantic_layer containing reconstruction_status, coordinate_system, datums, parameters, expressions, sketches, constraints, features, patterns, attachments, and construction_stages.", + "For semantic DesignIR 3.0, edit_interface must contain semantic_parameters, surface_parameter_groups=[], modification_levels=[semantic_feature], and preserved_interfaces. validation_contract must contain source_independence=true, geometry_checks, edit_checks, invariants, perturbations, and thresholds.", + "Use semantic parameter names that describe design intent, such as flange_thickness, shelf_depth, hole_spacing, rib_count, bore_diameter, and bolt_circle_diameter. Every editable parameter must directly drive at least one feature and have a perturbation test.", + "Uploaded STEP reconstruction also uses DesignIR 3.0, but in deterministic surface_parametric mode. Do not substitute one mode for the other.", "Never embed STEP/B-Rep/mesh data or source topology references in DesignIR.", "If CAD generation is requested and the generate_cad tool is available, call generate_cad.", "For modifications, use the current editable source of truth when provided. Preserve unrelated geometry and change only the selected/requested feature.", @@ -494,24 +504,20 @@ async function inspectStepAttachment(attachment: AttachmentRecord) { "}", "print(json.dumps(payload, ensure_ascii=False))", ].join("\n"); - const [{ stdout }, excerpt] = await Promise.all([ - execFileAsync("python3", ["-c", script, absolutePath], { + const { stdout } = await execFileAsync( + process.env.CAD_PYTHON || path.join(process.cwd(), "..", "text-to-cad", ".venv", "bin", "python"), + ["-c", script, absolutePath], + { timeout: 30_000, maxBuffer: 1024 * 1024, - }), - readTextExcerpt(attachment, 24_000).catch(() => null), - ]); + }, + ); const geometry = JSON.parse(stdout.trim().split(/\r?\n/).pop() || "{}"); return { attachment, inspectionKind: "step_geometry", geometry, - stepTextExcerpt: excerpt ? { - text: excerpt.text, - truncated: excerpt.truncated, - bytesRead: excerpt.bytesRead, - totalBytes: excerpt.totalBytes, - } : null, + sourcePolicy: "Teacher geometry was measured read-only; raw STEP text is not exposed to the reconstruction model.", }; } @@ -687,9 +693,159 @@ export async function streamAgentResponse({ } }, }), + reconstruct_uploaded_step: tool({ + description: [ + "Reconstruct one uploaded STEP/STP through CAD Router's executable DesignIR 3.0 / SurfaceIR pipeline.", + "Use this instead of generate_cad for an uploaded STEP.", + "It independently rebuilds STEP, runs geometry acceptance, validates editable parameters, and publishes the preview.", + ].join(" "), + inputSchema: z.object({ + id: z.string().optional().describe("Attachment id from the uploaded file list."), + taskId: z.string().optional().describe("Attachment taskId."), + path: z.string().optional().describe("STEP path inside the upload task."), + }), + execute: async ({ id, taskId, path: attachmentPath }) => { + const attachment = findAttachment(uploadedAttachments, { + id, + taskId, + path: attachmentPath, + }); + if (!attachment || !isStepAttachment(attachment)) { + throw new Error( + "A STEP/STP attachment is required. Use id/taskId/path from the uploaded attachment list.", + ); + } + writeCadProgress({ + step: "execute_cad", + label: "DesignIR 3.0 独立重建", + status: "running", + message: "正在提取 SurfaceIR、独立重建、执行几何和参数验收。", + }); + try { + cadGeneration = await reconstructUploadedStep({ + prompt: lastUserText(chatMessages), + teacherTaskId: String(attachment.taskId), + teacherPath: String(attachment.path), + teacherName: attachment.name, + }); + } catch (error) { + const message = error instanceof Error + ? error.message + : "Uploaded STEP reconstruction failed."; + writeCadProgress({ + step: "execute_cad", + label: "DesignIR 3.0 独立重建", + status: "error", + message, + }); + failAgentStream(message); + throw error; + } + writeCadProgress({ + step: "execute_cad", + label: "DesignIR 3.0 独立重建", + status: "success", + message: `${cadGeneration.artifactPath},参数验收已完成。`, + }); + writeCadProgress({ + step: "update_preview", + label: "更新右侧预览", + status: "success", + message: "独立重建模型已载入。", + }); + completeAgentStream("DesignIR 3.0 重建和验收已完成。"); + streamWriter?.write({ + type: "data-cad-result", + data: { + taskId: cadGeneration.taskId, + sourcePath: cadGeneration.sourcePath, + artifactPath: cadGeneration.artifactPath, + artifactUrl: cadGeneration.artifactUrl, + previewPath: cadGeneration.previewPath, + previewUrl: cadGeneration.previewUrl, + viewerAssetPath: cadGeneration.viewerAssetPath, + viewerAssetUrl: cadGeneration.viewerAssetUrl, + summary: cadGeneration.summary, + }, + id: `cad_result_${cadGeneration.taskId}`, + }); + return cadGeneration; + }, + }), + edit_designir3_parameter: tool({ + description: [ + "Edit one perturbation-validated parameter on the current DesignIR 3.0 reconstruction.", + "The requested value must be within the validated range shown in current cad-task.json.", + "The server reruns independent teacher-side semantic edit acceptance before publishing.", + ].join(" "), + inputSchema: z.object({ + parameter: z.string().describe("Validated parameter name from the current task."), + value: z.number().describe("New value within the parameter's validated range."), + }), + execute: async ({ parameter, value }) => { + if (!selectedTaskId) { + throw new Error("No current DesignIR 3.0 task is selected."); + } + writeCadProgress({ + step: "execute_cad", + label: "修改 DesignIR 3.0 参数", + status: "running", + message: `${parameter} → ${value},正在执行独立参数验收。`, + }); + try { + cadGeneration = await editDesignIR3Parameter({ + prompt: lastUserText(chatMessages), + sourceTaskId: selectedTaskId, + parameter, + value, + }); + } catch (error) { + const message = error instanceof Error + ? error.message + : "DesignIR 3.0 parameter edit failed."; + writeCadProgress({ + step: "execute_cad", + label: "修改 DesignIR 3.0 参数", + status: "error", + message, + }); + failAgentStream(message); + throw error; + } + writeCadProgress({ + step: "execute_cad", + label: "修改 DesignIR 3.0 参数", + status: "success", + message: cadGeneration.summary, + }); + writeCadProgress({ + step: "update_preview", + label: "更新右侧预览", + status: "success", + message: "参数修改后的模型已载入。", + }); + completeAgentStream("DesignIR 3.0 参数修改和验收已完成。"); + streamWriter?.write({ + type: "data-cad-result", + data: { + taskId: cadGeneration.taskId, + sourcePath: cadGeneration.sourcePath, + artifactPath: cadGeneration.artifactPath, + artifactUrl: cadGeneration.artifactUrl, + previewPath: cadGeneration.previewPath, + previewUrl: cadGeneration.previewUrl, + viewerAssetPath: cadGeneration.viewerAssetPath, + viewerAssetUrl: cadGeneration.viewerAssetUrl, + summary: cadGeneration.summary, + }, + id: `cad_result_${cadGeneration.taskId}`, + }); + return cadGeneration; + }, + }), generate_cad: tool({ description: [ - "Generate a real CAD model from agent-authored executable DesignIR 2.0.", + "Generate a real CAD model from agent-authored semantic DesignIR 3.0.", "Call this only when the user wants CAD generation or a model modification.", "CAD Router selects the backend and the server rebuilds STEP without teacher geometry.", ].join(" "), @@ -697,7 +853,7 @@ export async function streamAgentResponse({ summary: z.string().describe("Concise user-facing summary of the generated CAD model."), designirFilename: z.string().describe("DesignIR filename, e.g. model.designir.json."), stepFilename: z.string().describe("STEP output filename, e.g. model.step."), - designir: z.record(z.string(), z.unknown()).describe("Complete executable DesignIR 2.0 JSON object."), + designir: z.record(z.string(), z.unknown()).describe("Complete semantic DesignIR 3.0 JSON object in fully_semantic_parametric mode."), teacherTaskId: z.string().optional().describe("Uploaded teacher STEP task id for independent acceptance only."), teacherPath: z.string().optional().describe("Uploaded teacher STEP path for independent acceptance only."), assumptions: z.array(z.string()).default([]).describe("Assumptions made because the user did not specify every dimension."), diff --git a/designir-pipeline/README.md b/designir-pipeline/README.md index a759fdd..f44e37c 100644 --- a/designir-pipeline/README.md +++ b/designir-pipeline/README.md @@ -4,10 +4,12 @@ This directory owns the STEP reconstruction and distillation boundary: ```text teacher STEP - -> deterministic private evidence extraction - -> reconstruction agent authors DesignIR 2.0 - -> isolated compiler rebuilds STEP without teacher access - -> acceptance agent compares teacher and rebuilt STEP + -> deterministic OCCT extraction into DesignIR 3.0 + -> SurfaceIR: independent geometry/topology program + -> Semantic FeatureIR: canonical names, features and constraints + -> isolated OCCT compiler rebuilds STEP without teacher access + -> reconstruction/distillation agent proposes repairs and semantic bindings + -> acceptance agent compares geometry and executes parameter perturbations -> deterministic publisher promotes repeated validated experience ``` @@ -16,7 +18,7 @@ teacher STEP | Component | Teacher STEP | Private evidence | DesignIR | Rebuilt STEP | | --- | --- | --- | --- | --- | | Ingestion service | read | write | no | no | -| Reconstruction agent | denied | read | write | request compilation | +| Reconstruction agent | denied | read | write | request compilation/edit | | Isolated compiler | denied | no | read | write | | Acceptance agent | read | read | read | read | | Publisher | denied | aggregate only | schema only | reports only | @@ -35,6 +37,7 @@ designir-pipeline/ input/ drop source STEP/STP files here runs/ all private process artifacts output/library.json verified experience consumed at runtime + output/distillation-report.json public aggregate result and validation state ``` `input/` and `runs/` are ignored by Git. Only the deterministic result in @@ -53,16 +56,36 @@ needed: The shallow directory structure is for usability, not authorization. Agent and compiler access is enforced by their contracts and by anti-cheat checks. -## DesignIR 2.0 +## DesignIR contracts -The executable contract is documented by -`contracts/designir-2.0.schema.json`. It describes coordinate systems, datums, -parameters, expressions, sketches, constraints, features, patterns, -attachments, construction stages, edit interfaces, and validation contracts. -It never contains teacher geometry. +`contracts/designir-3.0.schema.json` is the authoritative contract for both +text/image semantic authoring and uploaded-STEP reconstruction. +`contracts/designir-2.0.schema.json` remains only for legacy migration. +DesignIR 3.0 combines: -The initial compiler deliberately supports a small, honest vocabulary through -both Build123d and SimpleCADAPI adapters: +- an executable OCCT SurfaceIR for independent geometric reconstruction; +- a Semantic FeatureIR for paper-style names such as `plate_thickness`, + `hole_diameter`, `hole_spacing`, `bolt_circle_diameter`, and `rib_count`; +- explicit edit operations and acceptance contracts. + +STEP does not contain the original sketch, parameter names, feature tree, or +construction history. Extraction therefore keeps a strict epistemic boundary: + +- analytic surfaces, axes, radii, topology, and bounds are observed evidence; +- feature instances, semantic names, constraints, and datums are inferred + hypotheses with confidence; +- canonical stages are recommended reconstruction order, never recovered + source history; +- a hypothesis becomes trusted DesignIR only after isolated compilation, + geometry comparison, and targeted parameter-edit validation. + +The private evidence names editable candidates using a stable vocabulary such +as `plate_thickness`, `hole_diameter`, `hole_spacing`, +`bolt_circle_diameter`, and `rib_count`. Ambiguous cylinder groups retain +aliases and reduced confidence instead of being silently declared holes. + +The semantic DesignIR 3.0 compiler currently supports this initial vocabulary +through Build123d and SimpleCADAPI: - `extrude_circle` - `extrude_rectangle` @@ -70,13 +93,138 @@ both Build123d and SimpleCADAPI adapters: - `through_hole` - `polar_hole_pattern` -Unsupported geometry is quarantined with explicit missing capabilities instead -of embedding the source B-Rep. +Build123d and SimpleCADAPI are not the geometry fidelity boundary. Analytic and +B-Spline surfaces unsupported by those adapters are compiled directly by OCCT +from SurfaceIR. Unsupported operations are quarantined instead of embedding the +source B-Rep. The SimpleCADAPI adapter also emits its replayable `*.simplecad.model.json` operation graph beside the STEP. DesignIR remains the backend-neutral source of truth. +## DesignIR 3.0 geometry and semantic layers + +DesignIR 3.0 adds an independent low-level parameter layer for STEP regions +that are not yet expressible as semantic features. It stores analytic surface +parameters, B-Spline poles/weights/knots, exact boundary curves, pcurves, and +face-wire-shell-solid topology as JSON. It does not store the STEP file, an +import instruction, an embedded B-Rep, or a triangle mesh. + +The semantic layer is the preferred modification interface. Parameters remain +non-editable candidates until an executable binding passes a real perturbation. +Feature-specific bindings enlarge cylindrical holes and bores by applying +deduplicated OCCT cut features to the independently rebuilt SurfaceIR. Every +geometry-bearing model also exposes `overall_scale` as a dimensionless minimum +editability fallback about a deterministic SurfaceIR datum. This fallback does +not claim recovery of feature-specific parameters such as plate thickness, +hole spacing, or rib count. + +The acceptance agent checks the requested axes/radii for cylindrical edits, +solid validity, declared volume direction, non-target parameter stability, and +source independence. For uniform scale it additionally requires volume to +change by the scale factor cubed, center and bounds to undergo the same +transform, and solid/face/edge counts to remain stable. Equivalent vertex +normalization performed by STEP serialization is retained as a diagnostic. + +```bash +# Convert every input STEP into an independent DesignIR 3.0 instance program. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py extract-folder \ + input --output-dir runs/surfaceir + +# Rebuild and compare a deterministic batch without loading STEP at runtime. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py validate-folder \ + input --designir-dir runs/surfaceir \ + --rebuilt-dir runs/surfaceir-rebuilt --limit 100 + +# Apply one named parameter without loading the teacher STEP in the compiler. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py edit \ + runs/surfaceir/part.designir.json \ + --parameter hole_diameter --value 6 \ + --output-designir runs/edited/part.designir.json \ + --output-step runs/edited/part.step + +# Agent B runs process-isolated +10% perturbations over a corpus. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py \ + validate-edits-folder input \ + --designir-dir runs/surfaceir \ + --output-dir runs/semantic-edit-acceptance \ + --geometry-report runs/surfaceir-rebuilt/validation-report.json \ + --workers 4 + +# Validate the universal source-independent edit fallback on every non-empty +# model. Empty STEP documents remain explicitly non-editable. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py \ + augment-overall-scale runs/surfaceir \ + --output runs/surfaceir/overall-scale-augmentation.json +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py \ + validate-edits-folder input \ + --designir-dir runs/surfaceir \ + --output-dir runs/overall-scale-acceptance \ + --geometry-report runs/surfaceir/strategy-selection-final.json \ + --parameter overall_scale --workers 4 + +# Merge incremental failure retries into one authoritative acceptance report. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py \ + merge-acceptance-reports \ + --baseline runs/semantic-edit-acceptance/batch-acceptance-report.json \ + --retry runs/semantic-edit-retry/batch-acceptance-report.json \ + --output runs/semantic-edit-authoritative.json +``` + +Boundary reconstruction strategies are evaluated by paired held-out A/B. The +Promoted methods and their exact held-out evidence are stored in +`output/library.json`. Geometry methods and semantic-edit methods have separate +counts; a geometry-only result never increases the edit-capability count. + +The current 1,000-file corpus contains 999 geometry-bearing STEP documents and +one truly empty STEP document. Of the geometry-bearing documents, 996 contain +solids and three contain top-level open shells. SurfaceIR preserves all three +open shells through `surface_layer.free_shells`; the empty source is rebuilt as +an equivalent empty STEP document. Full-corpus validation therefore produces +1,000 independent rebuilt STEP documents from 1,000 DesignIR programs. + +The compiler restores exact 3D curves, binds per-face pcurves, preserves seam +branches, keeps vertex and edge tolerances independent, groups shells under +their declared solid, and keeps top-level open shells outside fake solids. +Validation requires matching solid/face/edge topology, volume or surface area, +center and bounds. Boolean symmetric difference is the primary solid check. +For two numerically unstable coincident-solid booleans, a bidirectional sample +contract checks vertices, edge samples and valid face-interior samples instead; +the larger measured maximum distance is `0.0000647066 mm`, below the declared +`0.0001 mm` limit. + +Every one of the 999 geometry-bearing documents has at least one non-generic, +named parameter with a completed real perturbation contract. The private +DesignIR corpus contains 1,213 validated named parameter instances: + +- cylindrical cuts: `hole_diameter` and `bore_diameter`; +- primary-axis dimensions: `outer_diameter`, `body_length`, and + `plate_thickness`; +- canonical envelope dimensions: `overall_size_x`, `overall_size_y`, and + `overall_size_z`. + +`overall_scale` remains available as a source-independent fallback, but is not +counted in the 999/999 named-parameter coverage. Canonical envelope dimensions +describe the DesignIR coordinate-system bounds and are not presented as +recovered source sketch history. The one truly empty document has no geometry +to modify and intentionally exposes no fabricated parameter. + +```bash +# Validate the pcurve-bound strategy over the complete corpus. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py validate-folder \ + input --designir-dir runs/surfaceir \ + --rebuilt-dir runs/rebuilt-pcurve --boolean \ + --boundary-strategy exact_3d_pcurve + +# Compare with the exact-boundary fallback and fix the validated choice in each +# source-independent DesignIR document. +../text-to-cad/.venv/bin/python scripts/surfaceir_pipeline.py select-strategies \ + --designir-dir runs/surfaceir \ + --primary-report runs/rebuilt-pcurve/validation-report.json \ + --fallback-report runs/rebuilt-exact/validation-report.json \ + --output runs/surfaceir/strategy-selection-final.json +``` + ## Agents - `agents/reconstruction-agent.md`: Agent A authors DesignIR from private @@ -92,11 +240,27 @@ by deterministic code after several independent cases pass. Drop files into `input/`, then use the CAD Python environment: ```bash +# Build a deterministic, family-stratified 800/75/100 train/validation/test split. +node scripts/trajectory_pipeline.mjs init + +# Agent A proposes DesignIR; the isolated compiler and Agent B contract reject +# geometry or edit failures. Private attempts are written below runs/trajectory/. +node scripts/trajectory_pipeline.mjs run \ + --split train --limit 10 --attempts 3 --max-faces 24 + +# Generalize only replay-validated trajectories into non-consumable candidates. +node scripts/trajectory_pipeline.mjs distill + +# Compare the same model on the same held-out cases with and without candidates. +node scripts/trajectory_pipeline.mjs benchmark \ + --split validation --limit 10 --attempts 3 --max-faces 24 + scripts/cad-experience extract-folder scripts/cad-experience prepare # The semantic distiller writes runs/review/experience-draft.json. scripts/cad-experience publish \ - --draft runs/review/experience-draft.json + --draft runs/review/experience-draft.json \ + --validation-report runs/review/heldout-validation.json scripts/cad-experience audit output/library.json ../text-to-cad/.venv/bin/python scripts/designir_pipeline.py validate part.designir.json @@ -108,3 +272,26 @@ scripts/cad-experience audit output/library.json --teacher teacher.step --designir part.designir.json \ --rebuilt rebuilt.step --report acceptance.json ``` + +Repeated support only creates a candidate. `output/library.json` receives a +method only when its validation report identifies at least three independent +teacher hashes, every declared edit contract passes, and the held-out A/B score +delta against the empty-library baseline is positive across at least ten paired +cases. Until then `router_consumable` remains false and CAD Router cannot use +the candidate. + +The execution trajectory is the actual unit of learning: + +```text +private B-Rep evidence + -> proposed DesignIR + -> isolated rebuild without source STEP + -> geometry and edit-contract verdict + -> validator feedback and retry + -> successful trajectory candidate +``` + +Counts, detector confidence, and repeated geometric motifs are not treated as +improvement. Improvement is reported only by a positive paired held-out A/B +result. `output/distillation-report.json` separates corpus extraction, +untrusted candidates, execution-validated trajectories, and promoted methods. diff --git a/designir-pipeline/agents/acceptance-agent.md b/designir-pipeline/agents/acceptance-agent.md index fdfed7d..aa885e7 100644 --- a/designir-pipeline/agents/acceptance-agent.md +++ b/designir-pipeline/agents/acceptance-agent.md @@ -8,7 +8,7 @@ meaningfully editable. ## Allowed inputs - Teacher STEP from protected `input/`. -- Candidate DesignIR from `runs/designir/`. +- Candidate DesignIR 3.0 from the private run directory. - Reconstructed STEP from `runs/rebuilt/`. - Deterministic compiler version. - Modification test contract. @@ -19,13 +19,25 @@ Do not read Agent A chain-of-thought or informal rationale. Original-state geometry: -- solid and topology counts; +- solid, open-shell, face, edge, and diagnostic vertex counts; - bounding-box error; - volume and center-of-mass error; - symmetric-difference volume; - analytic feature counts and spatial relationships when available; - declared validation invariants. +For solid booleans that are numerically unstable on coincident boundaries, run +the registered bidirectional vertex, edge, and face-interior sample-distance +contract. Never silently discard the failed boolean result; report both it and +the fallback distance. + +Source independence: + +- move or hide the teacher STEP before reconstruction; +- reject source paths, STEP payloads, imported base solids, B-Rep blobs, and + mesh substitutes in the candidate; +- require a deterministic rebuild from DesignIR plus compiler code only. + Parametric behavior: - rebuild every declared perturbation; @@ -34,6 +46,20 @@ Parametric behavior: - preserve constraints and interfaces; - reject invalid, empty, non-solid, self-intersecting, or unstable results. +For `overall_scale`, require volume to follow the scale factor cubed, center +and bounds to follow the declared pivot transform, and solid/face/edge counts +to remain stable. Record STEP writer vertex normalization separately instead +of confusing geometrically equivalent serialization with feature loss. + +Producing a solid is not sufficient. Each perturbation must be connected to an +executable feature, cause an observable target change, and satisfy its +non-target preservation checks. Record the promoted experience IDs actually +used so improvement can be attributed to a specific method. + +Report geometry and semantics separately. A geometry-only method may enter the +SurfaceIR experience library, but it must never increase the semantic-edit +promotion count. + ## Required output Write one machine-readable report under `runs/acceptance/` with separate geometry, diff --git a/designir-pipeline/agents/reconstruction-agent.md b/designir-pipeline/agents/reconstruction-agent.md index d47fe3b..267edb5 100644 --- a/designir-pipeline/agents/reconstruction-agent.md +++ b/designir-pipeline/agents/reconstruction-agent.md @@ -1,14 +1,16 @@ -# Agent A — DesignIR Reconstruction +# Agent A — Distillation and Semantic Reconstruction ## Mission -Turn deterministic private evidence and promoted generalized experience into a -complete executable DesignIR 2.0 model. Produce design logic, not copied -geometry. +Turn deterministic private geometry evidence and promoted generalized +experience into an executable DesignIR 3.0 model. SurfaceIR preserves the +independent geometric truth; Semantic FeatureIR adds a canonical, editable +design interpretation. Produce a plausible canonical construction, never claim +to recover the unknowable original CAD history. ## Allowed inputs -- One private evidence packet from `runs/evidence/`. +- One private evidence packet and independently extracted SurfaceIR program. - The user modification request. - Promoted generalized experience. - The DesignIR schema and supported operation vocabulary. @@ -16,20 +18,27 @@ geometry. ## Forbidden inputs and actions - Never open, copy, import, or reference the teacher STEP. -- Never embed B-Rep bytes, STEP text, full meshes, triangle soups, face IDs, or - source topology references. +- Never embed B-Rep bytes, STEP text, full meshes, or triangle soups. - Never call `import_step`, `read_step`, `STEPControl_Reader`, or equivalent. - Never represent the teacher as a base feature. - Never infer unsupported geometry silently. ## Required output -Write one `runs/designir/*.designir.json` containing: +Write one private DesignIR 3.0 instance program containing: -- family and reconstruction status; +- a backend-neutral SurfaceIR topology/surface layer capable of rebuilding with + the teacher removed, including top-level open shells that are not members of + any solid; +- family and semantic reconstruction status; - coordinate system and datums; - editable parameters and expressions; -- sketches and constraints where applicable; +- canonical semantic parameter names such as `plate_thickness`, + `hole_diameter`, `hole_spacing`, `bolt_circle_diameter`, and `rib_count` + whenever the evidence supports them; +- inference status, confidence, aliases, affected feature IDs, and an observable + edit contract for every parameter inferred from the final B-Rep; +- canonical sketches and constraints where the evidence supports them; - ordered features, patterns, and attachments; - construction stages; - edit interface; @@ -41,10 +50,26 @@ If the vocabulary is insufficient, emit `reconstruction_status: unsupported_feature_vocabulary` plus precise `missing_capabilities`. Do not approximate secretly. +Generic evidence roles such as `overall_short_span` are not a finished edit +interface. Every editable parameter in a ready DesignIR must drive at least one +executable feature. `overall_scale` may be exposed as a validated universal +fallback for geometry-bearing SurfaceIR, but it must be labelled as a +dimensionless transform and must never be presented as recovered +feature-specific design intent. + +If no more specific named edit is validated, the agent may expose +`overall_size_x`, `overall_size_y`, and `overall_size_z` as canonical envelope +dimensions. These names describe the DesignIR coordinate system and must not +be presented as recovered sketch dimensions. + ## Completion gate Request `isolated-rebuild`. Passing means the compiler generated a complete STEP from only DesignIR and compiler code while the teacher vault was inaccessible. -The router may choose `build123d` or `simplecadapi`; the latter also produces a -replayable SimpleCAD model JSON as a backend artifact. Put rebuild artifacts in -`runs/rebuilt/`. +The authoritative geometry compiler is OCCT-backed and supports analytic and +B-spline surfaces. Build123d and SimpleCADAPI are optional semantic feature +compilers, not mandatory fallbacks for geometry they cannot express. + +Geometry equality alone completes only the geometry gate. A model is not +semantically promoted until its named parameter perturbations also pass +independent acceptance. diff --git a/designir-pipeline/config/promotion-policy.json b/designir-pipeline/config/promotion-policy.json index f0cc603..a112235 100644 --- a/designir-pipeline/config/promotion-policy.json +++ b/designir-pipeline/config/promotion-policy.json @@ -1,23 +1,30 @@ { "schema_version": "1.0", "minimum_independent_cases": 3, + "minimum_heldout_ab_pairs": 10, "required_states": [ "geometry_validated", "editability_validated", "replay_validated" ], "thresholds": { - "relative_volume_error_max": 0.01, - "center_of_mass_error_mm_max": 0.1, - "bounding_box_axis_error_mm_max": 0.1, - "symmetric_difference_ratio_max": 0.02, - "parameter_perturbation_success_rate_min": 1.0 + "relative_volume_error_max": 1e-7, + "relative_surface_area_error_max": 1e-7, + "center_of_mass_error_mm_max": 1e-6, + "bounding_box_axis_error_mm_max": 1e-6, + "symmetric_difference_ratio_max": 1e-8, + "bidirectional_surface_sample_distance_mm_max": 0.0001, + "solid_face_edge_topology_match_required": true, + "vertex_count_normalization_is_diagnostic": true, + "parameter_perturbation_success_rate_min": 1.0, + "heldout_baseline_score_delta_min_exclusive": 0.0 }, "publication_guards": { "instance_parameters_allowed": false, "absolute_coordinates_allowed": false, "teacher_geometry_allowed": false, - "automatic_agent_publish_allowed": false + "automatic_agent_publish_allowed": false, + "per_method_attribution_required": true, + "semantic_parameter_edit_contract_required": true } } - diff --git a/designir-pipeline/contracts/designir-2.0.schema.json b/designir-pipeline/contracts/designir-2.0.schema.json index e55073d..ab01816 100644 --- a/designir-pipeline/contracts/designir-2.0.schema.json +++ b/designir-pipeline/contracts/designir-2.0.schema.json @@ -33,6 +33,11 @@ "backend_hint": { "enum": ["auto", "build123d", "simplecadapi"] }, + "applied_experience_ids": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, "reconstruction_status": { "enum": ["ready", "partial", "unsupported_feature_vocabulary"] }, @@ -177,6 +182,12 @@ "minItems": 3, "maxItems": 3 }, + "valueVector3": { + "type": "array", + "items": {"$ref": "#/$defs/value"}, + "minItems": 3, + "maxItems": 3 + }, "parameter": { "type": "object", "additionalProperties": false, @@ -186,6 +197,28 @@ "unit": {"enum": ["mm", "deg", "count", "ratio"]}, "editable": {"type": "boolean"}, "role": {"type": "string"}, + "semantic_aliases": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + "inference": { + "type": "object", + "additionalProperties": false, + "required": ["status", "confidence"], + "properties": { + "status": { + "enum": ["user_specified", "inferred_from_final_brep", "validated"] + }, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "evidence_kind": {"type": "string"} + } + }, + "affects_feature_ids": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, "minimum": {"type": "number"}, "maximum": {"type": "number"} } @@ -228,8 +261,17 @@ "depth": {"$ref": "#/$defs/value"}, "count": {"$ref": "#/$defs/value"}, "pitch_diameter": {"$ref": "#/$defs/value"}, - "axis": {"type": "string"}, - "center": {"$ref": "#/$defs/vector3"}, + "axis": { + "oneOf": [ + {"type": "string"}, + {"$ref": "#/$defs/vector3"} + ] + }, + "center": {"$ref": "#/$defs/valueVector3"}, + "anchor": { + "enum": ["base_center", "geometric_center"], + "default": "base_center" + }, "host": {"type": "string"} } } diff --git a/designir-pipeline/contracts/designir-3.0.schema.json b/designir-pipeline/contracts/designir-3.0.schema.json new file mode 100644 index 0000000..70c7e3c --- /dev/null +++ b/designir-pipeline/contracts/designir-3.0.schema.json @@ -0,0 +1,334 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cadset.local/contracts/designir-3.0.schema.json", + "title": "DesignIR 3.0", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "designir_kind", + "model_id", + "units", + "document_status", + "reconstruction_mode", + "semantic_layer", + "edit_interface", + "validation_contract" + ], + "properties": { + "schema_version": {"const": "3.0"}, + "designir_kind": {"const": "independent_parametric_cad"}, + "model_id": {"type": "string", "minLength": 1}, + "family": {"type": "string", "minLength": 1}, + "units": {"const": "mm"}, + "authoring_mode": { + "enum": ["semantic_feature_program", "final_brep_inference"] + }, + "backend_hint": {"enum": ["build123d", "simplecadapi", "auto"]}, + "document_status": {"enum": ["geometry_present", "empty_geometry"]}, + "reconstruction_mode": { + "enum": [ + "fully_semantic_parametric", + "hybrid_semantic_surface_parametric", + "surface_parametric" + ] + }, + "semantic_layer": { + "type": "object", + "required": ["parameters", "datums", "features", "constraints"], + "properties": { + "reconstruction_status": { + "enum": ["ready", "partial", "unsupported_feature_vocabulary"] + }, + "coordinate_system": {"$ref": "#/$defs/coordinateSystem"}, + "parameters": {"type": "object"}, + "datums": {"type": "object"}, + "expressions": {"type": "object"}, + "sketches": {"type": "array", "items": {"type": "object"}}, + "features": {"type": "array", "items": {"type": "object"}}, + "constraints": {"type": "array", "items": {"type": "object"}}, + "patterns": {"type": "array", "items": {"type": "object"}}, + "attachments": {"type": "array", "items": {"type": "object"}}, + "construction_stages": {"type": "array", "items": {"type": "object"}}, + "missing_capabilities": {"type": "array", "items": {"type": "string"}}, + "applied_experience_ids": {"type": "array", "items": {"type": "string"}} + } + }, + "surface_layer": { + "type": "object", + "required": ["vertices", "solids", "free_shells", "surface_vocabulary", "curve_vocabulary"], + "properties": { + "vertices": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "point", "tolerance"], + "properties": { + "id": {"type": "string"}, + "point": {"$ref": "#/$defs/point3"}, + "tolerance": {"type": "number", "minimum": 0} + } + } + }, + "solids": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "shells"], + "properties": { + "id": {"type": "string"}, + "shells": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "faces"], + "properties": { + "id": {"type": "string"}, + "faces": { + "type": "array", + "items": {"$ref": "#/$defs/face"} + } + } + } + } + } + } + }, + "free_shells": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "faces"], + "properties": { + "id": {"type": "string"}, + "orientation": {"type": "integer"}, + "faces": { + "type": "array", + "items": {"$ref": "#/$defs/face"} + } + } + } + }, + "surface_vocabulary": { + "type": "array", + "items": { + "enum": ["plane", "cylinder", "cone", "sphere", "torus", "bspline"] + }, + "uniqueItems": true + }, + "curve_vocabulary": { + "type": "array", + "items": { + "enum": ["line", "circle", "ellipse", "bspline", "degenerate"] + }, + "uniqueItems": true + } + } + }, + "reconstruction_strategy": { + "type": "object", + "required": ["boundary_strategy", "selection_status"], + "properties": { + "boundary_strategy": { + "enum": [ + "exact_3d", + "exact_3d_pcurve", + "first_pcurve", + "analytic_uv_rect", + "uv_rect_all" + ] + }, + "selection_status": { + "enum": ["default", "geometry_validated", "unresolved"] + } + } + }, + "edit_interface": { + "type": "object", + "required": [ + "semantic_parameters", + "surface_parameter_groups", + "modification_levels" + ], + "properties": { + "semantic_parameters": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + "surface_parameter_groups": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + "modification_levels": { + "type": "array", + "items": { + "enum": ["semantic_feature", "analytic_surface", "spline_control"] + }, + "uniqueItems": true + }, + "preserved_interfaces": {"type": "array", "items": {"type": "string"}} + } + }, + "validation_contract": { + "type": "object", + "required": ["source_independence", "geometry_checks", "edit_checks"], + "properties": { + "source_independence": {"const": true}, + "geometry_checks": {"type": "array", "items": {"type": "string"}}, + "edit_checks": {"type": "array", "items": {"type": "string"}}, + "invariants": {"type": "array", "items": {"type": "object"}}, + "perturbations": { + "type": "array", + "items": { + "type": "object", + "required": ["parameter", "scale", "expected_change"], + "properties": { + "parameter": {"type": "string"}, + "scale": {"type": "number", "exclusiveMinimum": 0}, + "expected_change": {"type": "string", "minLength": 1}, + "preserve": {"type": "array", "items": {"type": "string"}} + } + } + }, + "thresholds": {"type": "object"} + } + }, + "compiled_surface_provenance": { + "type": "object", + "required": [ + "source", + "teacher_geometry_used", + "semantic_layer_authoritative" + ], + "properties": { + "source": {"const": "generated_step"}, + "teacher_geometry_used": {"const": false}, + "semantic_layer_authoritative": {"const": true} + } + } + }, + "allOf": [ + { + "if": { + "properties": {"reconstruction_mode": {"const": "surface_parametric"}} + }, + "then": {"required": ["surface_layer"]} + }, + { + "if": { + "properties": { + "reconstruction_mode": { + "enum": [ + "fully_semantic_parametric", + "hybrid_semantic_surface_parametric" + ] + } + } + }, + "then": { + "required": ["family", "authoring_mode"], + "properties": { + "semantic_layer": { + "required": [ + "reconstruction_status", + "coordinate_system", + "parameters", + "datums", + "expressions", + "sketches", + "features", + "constraints", + "patterns", + "attachments", + "construction_stages" + ] + } + } + } + }, + { + "if": { + "properties": { + "reconstruction_mode": { + "const": "hybrid_semantic_surface_parametric" + } + } + }, + "then": {"required": ["surface_layer", "compiled_surface_provenance"]} + } + ], + "$defs": { + "point3": { + "type": "array", + "prefixItems": [ + {"type": "number"}, + {"type": "number"}, + {"type": "number"} + ], + "minItems": 3, + "maxItems": 3 + }, + "coordinateSystem": { + "type": "object", + "required": ["origin", "x_axis", "y_axis", "z_axis"], + "properties": { + "origin": {"$ref": "#/$defs/point3"}, + "x_axis": {"$ref": "#/$defs/point3"}, + "y_axis": {"$ref": "#/$defs/point3"}, + "z_axis": {"$ref": "#/$defs/point3"} + } + }, + "face": { + "type": "object", + "required": ["id", "orientation", "surface", "uv_bounds", "wires"], + "properties": { + "id": {"type": "string"}, + "orientation": {"type": "integer"}, + "surface": {"type": "object"}, + "uv_bounds": { + "type": "array", + "items": {"type": "number"}, + "minItems": 4, + "maxItems": 4 + }, + "wires": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "orientation", "edges"], + "properties": { + "id": {"type": "string"}, + "orientation": {"type": "integer"}, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "topology_edge_id", + "vertex_ids", + "orientation", + "curve" + ], + "properties": { + "id": {"type": "string"}, + "topology_edge_id": {"type": "string"}, + "vertex_ids": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 2 + }, + "orientation": {"type": "integer"}, + "curve": {"type": "object"} + } + } + } + } + } + } + } + } + } +} diff --git a/designir-pipeline/examples/raised_hub_flange.designir.json b/designir-pipeline/examples/raised_hub_flange.designir.json index 7549975..e7f6e42 100644 --- a/designir-pipeline/examples/raised_hub_flange.designir.json +++ b/designir-pipeline/examples/raised_hub_flange.designir.json @@ -38,7 +38,7 @@ ], "features": [ {"id": "base_flange", "operation": "extrude_circle", "radius": {"parameter": "outer_radius"}, "height": {"parameter": "plate_thickness"}, "axis": "primary_axis"}, - {"id": "raised_hub", "operation": "add_cylinder", "radius": {"parameter": "hub_radius"}, "height": {"parameter": "hub_height"}, "center": [0, 0, 4], "axis": "primary_axis"}, + {"id": "raised_hub", "operation": "add_cylinder", "radius": {"parameter": "hub_radius"}, "height": {"parameter": "hub_height"}, "center": [0, 0, {"parameter": "plate_thickness"}], "axis": "primary_axis"}, {"id": "central_bore", "operation": "through_hole", "diameter": {"parameter": "bore_diameter"}, "axis": "primary_axis", "host": "base_flange"}, {"id": "mounting_pattern", "operation": "polar_hole_pattern", "count": {"parameter": "hole_count"}, "diameter": {"parameter": "hole_diameter"}, "pitch_diameter": {"parameter": "bolt_circle_diameter"}, "axis": "primary_axis", "host": "base_flange"} ], diff --git a/designir-pipeline/output/distillation-report.json b/designir-pipeline/output/distillation-report.json new file mode 100644 index 0000000..7a55239 --- /dev/null +++ b/designir-pipeline/output/distillation-report.json @@ -0,0 +1,327 @@ +{ + "schema_version": "1.0", + "report_kind": "step_reconstruction_distillation_result", + "extractor_version": "4.0", + "status": "all_input_documents_independently_reconstructed_and_all_geometry_documents_have_validated_named_edits", + "formal_promoted_experience_count": 12, + "formal_promoted_experience_ids": [ + "failure_repair.surface_boundary.prefer_exact_3d", + "failure_repair.periodic_torus.rectangular_uv_patch", + "failure_repair.surface_topology.bind_face_pcurves_and_group_shells", + "failure_repair.surface_topology.preserve_vertex_tolerance", + "edit_strategy.semantic_cylindrical_cut.enlarge_diameter", + "failure_repair.semantic_edit.boundary_strategy_fallback", + "edit_strategy.semantic_edit.preserve_validated_geometry_strategy", + "edit_strategy.semantic_parameter.search_validated_safe_range", + "failure_repair.semantic_edit.merge_duplicate_cylindrical_targets", + "edit_strategy.semantic_parameter.source_independent_overall_scale", + "edit_strategy.semantic_parameter.primary_axis_affine_dimensions", + "edit_strategy.semantic_parameter.canonical_directional_envelope" + ], + "corpus": { + "input_step_count": 1000, + "content_unique_step_count": 1000, + "single_solid_accepted_count": 975, + "rejected_count": 0, + "empty_document_count": 1, + "open_shell_document_count": 3, + "multi_solid_document_count": 21 + }, + "semantic_candidate_experience": { + "total_count": 26, + "promoted_count": 8, + "router_consumable": false, + "by_kind": { + "constraint": 4, + "dimensionless_distribution": 4, + "edit_strategy": 2, + "parameter_naming": 4, + "reconstruction_grammar": 9 + }, + "promoted_ids": [ + "edit_strategy.semantic_cylindrical_cut.enlarge_diameter", + "failure_repair.semantic_edit.boundary_strategy_fallback", + "edit_strategy.semantic_edit.preserve_validated_geometry_strategy", + "edit_strategy.semantic_parameter.search_validated_safe_range", + "failure_repair.semantic_edit.merge_duplicate_cylindrical_targets", + "edit_strategy.semantic_parameter.source_independent_overall_scale", + "edit_strategy.semantic_parameter.primary_axis_affine_dimensions", + "edit_strategy.semantic_parameter.canonical_directional_envelope" + ] + }, + "geometry_reconstruction_experience": { + "promoted_count": 4, + "router_consumable": false, + "surfaceir_consumable": true, + "promoted_ids": [ + "failure_repair.surface_boundary.prefer_exact_3d", + "failure_repair.periodic_torus.rectangular_uv_patch", + "failure_repair.surface_topology.bind_face_pcurves_and_group_shells", + "failure_repair.surface_topology.preserve_vertex_tolerance" + ], + "note": "These methods have positive held-out geometry A/B evidence. They do not count as proof of semantic parameter-edit capability." + }, + "semantic_parameter_results": [ + { + "canonical_parameter": "plate_thickness", + "support": 240, + "conditional_confidence": 1.0, + "state": "awaiting_positive_heldout_ab" + }, + { + "canonical_parameter": "hole_count", + "support": 396, + "conditional_confidence": 0.673469, + "state": "requires_internal_feature_polarity_validation" + }, + { + "canonical_parameter": "hole_diameter", + "support": 396, + "conditional_confidence": 0.673469, + "state": "requires_internal_feature_polarity_validation" + }, + { + "canonical_parameter": "bolt_circle_diameter", + "support": 121, + "conditional_confidence": 0.456604, + "state": "awaiting_positive_heldout_ab" + } + ], + "legacy_ab_warning": { + "completed_pair_count": 15, + "planned_pair_count": 50, + "baseline_similarity_mean": 69.11, + "experience_similarity_mean": 66.64, + "score_delta": -2.47, + "accepted_as_positive_validation": false + }, + "promotion_gate": { + "minimum_independent_cases_per_method": 3, + "minimum_heldout_ab_pairs": 10, + "edit_contract_pass_rate_required": 1.0, + "baseline_score_delta_must_be_positive": true, + "evidence_support_alone_can_promote": false + }, + "execution_validated_trajectory_pilot": { + "scope": "low_complexity_initial_debugging_subset_not_full_corpus", + "attempted_case_count": 6, + "replay_validated_case_count": 3, + "quarantined_case_count": 3, + "derived_candidate_method_count": 1, + "officially_promoted_method_count": 0, + "candidate_method": "simple cylinder classification, placement, and semantic parameter naming", + "heldout_ab": { + "pair_count": 2, + "baseline_replay_validated_count": 1, + "experience_replay_validated_count": 1, + "baseline_pass_rate": 0.5, + "experience_pass_rate": 0.5, + "pass_rate_delta": 0.0, + "positive": false, + "promotion_eligible": false + }, + "conclusion": "The new loop can produce independently rebuildable editable trajectories, but no capability improvement has yet been demonstrated. The candidate remains unavailable to cad-router." + }, + "designir_3_surface_program": { + "input_step_count": 1000, + "instance_program_count": 1000, + "extraction_failure_count": 0, + "empty_geometry_document_count": 1, + "open_shell_document_count": 3, + "instance_program_bytes": 168346362, + "supported_surface_vocabulary": [ + "plane", + "cylinder", + "cone", + "sphere", + "torus", + "bspline" + ], + "supported_curve_vocabulary": [ + "line", + "circle", + "ellipse", + "bspline", + "degenerate" + ], + "initial_rebuild_pilot": { + "case_count": 1000, + "validated_nonempty_count": 896, + "empty_geometry_count": 4, + "failed_count": 100 + }, + "heldout_boundary_strategy_ab": { + "pair_count": 100, + "baseline_geometry_pass_count": 17, + "treatment_geometry_pass_count": 95, + "improved_pair_count": 78, + "regressed_pair_count": 0, + "pass_rate_delta": 0.78 + }, + "promoted_experience_ids": [ + "failure_repair.surface_boundary.prefer_exact_3d", + "failure_repair.periodic_torus.rectangular_uv_patch", + "failure_repair.surface_topology.bind_face_pcurves_and_group_shells" + ], + "post_experience_full_corpus_validation": { + "case_count": 1000, + "validated_nonempty_count": 970, + "empty_geometry_count": 4, + "failed_count": 26, + "improved_count": 74, + "regressed_count": 0 + }, + "measured_geometry_bearing_pass_rate": 0.973896, + "pcurve_shell_strategy_ab": { + "pair_count": 1000, + "baseline_geometry_pass_count": 970, + "pcurve_primary_geometry_pass_count": 998, + "validated_fallback_geometry_pass_count": 1000, + "improved_pair_count": 30, + "regressed_pair_count": 0, + "pass_rate_delta": 0.03 + }, + "vertex_tolerance_strategy_ab": { + "pair_count": 1000, + "baseline_geometry_pass_count": 996, + "treatment_geometry_pass_count": 1000, + "improved_pair_count": 4, + "regressed_pair_count": 0, + "pass_rate_delta": 0.004 + }, + "current_full_corpus_validation": { + "case_count": 1000, + "validated_geometry_document_count": 999, + "validated_empty_document_count": 1, + "open_shell_document_count": 3, + "unresolved_document_count": 0, + "all_document_pass_rate": 1.0, + "selected_strategy_counts": { + "exact_3d_pcurve": 1000 + }, + "boolean_fallback_surface_sample_case_count": 2, + "maximum_accepted_bidirectional_surface_distance_mm": 0.0000647066 + }, + "semantic_edit_contract": { + "promoted_experience_count": 8, + "status": "positive_local_cut_primary_axis_dimension_and_universal_scale_edits_demonstrated", + "semantic_candidate_case_count": 942, + "semantic_parameter_instance_count": 5136, + "semantic_feature_instance_count": 2502, + "heldout_case_count": 50, + "heldout_applicable_case_count": 41, + "heldout_accepted_case_count": 41, + "heldout_edit_pass_rate": 1.0, + "primary_boundary_strategy_pass_count": 35, + "fallback_rescue_count": 6, + "full_corpus_validation": { + "case_count": 1000, + "baseline_geometry_not_validated_count": 30, + "no_executable_semantic_parameter_count": 155, + "applicable_edit_case_count": 815, + "accepted_edit_case_count": 795, + "rejected_edit_case_count": 20, + "applicable_edit_pass_rate": 0.97546, + "execution_failure_count": 19, + "non_target_guard_rejection_count": 1, + "post_observer_repair": { + "accepted_edit_case_count": 800, + "remaining_failed_case_count": 15, + "applicable_edit_pass_rate": 0.981595, + "rescued_case_count": 5, + "regressed_case_count": 0 + }, + "post_geometry_strategy_revalidation": { + "baseline_geometry_not_validated_count": 0, + "no_executable_semantic_parameter_count": 157, + "applicable_edit_case_count": 843, + "accepted_edit_case_count": 826, + "remaining_failed_case_count": 17, + "applicable_edit_pass_rate": 0.979834, + "newly_accepted_case_count": 26, + "regressed_case_count": 0 + }, + "post_adaptive_range_and_duplicate_target_repair": { + "applicable_edit_case_count": 843, + "accepted_edit_case_count": 837, + "remaining_failed_case_count": 6, + "applicable_edit_pass_rate": 0.992883, + "validated_positive_factors": [ + 1.02, + 1.05, + 1.1 + ], + "newly_accepted_case_count": 11, + "regressed_case_count": 0, + "duplicate_target_heldout_ab": { + "pair_count": 11, + "baseline_pass_count": 9, + "treatment_pass_count": 11, + "regressed_pair_count": 0 + } + }, + "post_source_independent_overall_scale": { + "geometry_bearing_case_count": 996, + "accepted_scale_edit_case_count": 996, + "rejected_scale_edit_case_count": 0, + "scale_edit_pass_rate": 1.0, + "baseline_any_validated_edit_case_count": 842, + "treatment_any_validated_edit_case_count": 996, + "newly_editable_case_count": 154, + "regressed_case_count": 0, + "validated_factor": 1.1, + "note": "overall_scale is a source-independent minimum editability fallback and does not claim recovery of feature-specific thickness, spacing or count semantics" + }, + "post_primary_axis_affine_dimensions": { + "applicable_case_count": 103, + "outer_diameter_accepted_count": 103, + "outer_diameter_rejected_count": 0, + "body_length_accepted_count": 70, + "body_length_rejected_count": 0, + "plate_thickness_accepted_count": 33, + "plate_thickness_rejected_count": 0, + "baseline_any_feature_specific_edit_case_count": 842, + "treatment_any_feature_specific_edit_case_count": 942, + "newly_feature_editable_case_count": 100, + "remaining_scale_only_case_count": 54, + "regressed_case_count": 0, + "validated_factor": 1.1, + "note": "axis-affine dimensions are canonical whole-body edits about an inferred primary cylinder axis; they do not claim recovery of the teacher feature tree" + }, + "post_canonical_directional_dimensions": { + "applicable_case_count": 56, + "overall_size_x_accepted_count": 56, + "overall_size_y_accepted_count": 56, + "overall_size_z_accepted_count": 55, + "rejected_parameter_count": 0, + "baseline_any_validated_named_edit_case_count": 942, + "treatment_any_validated_named_edit_case_count": 996, + "newly_named_editable_case_count": 54, + "regressed_case_count": 0, + "validated_factor": 1.1, + "note": "overall_size_x/y/z are canonical envelope dimensions in the DesignIR coordinate system, not recovered source sketch dimensions" + }, + "final_document_and_edit_coverage": { + "input_document_count": 1000, + "independently_rebuilt_document_count": 1000, + "geometry_present_document_count": 999, + "true_empty_document_count": 1, + "geometry_documents_with_at_least_one_validated_named_parameter": 999, + "validated_named_parameter_instance_count": 1213, + "geometry_document_named_edit_coverage": 1.0, + "anti_cheat_match_count": 0, + "designir_schema_valid_count": 1000, + "designir_schema_invalid_count": 0 + } + }, + "required_next_evidence": [ + "negative diameter edits", + "hole spacing and pitch diameter edits", + "plate thickness edits", + "additional non-cylindrical feature families", + "full-corpus edit acceptance" + ] + }, + "remaining_primary_failure_clusters": [] + } +} diff --git a/designir-pipeline/output/library.json b/designir-pipeline/output/library.json index fe33759..cd453da 100644 --- a/designir-pipeline/output/library.json +++ b/designir-pipeline/output/library.json @@ -2,7 +2,7 @@ "schema_version": "2.0", "library_kind": "generalized_cad_experience", "induction_mode": "replay_validated_designir_with_deterministic_publication", - "status": "empty", + "status": "active_for_surfaceir", "policy": { "instance_parameters_allowed": false, "absolute_coordinates_allowed": false, @@ -10,20 +10,391 @@ "teacher_geometry_allowed": false, "llm_semantic_review_required": true, "draft_evidence_verified": true, - "router_consumable": true, + "positive_heldout_ab_required": true, + "router_consumable": false, + "surfaceir_consumable": true, "minimum_support": 3 }, "corpus_summary": { - "accepted_case_count": 0, + "accepted_case_count": 1000, "duplicate_case_count": 0, "rejected_case_count": 0, - "family_count": 0 + "family_count": 11 }, "experience_summary": { - "llm_proposal_count": 0, - "promoted_experience_count": 0, - "candidate_experience_count": 0 + "llm_proposal_count": 13, + "promoted_experience_count": 12, + "candidate_experience_count": 1 }, - "experiences": [], - "candidate_experiences": [] + "experiences": [ + { + "id": "failure_repair.surface_boundary.prefer_exact_3d", + "kind": "failure_repair", + "scope": [ + "surfaceir_reconstruction" + ], + "when": { + "features": [ + "trimmed_surface", + "boundary_curve_network" + ] + }, + "guidance": "Build trimmed-face wires from their exact three-dimensional boundary curves. Retain parametric-space curves for periodic branch and seam repair instead of selecting the first stored branch for every occurrence.", + "check": "Require the rebuilt solid, face, edge and vertex counts to match before accepting the boundary strategy.", + "support": 100, + "confidence": 1.0, + "validation": { + "kind": "paired_heldout_ab", + "heldout_pair_count": 100, + "baseline_geometry_pass_count": 17, + "treatment_geometry_pass_count": 95, + "improved_pair_count": 78, + "regressed_pair_count": 0, + "pass_rate_delta": 0.78 + }, + "consumer_policy": "enabled_by_default_in_surfaceir_pipeline" + }, + { + "id": "failure_repair.periodic_torus.rectangular_uv_patch", + "kind": "failure_repair", + "scope": [ + "surfaceir_reconstruction" + ], + "when": { + "features": [ + "toroidal_surface", + "single_outer_wire", + "isoparametric_line_boundary" + ] + }, + "guidance": "Rebuild a singly bounded toroidal face whose parametric boundary consists only of isoparametric lines directly from its stored parameter bounds. Preserve curve-wire reconstruction for non-rectangular toroidal trims.", + "check": "Require the toroidal patch topology, volume, center of mass and bounding box to match the teacher before accepting the repair.", + "support": 999, + "confidence": 1.0, + "validation": { + "kind": "paired_heldout_ab", + "heldout_pair_count": 999, + "baseline_geometry_pass_count": 900, + "treatment_geometry_pass_count": 963, + "improved_pair_count": 63, + "regressed_pair_count": 0, + "pass_rate_delta": 0.063063 + }, + "consumer_policy": "enabled_by_default_in_surfaceir_pipeline" + }, + { + "id": "edit_strategy.semantic_cylindrical_cut.enlarge_diameter", + "kind": "edit_strategy", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "internal_cylindrical_face", + "canonical_hole_or_bore_axis", + "positive_diameter_increase" + ] + }, + "guidance": "Represent a positive hole or bore diameter edit as one deduplicated cylindrical cut per canonical axis and axial span. Rebuild the source-independent SurfaceIR first, then apply the declared cuts; keep every non-target semantic parameter unchanged.", + "check": "Require source independence, a valid solid count, the requested radius on every declared axis, decreasing volume, no added material, and unchanged non-target parameter values.", + "support": 41, + "confidence": 1.0, + "validation": { + "kind": "independent_heldout_capability_ab", + "heldout_pair_count": 41, + "baseline_annotation_only_edit_pass_count": 0, + "treatment_edit_pass_count": 41, + "improved_pair_count": 41, + "regressed_pair_count": 0, + "pass_rate_delta": 1.0 + }, + "consumer_policy": "enabled_for_positive_hole_and_bore_diameter_edits" + }, + { + "id": "failure_repair.semantic_edit.boundary_strategy_fallback", + "kind": "failure_repair", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "semantic_cylindrical_cut", + "invalid_or_missing_target_after_primary_rebuild" + ] + }, + "guidance": "Validate the preferred analytic UV boundary strategy first, then try exact three-dimensional boundaries and the remaining registered boundary strategies in isolation. Select the first result that preserves solid count, remains valid, realizes every target axis and changes volume in the declared direction.", + "check": "Record every attempted strategy and accept a fallback only when deterministic edit checks pass; never accept an empty shell or zero-solid result.", + "support": 41, + "confidence": 1.0, + "validation": { + "kind": "paired_heldout_ab", + "heldout_pair_count": 41, + "primary_strategy_edit_pass_count": 35, + "fallback_policy_edit_pass_count": 41, + "improved_pair_count": 6, + "regressed_pair_count": 0, + "pass_rate_delta": 0.146341 + }, + "consumer_policy": "enabled_by_default_for_semantic_edit_acceptance" + }, + { + "id": "failure_repair.surface_topology.bind_face_pcurves_and_group_shells", + "kind": "failure_repair", + "scope": [ + "surfaceir_reconstruction" + ], + "when": { + "features": [ + "shared_topology_edge", + "stored_face_pcurve", + "periodic_or_trimmed_surface" + ] + }, + "guidance": "Restore every shared edge from its exact three-dimensional curve and bind each stored parametric curve to the corresponding reconstructed face surface. Preserve both parametric branches for seam edges, then assemble all reconstructed shells under their declared DesignIR solid instead of emitting one solid per shell.", + "check": "Validate the pcurve-bound reconstruction against the teacher and retain the prior exact-boundary strategy for any regression. Require matching solid, face, edge and vertex counts plus volume, center, bounds and symmetric-difference acceptance before fixing the selected strategy in the source-independent DesignIR.", + "support": 996, + "confidence": 1.0, + "validation": { + "kind": "paired_full_corpus_ab_with_validated_fallback", + "heldout_pair_count": 1000, + "baseline_geometry_pass_count": 970, + "treatment_geometry_pass_count": 996, + "improved_pair_count": 26, + "regressed_pair_count": 0, + "pass_rate_delta": 0.026 + }, + "consumer_policy": "enabled_as_teacher_validated_surfaceir_strategy" + }, + { + "id": "failure_repair.surface_topology.preserve_vertex_tolerance", + "kind": "failure_repair", + "scope": [ + "surfaceir_reconstruction" + ], + "when": { + "features": [ + "shared_topology_vertex", + "edge_endpoint_near_curve_within_vertex_tolerance" + ] + }, + "guidance": "Preserve each DesignIR vertex tolerance independently when constructing shared edges. Update the edge tolerance on the edge itself and never overwrite a shared vertex tolerance with the tolerance of a later incident edge.", + "check": "Run the pcurve-bound strategy and the exact-boundary fallback as a paired teacher-side validation. Fix the selected strategy in DesignIR only when solid, face, edge and vertex counts, volume, center, bounds and symmetric difference all pass.", + "support": 1000, + "confidence": 1.0, + "validation": { + "kind": "paired_full_corpus_ab_with_validated_fallback", + "heldout_pair_count": 1000, + "baseline_geometry_pass_count": 996, + "treatment_geometry_pass_count": 1000, + "improved_pair_count": 4, + "regressed_pair_count": 0, + "pass_rate_delta": 0.004 + }, + "consumer_policy": "enabled_by_default_in_surfaceir_edge_construction" + }, + { + "id": "edit_strategy.semantic_edit.preserve_validated_geometry_strategy", + "kind": "edit_strategy", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "source_independent_designir", + "teacher_validated_boundary_strategy", + "declared_semantic_edit_operation" + ] + }, + "guidance": "Keep the boundary strategy already validated for the unedited DesignIR as the first semantic-edit reconstruction candidate. Then try the registered pcurve-bound, exact-boundary and analytic fallbacks; do not replace the validated strategy unconditionally with an older global default.", + "check": "Accept the edited model only when the requested target cylinders are observed, solid count and validity are preserved, volume changes in the declared direction, non-target parameters remain unchanged and the edited JSON contains no teacher reference.", + "support": 843, + "confidence": 1.0, + "validation": { + "kind": "paired_full_corpus_capability_ab", + "heldout_pair_count": 1000, + "baseline_edit_pass_count": 800, + "treatment_edit_pass_count": 826, + "improved_pair_count": 26, + "regressed_pair_count": 0, + "pass_rate_delta": 0.026 + }, + "consumer_policy": "enabled_by_default_for_semantic_edit_reconstruction" + }, + { + "id": "edit_strategy.semantic_parameter.search_validated_safe_range", + "kind": "edit_strategy", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "executable_semantic_parameter", + "requested_edit_breaks_topology_or_validity" + ] + }, + "guidance": "Treat editability as a validated interval rather than a single unrestricted value. If the requested positive perturbation fails, test descending meaningful factors and retain the largest value that passes the complete edit contract; record that bound instead of declaring the parameter globally non-editable.", + "check": "Every candidate bound must generate an edited STEP and pass target observation, solid validity and count, declared volume direction, non-target stability and source-independence checks.", + "support": 843, + "confidence": 1.0, + "validation": { + "kind": "paired_full_corpus_capability_ab", + "heldout_pair_count": 1000, + "baseline_edit_pass_count": 826, + "treatment_edit_pass_count": 835, + "improved_pair_count": 9, + "regressed_pair_count": 0, + "pass_rate_delta": 0.009 + }, + "consumer_policy": "enabled_for_semantic_edit_range_validation" + }, + { + "id": "failure_repair.semantic_edit.merge_duplicate_cylindrical_targets", + "kind": "failure_repair", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "multiple_target_faces", + "coincident_cylinder_axis_anchor_and_axial_interval" + ] + }, + "guidance": "Merge target faces that describe the same physical cylindrical cut by comparing canonical axis, spatial anchor and absolute axial interval within model tolerance. Execute one cut, retain all source face identifiers for traceability, and validate one physical target instead of counting duplicate B-Rep faces as separate holes.", + "check": "The merged operation must preserve the requested radius, solid count and validity while passing the same source-independence and non-target guards as an unmerged edit.", + "support": 11, + "confidence": 1.0, + "validation": { + "kind": "paired_failure_cluster_ab", + "heldout_pair_count": 11, + "baseline_edit_pass_count": 9, + "treatment_edit_pass_count": 11, + "improved_pair_count": 2, + "regressed_pair_count": 0, + "pass_rate_delta": 0.181818 + }, + "consumer_policy": "enabled_by_default_for_cylindrical_edit_binding" + }, + { + "id": "edit_strategy.semantic_parameter.source_independent_overall_scale", + "kind": "edit_strategy", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "source_independent_designir", + "geometry_present", + "semantic_feature_binding_may_be_unavailable" + ] + }, + "guidance": "Expose a dimensionless overall_scale parameter about a deterministic datum derived from the independent SurfaceIR bounds. Compile it as a uniform transform of the rebuilt shape, never as a teacher STEP or embedded B-Rep dependency. Treat this as a minimum editability fallback, not as evidence that feature-specific parameters such as thickness, spacing or count were recovered.", + "check": "Require valid solids, preserved solid/face/edge counts, volume scaling by the factor cubed, transformed center and bounds within tolerance, unchanged non-target parameter values and a source-independent edited JSON. Record vertex-count normalization after STEP serialization as a diagnostic rather than rejecting otherwise equivalent topology.", + "support": 996, + "confidence": 1.0, + "validation": { + "kind": "paired_full_corpus_capability_ab", + "heldout_pair_count": 996, + "baseline_any_validated_edit_count": 842, + "treatment_any_validated_edit_count": 996, + "improved_pair_count": 154, + "regressed_pair_count": 0, + "pass_rate_delta": 0.154618, + "standalone_scale_acceptance_count": 996, + "standalone_scale_rejection_count": 0 + }, + "consumer_policy": "enabled_as_fallback_for_geometry_bearing_surfaceir" + }, + { + "id": "edit_strategy.semantic_parameter.primary_axis_affine_dimensions", + "kind": "edit_strategy", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "primary_external_cylinder", + "source_independent_designir", + "no_validated_feature_specific_edit" + ] + }, + "guidance": "Bind outer_diameter to a radial affinity about the inferred primary cylinder axis, and bind body_length or plate_thickness to an axial affinity about the target cylinder midpoint. Preserve the orthogonal dimension, expose the operation explicitly in DesignIR, and treat it as a canonical whole-body dimension edit rather than recovered source history.", + "check": "Rebuild without the teacher, apply the declared axis-affine transform, write and reread STEP, independently remeasure the target face boundary along or around the declared axis, and require the requested dimension, valid solids, stable solid/face/edge counts, unchanged non-target parameter values, and normalized expected volume, center and bounds.", + "support": 103, + "confidence": 1.0, + "validation": { + "kind": "paired_applicable_corpus_capability_ab", + "heldout_outer_diameter_pair_count": 103, + "heldout_outer_diameter_pass_count": 103, + "heldout_body_length_pair_count": 70, + "heldout_body_length_pass_count": 70, + "heldout_plate_thickness_pair_count": 33, + "heldout_plate_thickness_pass_count": 33, + "baseline_any_feature_specific_edit_count": 842, + "treatment_any_feature_specific_edit_count": 942, + "improved_case_count": 100, + "regressed_case_count": 0, + "pass_rate_delta": 0.100402 + }, + "consumer_policy": "enabled_for_primary_cylindrical_parts_without_a_validated_local_feature_edit" + }, + { + "id": "edit_strategy.semantic_parameter.canonical_directional_envelope", + "kind": "edit_strategy", + "scope": [ + "surfaceir_semantic_editing" + ], + "when": { + "features": [ + "geometry_present", + "no_validated_named_edit", + "nonzero_global_axis_extent" + ] + }, + "guidance": "Expose nonzero global-axis bounds as overall_size_x, overall_size_y and overall_size_z about a deterministic bounds-center datum. Compile one requested dimension as an axis affinity that leaves the other two global coordinates unchanged. Label these as canonical envelope dimensions, never as recovered sketch constraints or local feature intent.", + "check": "Write and reread the independently rebuilt STEP, remeasure the complete vertex bounds along the requested axis, require the requested extent, valid geometry, stable solid/face/edge counts, unchanged non-target parameter values, and normalized expected volume, center and bounds.", + "support": 56, + "confidence": 1.0, + "validation": { + "kind": "paired_applicable_corpus_capability_ab", + "heldout_x_pair_count": 56, + "heldout_x_pass_count": 56, + "heldout_y_pair_count": 56, + "heldout_y_pass_count": 56, + "heldout_z_pair_count": 55, + "heldout_z_pass_count": 55, + "baseline_any_validated_named_edit_count": 942, + "treatment_any_validated_named_edit_count": 996, + "improved_case_count": 54, + "regressed_case_count": 0, + "pass_rate_delta": 0.054217 + }, + "consumer_policy": "enabled_for_geometry_without_a_more_specific_validated_parameter" + } + ], + "candidate_experiences": [ + { + "id": "failure_repair.surface_topology.preserve_free_shell_documents", + "kind": "failure_repair", + "scope": [ + "surfaceir_reconstruction" + ], + "when": { + "features": [ + "step_document_without_solid", + "top_level_open_shell" + ] + }, + "support": 3, + "required_heldout_pair_count": 10, + "completed_heldout_pair_count": 3, + "remaining_heldout_pair_count": 7, + "promotion_state": "candidate", + "measured_result": { + "baseline_independent_rebuild_count": 0, + "treatment_independent_rebuild_count": 3, + "regressed_pair_count": 0 + }, + "consumer_policy": "visible_for_review_but_not_promoted_as_generalized_experience" + } + ] } diff --git a/designir-pipeline/scripts/designir_pipeline.py b/designir-pipeline/scripts/designir_pipeline.py index 6f15d23..84bd613 100644 --- a/designir-pipeline/scripts/designir_pipeline.py +++ b/designir-pipeline/scripts/designir_pipeline.py @@ -1,11 +1,17 @@ #!/usr/bin/env python3 -"""Compile and validate DesignIR 2.0 without depending on teacher geometry.""" +"""Compile and validate semantic DesignIR 3.0 without teacher geometry. + +Legacy DesignIR 2.0 documents remain accepted and can be migrated explicitly. +Surface-parametric DesignIR 3.0 documents are handled by surfaceir_pipeline.py. +""" from __future__ import annotations import argparse import ast import copy +import hashlib +import importlib.util import json import math import re @@ -15,11 +21,23 @@ import tempfile from pathlib import Path from typing import Any -from build123d import Align, Box, CenterOf, Cylinder, Part, Pos, export_step, import_step +from build123d import ( + Align, + Box, + CenterOf, + Cylinder, + Part, + Plane, + Pos, + export_step, + import_step, +) -SCHEMA_VERSION = "2.0" -DESIGNIR_KIND = "executable_parametric_design" +SCHEMA_VERSION = "3.0" +DESIGNIR_KIND = "independent_parametric_cad" +LEGACY_SCHEMA_VERSION = "2.0" +LEGACY_DESIGNIR_KIND = "executable_parametric_design" SUPPORTED_OPERATIONS = { "extrude_circle", "extrude_rectangle", @@ -138,14 +156,14 @@ def _vector3(value: Any, label: str) -> None: raise DesignIRError(f"{label} must be a three-number vector") -def validate_designir(payload: dict[str, Any]) -> dict[str, Any]: +def _validate_compiler_payload(payload: dict[str, Any]) -> dict[str, Any]: missing = sorted(REQUIRED_TOP_LEVEL - set(payload)) if missing: raise DesignIRError("Missing DesignIR fields: " + ", ".join(missing)) - if payload["schema_version"] != SCHEMA_VERSION: + if payload["schema_version"] != LEGACY_SCHEMA_VERSION: raise DesignIRError("DesignIR schema_version must be 2.0") - if payload["designir_kind"] != DESIGNIR_KIND: - raise DesignIRError(f"designir_kind must be {DESIGNIR_KIND}") + if payload["designir_kind"] != LEGACY_DESIGNIR_KIND: + raise DesignIRError(f"designir_kind must be {LEGACY_DESIGNIR_KIND}") if payload["units"] != "mm": raise DesignIRError("Only millimetres are supported") if payload["reconstruction_status"] not in { @@ -196,14 +214,150 @@ def validate_designir(payload: dict[str, Any]) -> dict[str, Any]: unknown = sorted(set(editable) - set(parameters)) if unknown: raise DesignIRError("Unknown editable parameters: " + ", ".join(unknown)) + disconnected = sorted( + name + for name in editable + if not parameters[name].get("editable") + or _parameter_reference_count(features, name) == 0 + ) + if disconnected: + raise DesignIRError( + "Editable parameters must drive at least one feature: " + + ", ".join(disconnected) + ) for test in payload["validation_contract"].get("perturbations", []): if test.get("parameter") not in parameters: raise DesignIRError( f"Perturbation references unknown parameter {test.get('parameter')}" ) + if not str(test.get("expected_change") or "").strip(): + raise DesignIRError("Every perturbation requires expected_change") return payload +def migrate_designir_2_to_3(payload: dict[str, Any]) -> dict[str, Any]: + """Convert the legacy flat semantic contract into unified DesignIR 3.0.""" + legacy = _validate_compiler_payload(copy.deepcopy(payload)) + editable = list(legacy["edit_interface"].get("editable_parameters", [])) + validation = legacy["validation_contract"] + semantic_layer = { + "reconstruction_status": legacy["reconstruction_status"], + "coordinate_system": legacy["coordinate_system"], + "datums": legacy["datums"], + "parameters": legacy["parameters"], + "expressions": legacy["expressions"], + "sketches": legacy["sketches"], + "constraints": legacy["constraints"], + "features": legacy["features"], + "patterns": legacy["patterns"], + "attachments": legacy["attachments"], + "construction_stages": legacy["construction_stages"], + "missing_capabilities": legacy.get("missing_capabilities", []), + "applied_experience_ids": legacy.get("applied_experience_ids", []), + } + return { + "schema_version": SCHEMA_VERSION, + "designir_kind": DESIGNIR_KIND, + "model_id": legacy["model_id"], + "family": legacy["family"], + "units": legacy["units"], + "document_status": "geometry_present", + "reconstruction_mode": "fully_semantic_parametric", + "authoring_mode": "semantic_feature_program", + "backend_hint": legacy.get("backend_hint", "build123d"), + "semantic_layer": semantic_layer, + "edit_interface": { + "semantic_parameters": editable, + "surface_parameter_groups": [], + "modification_levels": ["semantic_feature"], + "preserved_interfaces": legacy["edit_interface"].get( + "preserved_interfaces", [] + ), + }, + "validation_contract": { + "source_independence": True, + "geometry_checks": validation.get( + "geometry_checks", + ["solid_count", "bounding_box", "volume", "center_of_mass"], + ), + "edit_checks": validation.get( + "edit_checks", + ["parameter_perturbation", "constraint_preservation"], + ), + "invariants": validation.get("invariants", []), + "perturbations": validation.get("perturbations", []), + "thresholds": validation.get("thresholds", {}), + }, + } + + +def compiler_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Return the stable flat representation consumed by both CAD backends.""" + version = payload.get("schema_version") + if version == LEGACY_SCHEMA_VERSION: + return _validate_compiler_payload(copy.deepcopy(payload)) + if version != SCHEMA_VERSION or payload.get("designir_kind") != DESIGNIR_KIND: + raise DesignIRError( + "DesignIR must be legacy 2.0 or independent_parametric_cad 3.0" + ) + mode = payload.get("reconstruction_mode") + if mode == "surface_parametric": + raise DesignIRError( + "Surface-parametric DesignIR must be rebuilt with surfaceir_pipeline.py" + ) + if mode not in { + "fully_semantic_parametric", + "hybrid_semantic_surface_parametric", + }: + raise DesignIRError(f"Unsupported DesignIR 3.0 reconstruction_mode: {mode}") + if payload.get("units") != "mm": + raise DesignIRError("Only millimetres are supported") + forbidden = find_forbidden(payload.get("semantic_layer", {})) + if forbidden: + raise DesignIRError(f"Teacher geometry dependency is forbidden at {forbidden}") + semantic = payload.get("semantic_layer") + if not isinstance(semantic, dict): + raise DesignIRError("semantic_layer must be an object") + edit_interface = payload.get("edit_interface", {}) + validation = payload.get("validation_contract", {}) + flat = { + "schema_version": LEGACY_SCHEMA_VERSION, + "designir_kind": LEGACY_DESIGNIR_KIND, + "model_id": payload.get("model_id"), + "family": payload.get("family", "general_mechanical_part"), + "units": payload.get("units"), + "reconstruction_status": semantic.get("reconstruction_status", "ready"), + "coordinate_system": semantic.get("coordinate_system"), + "datums": semantic.get("datums", {}), + "parameters": semantic.get("parameters", {}), + "expressions": semantic.get("expressions", {}), + "sketches": semantic.get("sketches", []), + "constraints": semantic.get("constraints", []), + "features": semantic.get("features", []), + "patterns": semantic.get("patterns", []), + "attachments": semantic.get("attachments", []), + "construction_stages": semantic.get("construction_stages", []), + "edit_interface": { + "editable_parameters": edit_interface.get("semantic_parameters", []), + "preserved_interfaces": edit_interface.get("preserved_interfaces", []), + }, + "validation_contract": { + "invariants": validation.get("invariants", []), + "perturbations": validation.get("perturbations", []), + "thresholds": validation.get("thresholds", {}), + }, + "backend_hint": payload.get("backend_hint", "build123d"), + "missing_capabilities": semantic.get("missing_capabilities", []), + "applied_experience_ids": semantic.get("applied_experience_ids", []), + } + return _validate_compiler_payload(flat) + + +def validate_designir(payload: dict[str, Any]) -> dict[str, Any]: + """Validate either supported version and return its compiler representation.""" + return compiler_payload(payload) + + def _safe_expression(expression: str, values: dict[str, float]) -> float: try: tree = ast.parse(expression, mode="eval") @@ -261,13 +415,69 @@ def resolve_value(value: Any, values: dict[str, float], label: str) -> float: raise DesignIRError(f"{label} must be a number, parameter, or expression") -def _axis_is_z(feature: dict[str, Any]) -> None: - if feature.get("axis", "primary_axis") not in {"z", "primary_axis"}: - raise DesignIRError("The initial compiler supports only the primary Z axis") +def resolve_center( + value: Any, values: dict[str, float], label: str +) -> list[float]: + if not isinstance(value, list) or len(value) != 3: + raise DesignIRError(f"{label} must contain three values") + return [ + resolve_value(component, values, f"{label}[{index}]") + for index, component in enumerate(value) + ] + + +def feature_axis(payload: dict[str, Any], feature: dict[str, Any]) -> list[float]: + reference = feature.get("axis", "primary_axis") + if isinstance(reference, list): + axis = reference + elif reference in {"x", "y", "z"}: + axis = { + "x": [1.0, 0.0, 0.0], + "y": [0.0, 1.0, 0.0], + "z": [0.0, 0.0, 1.0], + }[reference] + else: + datum = payload.get("datums", {}).get(str(reference), {}) + axis = datum.get("axis") + if isinstance(axis, str) and axis in {"x", "y", "z"}: + axis = { + "x": [1.0, 0.0, 0.0], + "y": [0.0, 1.0, 0.0], + "z": [0.0, 0.0, 1.0], + }[axis] + _vector3(axis, f"{feature.get('id', 'feature')}.axis") + length = math.sqrt(sum(float(value) ** 2 for value in axis)) + if length <= 1e-12: + raise DesignIRError(f"{feature.get('id', 'feature')}.axis is zero") + return [float(value) / length for value in axis] + + +def _axis_is_z(axis: list[float]) -> bool: + return abs(axis[0]) <= 1e-9 and abs(axis[1]) <= 1e-9 and axis[2] > 0.0 + + +def feature_base_center( + feature: dict[str, Any], + center: list[float], + axis: list[float], + height: float, +) -> list[float]: + """Resolve the primitive's bottom-face center from an explicit anchor.""" + anchor = str(feature.get("anchor", "base_center")) + if anchor == "base_center": + return center + if anchor == "geometric_center": + return [ + float(center[index]) - float(axis[index]) * height / 2.0 + for index in range(3) + ] + raise DesignIRError( + f"{feature.get('id', 'feature')}.anchor must be base_center or geometric_center" + ) def build_shape(payload: dict[str, Any]) -> Part: - validate_designir(payload) + payload = validate_designir(payload) if payload["reconstruction_status"] != "ready": missing = ", ".join(payload.get("missing_capabilities", [])) or "unspecified" raise DesignIRError( @@ -278,32 +488,39 @@ def build_shape(payload: dict[str, Any]) -> Part: for feature in payload["features"]: operation = feature["operation"] - _axis_is_z(feature) + axis = feature_axis(payload, feature) if operation in {"extrude_circle", "add_cylinder"}: radius = resolve_value(feature["radius"], values, f"{feature['id']}.radius") height = resolve_value(feature["height"], values, f"{feature['id']}.height") - center = feature.get("center", [0, 0, 0]) - _vector3(center, f"{feature['id']}.center") - tool = Pos(*map(float, center)) * Cylinder( - radius, - height, - align=(Align.CENTER, Align.CENTER, Align.MIN), + center = resolve_center( + feature.get("center", [0, 0, 0]), + values, + f"{feature['id']}.center", + ) + base_center = feature_base_center(feature, center, axis, height) + tool = Plane(origin=base_center, z_dir=axis) * Cylinder( + radius, height, align=(Align.CENTER, Align.CENTER, Align.MIN) ) shape = tool if shape is None else shape + tool elif operation == "extrude_rectangle": width = resolve_value(feature["width"], values, f"{feature['id']}.width") depth = resolve_value(feature["depth"], values, f"{feature['id']}.depth") height = resolve_value(feature["height"], values, f"{feature['id']}.height") - center = feature.get("center", [0, 0, 0]) - _vector3(center, f"{feature['id']}.center") - tool = Pos(*map(float, center)) * Box( - width, - depth, - height, - align=(Align.CENTER, Align.CENTER, Align.MIN), + center = resolve_center( + feature.get("center", [0, 0, 0]), + values, + f"{feature['id']}.center", + ) + base_center = feature_base_center(feature, center, axis, height) + tool = Plane(origin=base_center, z_dir=axis) * Box( + width, depth, height, align=(Align.CENTER, Align.CENTER, Align.MIN) ) shape = tool if shape is None else shape + tool elif operation in {"through_hole", "polar_hole_pattern"}: + if not _axis_is_z(axis): + raise DesignIRError( + f"{feature['id']} hole cutting currently requires positive Z axis" + ) if shape is None: raise DesignIRError(f"{feature['id']} requires a host solid") diameter = resolve_value( @@ -314,8 +531,11 @@ def build_shape(payload: dict[str, Any]) -> Part: cutter_z = float(bounds.min.Z) - 1.0 centers: list[tuple[float, float]] if operation == "through_hole": - center = feature.get("center", [0, 0, 0]) - _vector3(center, f"{feature['id']}.center") + center = resolve_center( + feature.get("center", [0, 0, 0]), + values, + f"{feature['id']}.center", + ) centers = [(float(center[0]), float(center[1]))] else: count = int( @@ -352,7 +572,7 @@ def build_shape(payload: dict[str, Any]) -> Part: def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: - validate_designir(payload) + payload = validate_designir(payload) if payload["reconstruction_status"] != "ready": missing = ", ".join(payload.get("missing_capabilities", [])) or "unspecified" raise DesignIRError( @@ -373,7 +593,7 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: with scad.GraphSession() as session: for feature in payload["features"]: operation = feature["operation"] - _axis_is_z(feature) + axis = feature_axis(payload, feature) if operation in {"extrude_circle", "add_cylinder"}: radius = resolve_value( feature["radius"], values, f"{feature['id']}.radius" @@ -381,14 +601,17 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: height = resolve_value( feature["height"], values, f"{feature['id']}.height" ) - center = feature.get("center", [0, 0, 0]) - _vector3(center, f"{feature['id']}.center") - point = tuple(map(float, center)) + center = resolve_center( + feature.get("center", [0, 0, 0]), + values, + f"{feature['id']}.center", + ) + point = tuple(feature_base_center(feature, center, axis, height)) tool = scad.make_cylinder_rsolid( radius=radius, height=height, bottom_face_center=point, - axis=(0.0, 0.0, 1.0), + axis=tuple(axis), ) shape = ( tool @@ -402,6 +625,10 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: else max(z_max, point[2] + height) ) elif operation == "extrude_rectangle": + if not _axis_is_z(axis): + raise DesignIRError( + f"{feature['id']} SimpleCAD box currently requires positive Z axis" + ) width = resolve_value( feature["width"], values, f"{feature['id']}.width" ) @@ -411,9 +638,12 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: height = resolve_value( feature["height"], values, f"{feature['id']}.height" ) - center = feature.get("center", [0, 0, 0]) - _vector3(center, f"{feature['id']}.center") - point = tuple(map(float, center)) + center = resolve_center( + feature.get("center", [0, 0, 0]), + values, + f"{feature['id']}.center", + ) + point = tuple(feature_base_center(feature, center, axis, height)) tool = scad.make_box_rsolid( width=width, height=depth, @@ -432,14 +662,21 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: else max(z_max, point[2] + height) ) elif operation in {"through_hole", "polar_hole_pattern"}: + if not _axis_is_z(axis): + raise DesignIRError( + f"{feature['id']} SimpleCAD hole currently requires positive Z axis" + ) if shape is None or z_min is None or z_max is None: raise DesignIRError(f"{feature['id']} requires a host solid") diameter = resolve_value( feature["diameter"], values, f"{feature['id']}.diameter" ) if operation == "through_hole": - center = feature.get("center", [0, 0, 0]) - _vector3(center, f"{feature['id']}.center") + center = resolve_center( + feature.get("center", [0, 0, 0]), + values, + f"{feature['id']}.center", + ) centers = [(float(center[0]), float(center[1]))] else: count = int( @@ -532,10 +769,69 @@ def compile_designir( } +def materialize_hybrid_designir( + payload: dict[str, Any], generated_step: Path +) -> dict[str, Any]: + """Attach a compiled SurfaceIR snapshot to a semantic DesignIR 3.0 model. + + The semantic feature program remains authoritative for future edits. The + SurfaceIR snapshot is extracted only from the newly generated STEP, never + from teacher geometry. + """ + semantic = ( + migrate_designir_2_to_3(payload) + if payload.get("schema_version") == LEGACY_SCHEMA_VERSION + else copy.deepcopy(payload) + ) + compiler_payload(semantic) + surface_script = Path(__file__).with_name("surfaceir_pipeline.py") + spec = importlib.util.spec_from_file_location( + "_designir_surfaceir_pipeline", surface_script + ) + if spec is None or spec.loader is None: + raise DesignIRError("Cannot load SurfaceIR compiler") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + extracted = module.extract_surfaceir(generated_step.expanduser().resolve()) + semantic["surface_layer"] = extracted["surface_layer"] + semantic["reconstruction_strategy"] = extracted.get( + "reconstruction_strategy", + { + "boundary_strategy": "exact_3d", + "selection_status": "default", + }, + ) + semantic["document_status"] = extracted.get( + "document_status", "geometry_present" + ) + semantic["reconstruction_mode"] = "hybrid_semantic_surface_parametric" + edit_interface = semantic["edit_interface"] + edit_interface["surface_parameter_groups"] = extracted.get( + "edit_interface", {} + ).get("surface_parameter_groups", []) + edit_interface["modification_levels"] = list( + dict.fromkeys( + list(edit_interface.get("modification_levels", [])) + + ["semantic_feature", "analytic_surface", "spline_control"] + ) + ) + semantic["compiled_surface_provenance"] = { + "source": "generated_step", + "teacher_geometry_used": False, + "semantic_layer_authoritative": True, + } + return semantic + + def anti_cheat(payload: dict[str, Any], generator: Path | None = None) -> dict[str, Any]: validate_designir(payload) violations: list[str] = [] - forbidden = find_forbidden(payload) + inspected = ( + payload.get("semantic_layer", {}) + if payload.get("schema_version") == SCHEMA_VERSION + else payload + ) + forbidden = find_forbidden(inspected) if forbidden: violations.append(f"DesignIR teacher dependency at {forbidden}") if generator: @@ -594,14 +890,30 @@ def _symmetric_difference_volume(first: Part, second: Part) -> float: raise DesignIRError(f"Symmetric-difference boolean failed: {exc}") from exc +def _parameter_reference_count(value: Any, parameter: str) -> int: + if isinstance(value, dict): + direct = 1 if value.get("parameter") == parameter else 0 + return direct + sum( + _parameter_reference_count(child, parameter) + for child in value.values() + ) + if isinstance(value, list): + return sum(_parameter_reference_count(child, parameter) for child in value) + return 0 + + def acceptance_report( teacher_path: Path, rebuilt_path: Path, payload: dict[str, Any], ) -> dict[str, Any]: - validate_designir(payload) - teacher = import_step(teacher_path.expanduser().resolve()) - rebuilt = import_step(rebuilt_path.expanduser().resolve()) + payload = validate_designir(payload) + teacher_resolved = teacher_path.expanduser().resolve() + rebuilt_resolved = rebuilt_path.expanduser().resolve() + if teacher_resolved == rebuilt_resolved: + raise DesignIRError("Teacher and rebuilt STEP must be different files") + teacher = import_step(teacher_resolved) + rebuilt = import_step(rebuilt_resolved) teacher_facts = geometry_facts(teacher) rebuilt_facts = geometry_facts(rebuilt) volume_base = max(teacher_facts["volume_mm3"], 1e-12) @@ -640,6 +952,38 @@ def acceptance_report( try: output = Path(temporary) / f"perturbation-{index}.step" result = compile_designir(modified, output) + modified_shape = import_step(output) + changed_volume = _symmetric_difference_volume( + rebuilt, modified_shape + ) + change_ratio = changed_volume / max( + rebuilt_facts["volume_mm3"], 1e-12 + ) + reference_count = _parameter_reference_count( + payload.get("features", []), name + ) + preserved = { + preserved_name: ( + preserved_name in payload["parameters"] + and preserved_name in modified["parameters"] + and payload["parameters"][preserved_name]["value"] + == modified["parameters"][preserved_name]["value"] + ) + for preserved_name in test.get("preserve", []) + } + expected_change = str(test.get("expected_change") or "") + expected_change_observed = change_ratio > 1e-9 + if expected_change == "overall_height_increases": + expected_change_observed = ( + result["facts"]["size_mm"][2] + > rebuilt_facts["size_mm"][2] + 1e-9 + ) + success = ( + result["facts"]["solid_count"] > 0 + and reference_count > 0 + and expected_change_observed + and all(preserved.values()) + ) perturbations.append( { "parameter": name, @@ -647,7 +991,11 @@ def acceptance_report( "modified_value": modified["parameters"][name]["value"], "expected_change": test.get("expected_change"), "preserve": test.get("preserve", []), - "success": result["facts"]["solid_count"] > 0, + "parameter_reference_count": reference_count, + "geometry_change_ratio": change_ratio, + "expected_change_observed": expected_change_observed, + "preserved_parameter_checks": preserved, + "success": success, "facts": result["facts"], } ) @@ -678,6 +1026,14 @@ def acceptance_report( "schema_version": "1.0", "report_kind": "independent_designir_acceptance", "model_id": payload["model_id"], + "teacher_sha256": hashlib.sha256(teacher_resolved.read_bytes()).hexdigest(), + "applied_experience_ids": sorted( + { + str(item) + for item in payload.get("applied_experience_ids", []) + if str(item).strip() + } + ), "geometry": geometry, "feature_semantics": { "declared_feature_count": len(payload["features"]), @@ -700,15 +1056,34 @@ def acceptance_report( def promotion_check(reports: list[Path], policy_path: Path) -> dict[str, Any]: policy = read_json(policy_path) loaded = [read_json(path) for path in reports] - model_ids = {str(item.get("model_id")) for item in loaded} passing = [item for item in loaded if item.get("next_state") == "replay_validated"] minimum = int(policy["minimum_independent_cases"]) - eligible = len(passing) >= minimum and len(model_ids) >= minimum + methods: dict[str, set[str]] = {} + for item in passing: + source_hash = str(item.get("teacher_sha256") or "") + if not source_hash: + continue + for experience_id in item.get("applied_experience_ids", []): + methods.setdefault(str(experience_id), set()).add(source_hash) + validated_methods = sorted( + experience_id + for experience_id, source_hashes in methods.items() + if len(source_hashes) >= minimum + ) + independent_sources = len( + { + source_hash + for source_hashes in methods.values() + for source_hash in source_hashes + } + ) + eligible = bool(validated_methods) return { "eligible": eligible, - "independent_case_count": len(model_ids), + "independent_case_count": independent_sources, "passing_case_count": len(passing), "required_case_count": minimum, + "validated_experience_ids": validated_methods, "next_state": "promoted" if eligible else "candidate", } @@ -720,6 +1095,10 @@ def build_parser() -> argparse.ArgumentParser: validate = commands.add_parser("validate") validate.add_argument("designir", type=Path) + migrate = commands.add_parser("migrate") + migrate.add_argument("designir", type=Path) + migrate.add_argument("--output", type=Path, required=True) + guard = commands.add_parser("anti-cheat") guard.add_argument("designir", type=Path) guard.add_argument("--generator", type=Path) @@ -736,6 +1115,11 @@ def build_parser() -> argparse.ArgumentParser: isolated.add_argument("--output", type=Path, required=True) isolated.add_argument("--backend", choices=("build123d", "simplecadapi")) + materialize = commands.add_parser("materialize-hybrid") + materialize.add_argument("designir", type=Path) + materialize.add_argument("--step", type=Path, required=True) + materialize.add_argument("--output", type=Path, required=True) + accept = commands.add_parser("accept") accept.add_argument("--teacher", type=Path, required=True) accept.add_argument("--designir", type=Path, required=True) @@ -752,12 +1136,29 @@ def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: if args.command == "validate": - payload = validate_designir(read_json(args.designir)) + original = read_json(args.designir) + payload = validate_designir(original) result = { "valid": True, "model_id": payload["model_id"], + "input_schema_version": original.get("schema_version"), "supported_operations": sorted(SUPPORTED_OPERATIONS), } + elif args.command == "migrate": + original = read_json(args.designir) + migrated = ( + migrate_designir_2_to_3(original) + if original.get("schema_version") == LEGACY_SCHEMA_VERSION + else original + ) + compiler_payload(migrated) + write_json(args.output, migrated) + result = { + "valid": True, + "output": str(args.output.expanduser().resolve()), + "schema_version": migrated["schema_version"], + "reconstruction_mode": migrated["reconstruction_mode"], + } elif args.command == "anti-cheat": result = anti_cheat(read_json(args.designir), args.generator) elif args.command == "compile": @@ -768,6 +1169,18 @@ def main(argv: list[str] | None = None) -> int: result = isolated_rebuild( args.designir, args.output, backend=args.backend ) + elif args.command == "materialize-hybrid": + materialized = materialize_hybrid_designir( + read_json(args.designir), args.step + ) + write_json(args.output, materialized) + result = { + "valid": True, + "output": str(args.output.expanduser().resolve()), + "schema_version": materialized["schema_version"], + "reconstruction_mode": materialized["reconstruction_mode"], + "semantic_layer_authoritative": True, + } elif args.command == "accept": result = acceptance_report( args.teacher, args.rebuilt, read_json(args.designir) diff --git a/designir-pipeline/scripts/surfaceir_pipeline.py b/designir-pipeline/scripts/surfaceir_pipeline.py new file mode 100644 index 0000000..246c3f0 --- /dev/null +++ b/designir-pipeline/scripts/surfaceir_pipeline.py @@ -0,0 +1,4516 @@ +#!/usr/bin/env python3 +"""Extract an independent, editable DesignIR 3.0 surface/topology program.""" + +from __future__ import annotations + +import argparse +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +import copy +import hashlib +import json +import math +from pathlib import Path +import re +import subprocess +import sys +import tempfile +from typing import Any + +from build123d import Shape, import_step +from OCP.BRep import BRep_Builder, BRep_Tool +from OCP.BRepBndLib import BRepBndLib +from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.BRepClass import BRepClass_FaceClassifier +from OCP.BRepExtrema import BRepExtrema_DistShapeShape +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface +from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCP.BRepLib import BRepLib +from OCP.BRepGProp import BRepGProp +from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_GTransform, + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_MakeVertex, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, + BRepBuilderAPI_Transform, +) +from OCP.BRepTools import BRepTools, BRepTools_WireExplorer +from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder +from OCP.Bnd import Bnd_Box +from OCP.Geom import ( + Geom_BSplineCurve, + Geom_BSplineSurface, + Geom_Circle, + Geom_ConicalSurface, + Geom_CylindricalSurface, + Geom_Ellipse, + Geom_Line, + Geom_Plane, + Geom_SphericalSurface, + Geom_ToroidalSurface, +) +from OCP.Geom2d import ( + Geom2d_BSplineCurve, + Geom2d_Circle, + Geom2d_Ellipse, + Geom2d_Line, +) +from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve +from OCP.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Line, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_Torus, +) +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfPnt2d, TColgp_Array2OfPnt +from OCP.TColStd import ( + TColStd_Array1OfInteger, + TColStd_Array1OfReal, + TColStd_Array2OfReal, +) +from OCP.IFSelect import IFSelect_RetDone +from OCP.GProp import GProp_GProps +from OCP.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer +from OCP.ShapeFix import ShapeFix_Shape +from OCP.TopAbs import ( + TopAbs_EDGE, + TopAbs_FACE, + TopAbs_IN, + TopAbs_ON, + TopAbs_SHELL, + TopAbs_SOLID, + TopAbs_VERTEX, + TopAbs_WIRE, +) +from OCP.TopExp import TopExp_Explorer +from OCP.TopExp import TopExp +from OCP.TopLoc import TopLoc_Location +from OCP.TopTools import TopTools_IndexedMapOfShape +from OCP.TopoDS import TopoDS, TopoDS_Compound, TopoDS_Shell, TopoDS_Vertex +from OCP.gp import ( + gp_Ax2, + gp_Ax22d, + gp_Ax3, + gp_Circ, + gp_Circ2d, + gp_Dir, + gp_Dir2d, + gp_Elips, + gp_Elips2d, + gp_GTrsf, + gp_Lin, + gp_Lin2d, + gp_Mat, + gp_Pnt, + gp_Pnt2d, + gp_Trsf, + gp_XYZ, +) + + +def _number(value: float) -> float: + result = float(value) + if not math.isfinite(result): + raise ValueError("DesignIR cannot contain non-finite geometry") + return result + + +def _point(value: Any) -> list[float]: + return [_number(value.X()), _number(value.Y()), _number(value.Z())] + + +def _direction(value: Any) -> list[float]: + return _point(value) + + +def _axis3(position: Any) -> dict[str, Any]: + direct = ( + bool(position.Direct()) + if hasattr(position, "Direct") + else bool( + position.XDirection() + .Crossed(position.YDirection()) + .Dot(position.Direction()) + > 0 + ) + ) + return { + "location": _point(position.Location()), + "axis": _direction(position.Direction()), + "x_direction": _direction(position.XDirection()), + "y_direction": _direction(position.YDirection()), + "direct": direct, + } + + +def _bspline_curve(curve: Any) -> dict[str, Any]: + return { + "type": "bspline", + "degree": int(curve.Degree()), + "periodic": bool(curve.IsPeriodic()), + "rational": bool(curve.IsRational()), + "poles": [_point(curve.Pole(index)) for index in range(1, curve.NbPoles() + 1)], + "weights": [ + _number(curve.Weight(index)) for index in range(1, curve.NbPoles() + 1) + ], + "knots": [ + _number(curve.Knot(index)) for index in range(1, curve.NbKnots() + 1) + ], + "multiplicities": [ + int(curve.Multiplicity(index)) + for index in range(1, curve.NbKnots() + 1) + ], + } + + +def _curve_record(edge: Any) -> dict[str, Any]: + if bool(BRep_Tool.Degenerated_s(edge)): + return {"type": "degenerate"} + adaptor = BRepAdaptor_Curve(edge) + first, last = map(_number, BRep_Tool.Range_s(edge)) + kind = adaptor.GetType() + if kind == GeomAbs_Line: + line = adaptor.Line() + payload = { + "type": "line", + "location": _point(line.Location()), + "direction": _direction(line.Direction()), + } + elif kind == GeomAbs_Circle: + circle = adaptor.Circle() + payload = { + "type": "circle", + "position": _axis3(circle.Position()), + "radius": _number(circle.Radius()), + } + elif kind == GeomAbs_Ellipse: + ellipse = adaptor.Ellipse() + payload = { + "type": "ellipse", + "position": _axis3(ellipse.Position()), + "major_radius": _number(ellipse.MajorRadius()), + "minor_radius": _number(ellipse.MinorRadius()), + } + elif kind == GeomAbs_BSplineCurve: + payload = _bspline_curve(adaptor.BSpline()) + else: + raise ValueError(f"Unsupported curve type: {int(kind)}") + payload["parameter_range"] = [first, last] + return payload + + +def _point2d(value: Any) -> list[float]: + return [_number(value.X()), _number(value.Y())] + + +def _pcurve_record(edge: Any, face: Any) -> dict[str, Any] | None: + curve = BRep_Tool.CurveOnSurface_s(edge, face, 0.0, 0.0) + if curve is None: + return None + adaptor = Geom2dAdaptor_Curve(curve) + first, last = map(_number, BRep_Tool.Range_s(edge, face)) + kind = adaptor.GetType() + if kind == GeomAbs_Line: + line = adaptor.Line() + payload = { + "type": "line", + "location": _point2d(line.Location()), + "direction": _point2d(line.Direction()), + } + elif kind == GeomAbs_Circle: + circle = adaptor.Circle() + position = circle.Position() + payload = { + "type": "circle", + "location": _point2d(position.Location()), + "x_direction": _point2d(position.XDirection()), + "y_direction": _point2d(position.YDirection()), + "radius": _number(circle.Radius()), + } + elif kind == GeomAbs_Ellipse: + ellipse = adaptor.Ellipse() + position = ellipse.Axis() + payload = { + "type": "ellipse", + "location": _point2d(position.Location()), + "x_direction": _point2d(position.XDirection()), + "y_direction": _point2d(position.YDirection()), + "major_radius": _number(ellipse.MajorRadius()), + "minor_radius": _number(ellipse.MinorRadius()), + } + elif kind == GeomAbs_BSplineCurve: + spline = adaptor.BSpline() + payload = { + "type": "bspline", + "degree": int(spline.Degree()), + "periodic": bool(spline.IsPeriodic()), + "rational": bool(spline.IsRational()), + "poles": [ + _point2d(spline.Pole(index)) + for index in range(1, spline.NbPoles() + 1) + ], + "weights": [ + _number(spline.Weight(index)) + for index in range(1, spline.NbPoles() + 1) + ], + "knots": [ + _number(spline.Knot(index)) + for index in range(1, spline.NbKnots() + 1) + ], + "multiplicities": [ + int(spline.Multiplicity(index)) + for index in range(1, spline.NbKnots() + 1) + ], + } + else: + raise ValueError(f"Unsupported pcurve type: {int(kind)}") + payload["parameter_range"] = [first, last] + return payload + + +def _bspline_surface(surface: Any) -> dict[str, Any]: + return { + "type": "bspline", + "u_degree": int(surface.UDegree()), + "v_degree": int(surface.VDegree()), + "u_periodic": bool(surface.IsUPeriodic()), + "v_periodic": bool(surface.IsVPeriodic()), + "u_rational": bool(surface.IsURational()), + "v_rational": bool(surface.IsVRational()), + "poles": [ + [ + _point(surface.Pole(u_index, v_index)) + for v_index in range(1, surface.NbVPoles() + 1) + ] + for u_index in range(1, surface.NbUPoles() + 1) + ], + "weights": [ + [ + _number(surface.Weight(u_index, v_index)) + for v_index in range(1, surface.NbVPoles() + 1) + ] + for u_index in range(1, surface.NbUPoles() + 1) + ], + "u_knots": [ + _number(surface.UKnot(index)) + for index in range(1, surface.NbUKnots() + 1) + ], + "v_knots": [ + _number(surface.VKnot(index)) + for index in range(1, surface.NbVKnots() + 1) + ], + "u_multiplicities": [ + int(surface.UMultiplicity(index)) + for index in range(1, surface.NbUKnots() + 1) + ], + "v_multiplicities": [ + int(surface.VMultiplicity(index)) + for index in range(1, surface.NbVKnots() + 1) + ], + } + + +def _surface_record(face: Any) -> dict[str, Any]: + adaptor = BRepAdaptor_Surface(face) + kind = adaptor.GetType() + if kind == GeomAbs_Plane: + plane = adaptor.Plane() + return {"type": "plane", "position": _axis3(plane.Position())} + if kind == GeomAbs_Cylinder: + cylinder = adaptor.Cylinder() + return { + "type": "cylinder", + "position": _axis3(cylinder.Position()), + "radius": _number(cylinder.Radius()), + } + if kind == GeomAbs_Cone: + cone = adaptor.Cone() + return { + "type": "cone", + "position": _axis3(cone.Position()), + "reference_radius": _number(cone.RefRadius()), + "semi_angle_radians": _number(cone.SemiAngle()), + } + if kind == GeomAbs_Sphere: + sphere = adaptor.Sphere() + return { + "type": "sphere", + "position": _axis3(sphere.Position()), + "radius": _number(sphere.Radius()), + } + if kind == GeomAbs_Torus: + torus = adaptor.Torus() + return { + "type": "torus", + "position": _axis3(torus.Position()), + "major_radius": _number(torus.MajorRadius()), + "minor_radius": _number(torus.MinorRadius()), + } + if kind == GeomAbs_BSplineSurface: + return _bspline_surface(adaptor.BSpline()) + raise ValueError(f"Unsupported surface type: {int(kind)}") + + +def _edge_records( + wire: Any, + face: Any, + prefix: str, + edge_map: TopTools_IndexedMapOfShape, + vertex_map: TopTools_IndexedMapOfShape, +) -> list[dict[str, Any]]: + records = [] + explorer = BRepTools_WireExplorer(wire) + index = 0 + while explorer.More(): + index += 1 + edge = explorer.Current() + topology_index = int(edge_map.FindIndex(edge)) + if topology_index <= 0: + raise ValueError("Cannot resolve shared topology edge") + first_vertex = TopoDS_Vertex() + last_vertex = TopoDS_Vertex() + TopExp.Vertices_s(edge, first_vertex, last_vertex) + vertex_ids = [] + for vertex in (first_vertex, last_vertex): + if vertex.IsNull(): + continue + vertex_index = int(vertex_map.FindIndex(vertex)) + if vertex_index <= 0: + raise ValueError("Cannot resolve shared topology vertex") + vertex_ids.append(f"vertex_{vertex_index}") + records.append( + { + "id": f"{prefix}.edge_{index}", + "topology_edge_id": f"edge_{topology_index}", + "vertex_ids": vertex_ids, + "orientation": int(explorer.Orientation()), + "tolerance": _number(BRep_Tool.Tolerance_s(edge)), + "curve": _curve_record(edge), + "pcurve": _pcurve_record(edge, face), + } + ) + explorer.Next() + return records + + +def _wire_records( + face: Any, + prefix: str, + edge_map: TopTools_IndexedMapOfShape, + vertex_map: TopTools_IndexedMapOfShape, +) -> list[dict[str, Any]]: + records = [] + outer = BRepTools.OuterWire_s(face) + explorer = TopExp_Explorer(face, TopAbs_WIRE) + index = 0 + while explorer.More(): + index += 1 + wire = TopoDS.Wire_s(explorer.Current()) + wire_id = f"{prefix}.wire_{index}" + records.append( + { + "id": wire_id, + "orientation": int(wire.Orientation()), + "outer": bool(wire.IsSame(outer)), + "edges": _edge_records( + wire, face, wire_id, edge_map, vertex_map + ), + } + ) + explorer.Next() + return records + + +def _face_records( + shell: Any, + prefix: str, + edge_map: TopTools_IndexedMapOfShape, + vertex_map: TopTools_IndexedMapOfShape, +) -> list[dict[str, Any]]: + records = [] + explorer = TopExp_Explorer(shell, TopAbs_FACE) + index = 0 + while explorer.More(): + index += 1 + face = TopoDS.Face_s(explorer.Current()) + face_id = f"{prefix}.face_{index}" + records.append( + { + "id": face_id, + "orientation": int(face.Orientation()), + "surface": _surface_record(face), + "uv_bounds": [_number(value) for value in BRepTools.UVBounds_s(face)], + "wires": _wire_records(face, face_id, edge_map, vertex_map), + } + ) + explorer.Next() + return records + + +def _vertex_records(vertex_map: TopTools_IndexedMapOfShape) -> list[dict[str, Any]]: + records = [] + for index in range(1, vertex_map.Extent() + 1): + vertex = TopoDS.Vertex_s(vertex_map.FindKey(index)) + records.append( + { + "id": f"vertex_{index}", + "point": _point(BRep_Tool.Pnt_s(vertex)), + "tolerance": _number(BRep_Tool.Tolerance_s(vertex)), + } + ) + return records + + +def _topology_records(shape: Any) -> dict[str, Any]: + edge_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(shape, TopAbs_EDGE, edge_map) + vertex_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(shape, TopAbs_VERTEX, vertex_map) + solids = [] + solid_shell_shapes = [] + solid_explorer = TopExp_Explorer(shape, TopAbs_SOLID) + solid_index = 0 + while solid_explorer.More(): + solid_index += 1 + solid = TopoDS.Solid_s(solid_explorer.Current()) + shells = [] + shell_explorer = TopExp_Explorer(solid, TopAbs_SHELL) + shell_index = 0 + while shell_explorer.More(): + shell_index += 1 + shell = TopoDS.Shell_s(shell_explorer.Current()) + solid_shell_shapes.append(shell) + shell_id = f"solid_{solid_index}.shell_{shell_index}" + shells.append( + { + "id": shell_id, + "orientation": int(shell.Orientation()), + "faces": _face_records( + shell, shell_id, edge_map, vertex_map + ), + } + ) + shell_explorer.Next() + solids.append({"id": f"solid_{solid_index}", "shells": shells}) + solid_explorer.Next() + free_shells = [] + shell_explorer = TopExp_Explorer(shape, TopAbs_SHELL) + free_shell_index = 0 + while shell_explorer.More(): + shell = TopoDS.Shell_s(shell_explorer.Current()) + if not any(shell.IsSame(candidate) for candidate in solid_shell_shapes): + free_shell_index += 1 + shell_id = f"free_shell_{free_shell_index}" + free_shells.append( + { + "id": shell_id, + "orientation": int(shell.Orientation()), + "faces": _face_records( + shell, shell_id, edge_map, vertex_map + ), + } + ) + shell_explorer.Next() + return { + "vertices": _vertex_records(vertex_map), + "solids": solids, + "free_shells": free_shells, + } + + +def _normalized_axis(position: dict[str, Any]) -> tuple[list[float], list[float]]: + axis = [float(value) for value in position["axis"]] + magnitude = math.sqrt(sum(value * value for value in axis)) + axis = [value / magnitude for value in axis] + for value in axis: + if abs(value) > 1e-9: + if value < 0: + axis = [-item for item in axis] + break + location = [float(value) for value in position["location"]] + distance = sum(location[index] * axis[index] for index in range(3)) + anchor = [ + location[index] - distance * axis[index] for index in range(3) + ] + return axis, anchor + + +def _parallel(first: list[float], second: list[float], tolerance: float = 1e-6) -> bool: + return abs(abs(sum(a * b for a, b in zip(first, second))) - 1.0) <= tolerance + + +def _distance(first: list[float], second: list[float]) -> float: + return math.sqrt(sum((a - b) ** 2 for a, b in zip(first, second))) + + +def _dot(first: list[float], second: list[float]) -> float: + return sum(a * b for a, b in zip(first, second)) + + +def _cross(first: list[float], second: list[float]) -> list[float]: + return [ + first[1] * second[2] - first[2] * second[1], + first[2] * second[0] - first[0] * second[2], + first[0] * second[1] - first[1] * second[0], + ] + + +def _surfaceir_faces(topology: dict[str, Any]) -> list[dict[str, Any]]: + solid_faces = [ + face + for solid in topology["solids"] + for shell in solid["shells"] + for face in shell["faces"] + ] + free_faces = [ + face + for shell in topology.get("free_shells", []) + for face in shell["faces"] + ] + return solid_faces + free_faces + + +def _observed_internal_cylinders(topology: dict[str, Any]) -> list[dict[str, Any]]: + observations = [] + for face in _surfaceir_faces(topology): + surface = face["surface"] + if surface["type"] != "cylinder" or int(face["orientation"]) != 1: + continue + axis, anchor = _normalized_axis(surface["position"]) + observations.append( + { + "face_id": face["id"], + "axis": axis, + "anchor": anchor, + "radius": float(surface["radius"]), + "axial_span": abs( + float(face["uv_bounds"][3]) - float(face["uv_bounds"][2]) + ), + } + ) + return observations + + +def _cylinder_semantics(topology: dict[str, Any]) -> dict[str, Any]: + """Infer conservative paper-style names without claiming source history.""" + + cylinders = [] + for face in _surfaceir_faces(topology): + surface = face["surface"] + if surface["type"] != "cylinder": + continue + axis, anchor = _normalized_axis(surface["position"]) + cylinders.append( + { + "face_id": face["id"], + "internal": int(face["orientation"]) == 1, + "axis": axis, + "anchor": anchor, + "radius": float(surface["radius"]), + "diameter": 2.0 * float(surface["radius"]), + "axial_span": abs( + float(face["uv_bounds"][3]) - float(face["uv_bounds"][2]) + ), + "boundary_edge_ids": sorted( + { + edge["topology_edge_id"] + for wire in face["wires"] + for edge in wire["edges"] + } + ), + } + ) + + parameters: dict[str, Any] = {} + datums: dict[str, Any] = {} + features: list[dict[str, Any]] = [] + constraints: list[dict[str, Any]] = [] + internal = [item for item in cylinders if item["internal"]] + external = [item for item in cylinders if not item["internal"]] + + # Coaxial cylinder segments represent one canonical hole axis. This avoids + # treating counterbores and stepped bores as unrelated holes. + hole_axes: list[dict[str, Any]] = [] + for item in sorted(internal, key=lambda value: value["face_id"]): + match = next( + ( + candidate + for candidate in hole_axes + if _parallel(candidate["axis"], item["axis"]) + and _distance(candidate["anchor"], item["anchor"]) <= 1e-5 + ), + None, + ) + if match is None: + match = { + "axis": item["axis"], + "anchor": item["anchor"], + "segments": [], + } + hole_axes.append(match) + match["segments"].append(item) + + hole_axes.sort( + key=lambda hole: ( + tuple(round(value, 9) for value in hole["axis"]), + tuple(round(value, 9) for value in hole["anchor"]), + ) + ) + for index, hole in enumerate(hole_axes, 1): + hole["id"] = f"hole_axis_{index}" + + for hole in hole_axes: + datums[hole["id"]] = { + "kind": "axis", + "origin": [_number(value) for value in hole["anchor"]], + "direction": [_number(value) for value in hole["axis"]], + "inference": "canonical_from_final_brep", + } + + # Equal-diameter parallel axes are candidates for a repeated hole feature. + unused = set(range(len(hole_axes))) + pattern_index = 0 + while unused: + seed_index = min(unused) + seed = hole_axes[seed_index] + seed_diameter = min(segment["diameter"] for segment in seed["segments"]) + group = [ + index + for index in sorted(unused) + if _parallel(seed["axis"], hole_axes[index]["axis"]) + and abs( + min( + segment["diameter"] + for segment in hole_axes[index]["segments"] + ) + - seed_diameter + ) + <= max(1e-5, seed_diameter * 1e-6) + ] + for index in group: + unused.discard(index) + if len(group) < 2: + continue + + points = [hole_axes[index]["anchor"] for index in group] + centroid = [ + sum(point[dimension] for point in points) / len(points) + for dimension in range(3) + ] + radial_distances = [_distance(point, centroid) for point in points] + radius_mean = sum(radial_distances) / len(radial_distances) + radial_equal = ( + len(group) >= 3 + and radius_mean > 1e-7 + and max(abs(value - radius_mean) for value in radial_distances) + <= max(1e-5, radius_mean * 1e-5) + ) + angular_equal = False + if radial_equal: + reference = [ + (points[0][index] - centroid[index]) / radius_mean + for index in range(3) + ] + transverse = _cross(seed["axis"], reference) + transverse_length = math.sqrt(_dot(transverse, transverse)) + if transverse_length > 1e-7: + transverse = [ + value / transverse_length for value in transverse + ] + angles = sorted( + math.atan2( + _dot( + [ + point[index] - centroid[index] + for index in range(3) + ], + transverse, + ), + _dot( + [ + point[index] - centroid[index] + for index in range(3) + ], + reference, + ), + ) + % (2.0 * math.pi) + for point in points + ) + angle_gaps = [ + ( + angles[(index + 1) % len(angles)] + - angles[index] + ) + % (2.0 * math.pi) + for index in range(len(angles)) + ] + expected_gap = 2.0 * math.pi / len(angles) + angular_equal = max( + abs(gap - expected_gap) for gap in angle_gaps + ) <= 1e-5 + polar = radial_equal and angular_equal + + # A covariance-free collinearity test is sufficient for exact CAD axes. + direction = [ + points[-1][dimension] - points[0][dimension] + for dimension in range(3) + ] + direction_length = math.sqrt(sum(value * value for value in direction)) + linear = direction_length > 1e-7 + if linear: + direction = [value / direction_length for value in direction] + linear = all( + math.sqrt( + sum( + ( + (point[dimension] - points[0][dimension]) + - sum( + (point[index] - points[0][index]) + * direction[index] + for index in range(3) + ) + * direction[dimension] + ) + ** 2 + for dimension in range(3) + ) + ) + <= 1e-5 + for point in points + ) + + pattern_index += 1 + feature_id = f"hole_pattern_{pattern_index}" + diameter_name = ( + "hole_diameter" if pattern_index == 1 else f"hole_diameter_{pattern_index}" + ) + count_name = ( + "hole_count" if pattern_index == 1 else f"hole_count_{pattern_index}" + ) + parameters[diameter_name] = { + "value": _number(seed_diameter), + "unit": "mm", + "editable": False, + "semantic_role": "hole_diameter", + "inference": "cylindrical_cut_surface", + "confidence": 0.98, + "affected_face_ids": [ + segment["face_id"] + for index in group + for segment in hole_axes[index]["segments"] + if abs(segment["diameter"] - seed_diameter) + <= max(1e-5, seed_diameter * 1e-6) + ], + "edit_state": "executable_enlarge_cut_binding", + } + parameters[count_name] = { + "value": len(group), + "unit": "count", + "editable": False, + "semantic_role": "hole_count", + "inference": "equal_diameter_parallel_axes", + "confidence": 0.95, + "edit_state": "awaiting_executable_binding", + } + pattern_kind = "polar" if polar else "linear" if linear else "set" + feature = { + "id": feature_id, + "operation": f"{pattern_kind}_hole_pattern", + "member_axis_ids": [hole_axes[index]["id"] for index in group], + "diameter_parameter": diameter_name, + "count_parameter": count_name, + "inference": "canonical_from_final_brep", + "original_history_recovered": False, + "confidence": 0.95 if pattern_kind != "set" else 0.75, + } + if polar: + parameter_name = ( + "bolt_circle_diameter" + if pattern_index == 1 + else f"bolt_circle_diameter_{pattern_index}" + ) + parameters[parameter_name] = { + "value": _number(2.0 * radius_mean), + "unit": "mm", + "editable": False, + "semantic_role": "bolt_circle_diameter", + "inference": "equal_radius_axis_distribution", + "confidence": 0.98, + "edit_state": "awaiting_executable_binding", + } + feature["pitch_diameter_parameter"] = parameter_name + constraints.append( + { + "id": f"{feature_id}_equal_angular_spacing", + "kind": "equal_angular_spacing", + "participants": [feature_id], + "confidence": 0.9, + } + ) + elif linear: + projections = sorted( + sum( + (point[index] - points[0][index]) * direction[index] + for index in range(3) + ) + for point in points + ) + gaps = [ + projections[index + 1] - projections[index] + for index in range(len(projections) - 1) + ] + if gaps and max(gaps) - min(gaps) <= max(1e-5, max(gaps) * 1e-5): + parameter_name = ( + "hole_spacing" + if pattern_index == 1 + else f"hole_spacing_{pattern_index}" + ) + parameters[parameter_name] = { + "value": _number(sum(gaps) / len(gaps)), + "unit": "mm", + "editable": False, + "semantic_role": "hole_spacing", + "inference": "equal_linear_axis_spacing", + "confidence": 0.98, + "edit_state": "awaiting_executable_binding", + } + feature["spacing_parameter"] = parameter_name + constraints.append( + { + "id": f"{feature_id}_equal_spacing", + "kind": "equal_linear_spacing", + "participants": [feature_id], + "confidence": 0.98, + } + ) + features.append(feature) + + patterned_axes = { + axis_id + for feature in features + for axis_id in feature.get("member_axis_ids", []) + } + vertex_points = [ + [float(value) for value in vertex["point"]] + for vertex in topology.get("vertices", []) + ] + bounds_center = ( + [ + ( + min(point[index] for point in vertex_points) + + max(point[index] for point in vertex_points) + ) + / 2.0 + for index in range(3) + ] + if vertex_points + else [0.0, 0.0, 0.0] + ) + bounds_diagonal = ( + _distance( + [min(point[index] for point in vertex_points) for index in range(3)], + [max(point[index] for point in vertex_points) for index in range(3)], + ) + if vertex_points + else 0.0 + ) + primary_external = ( + max(external, key=lambda item: item["radius"]) if external else None + ) + unpatterned_index = 0 + for hole in hole_axes: + if hole["id"] in patterned_axes: + continue + unpatterned_index += 1 + diameter = min(segment["diameter"] for segment in hole["segments"]) + center_delta = [ + bounds_center[index] - hole["anchor"][index] + for index in range(3) + ] + axial_component = _dot(center_delta, hole["axis"]) + axis_distance_from_center = math.sqrt( + sum( + ( + center_delta[index] + - axial_component * hole["axis"][index] + ) + ** 2 + for index in range(3) + ) + ) + central = ( + primary_external is not None + and _parallel(hole["axis"], primary_external["axis"]) + and _distance(hole["anchor"], primary_external["anchor"]) + <= max(1e-5, primary_external["diameter"] * 1e-6) + ) or axis_distance_from_center <= max( + 1e-5, + bounds_diagonal * 1e-6, + ) + parameter_name = ( + "bore_diameter" + if central + else f"hole_diameter_single_{unpatterned_index}" + ) + parameters[parameter_name] = { + "value": _number(diameter), + "unit": "mm", + "editable": False, + "semantic_role": ( + "bore_diameter" if central else "hole_diameter" + ), + "inference": "cylindrical_cut_surface", + "confidence": 0.9 if len(hole_axes) == 1 else 0.8, + "affected_face_ids": [ + segment["face_id"] + for segment in hole["segments"] + if abs(segment["diameter"] - diameter) + <= max(1e-5, diameter * 1e-6) + ], + "edit_state": "executable_enlarge_cut_binding", + } + features.append( + { + "id": f"canonical_hole_{unpatterned_index}", + "operation": ( + "central_bore" if central else "cylindrical_cut" + ), + "axis_id": hole["id"], + "diameter_parameter": parameter_name, + "segment_face_ids": [ + segment["face_id"] for segment in hole["segments"] + ], + "inference": "canonical_from_final_brep", + "original_history_recovered": False, + "confidence": 0.9 if len(hole_axes) == 1 else 0.8, + } + ) + + if external: + has_feature_specific_cut_binding = any( + parameter.get("edit_state") + == "executable_enlarge_cut_binding" + for parameter in parameters.values() + ) + primary_axis_edit_state = ( + "awaiting_executable_binding" + if has_feature_specific_cut_binding + else "executable_axis_affine_binding" + ) + largest = max(external, key=lambda item: item["radius"]) + parameters["outer_diameter"] = { + "value": _number(largest["diameter"]), + "unit": "mm", + "editable": False, + "semantic_role": "outer_diameter", + "inference": "largest_external_cylinder", + "confidence": 0.9, + "affected_face_ids": [largest["face_id"]], + "edit_state": primary_axis_edit_state, + } + feature = { + "id": "primary_cylindrical_body", + "operation": "cylindrical_body", + "face_id": largest["face_id"], + "diameter_parameter": "outer_diameter", + "inference": "canonical_from_final_brep", + "original_history_recovered": False, + "confidence": 0.9, + } + if largest["axial_span"] > 1e-7: + semantic_role = ( + "plate_thickness" + if largest["axial_span"] < largest["diameter"] * 0.35 + else "body_length" + ) + parameters[semantic_role] = { + "value": _number(largest["axial_span"]), + "unit": "mm", + "editable": False, + "semantic_role": semantic_role, + "inference": "external_cylinder_axial_span", + "confidence": 0.85, + "affected_face_ids": [largest["face_id"]], + "edit_state": primary_axis_edit_state, + } + feature["length_parameter"] = semantic_role + features.append(feature) + + return { + "parameters": parameters, + "datums": datums, + "features": features, + "constraints": constraints, + "inference_policy": { + "kind": "canonical_design_interpretation", + "claims_original_feature_history": False, + "editable_only_after_perturbation_validation": True, + }, + } + + +def apply_semantic_parameter( + payload: dict[str, Any], parameter_name: str, value: float +) -> dict[str, Any]: + """Apply one explicitly bound semantic edit to an independent IR copy.""" + + result = copy.deepcopy(payload) + parameters = result.get("semantic_layer", {}).get("parameters", {}) + if parameter_name not in parameters: + raise ValueError(f"Unknown semantic parameter: {parameter_name}") + parameter = parameters[parameter_name] + if parameter.get("edit_state") not in { + "executable_enlarge_cut_binding", + "executable_axis_affine_binding", + "executable_uniform_scale_binding", + "validated_executable_binding", + }: + raise ValueError( + f"Semantic parameter {parameter_name} has no executable binding" + ) + role = parameter.get("semantic_role") + if role not in { + "hole_diameter", + "bore_diameter", + "outer_diameter", + "body_length", + "plate_thickness", + "overall_size_x", + "overall_size_y", + "overall_size_z", + "overall_scale", + }: + raise ValueError(f"Unsupported semantic edit role: {role}") + new_value = float(value) + if not math.isfinite(new_value) or new_value <= 0: + raise ValueError("Semantic edits require a finite positive value") + old_value = float(parameter["value"]) + if role == "overall_scale": + if math.isclose(new_value, old_value, rel_tol=0.0, abs_tol=1e-12): + raise ValueError("Overall scale edit must change the value") + pivot_name = parameter.get("pivot_datum") + pivot = ( + result.get("semantic_layer", {}) + .get("datums", {}) + .get(pivot_name, {}) + .get("point") + ) + if not isinstance(pivot, list) or len(pivot) != 3: + raise ValueError("Overall scale edit has no valid pivot datum") + scale_factor = new_value / old_value + parameter["value"] = _number(new_value) + parameter["editable"] = True + parameter["edit_state"] = "edited_pending_acceptance" + result["edit_operations"] = [ + { + "operation": "uniform_scale", + "factor": _number(scale_factor), + "pivot": [_number(float(component)) for component in pivot], + } + ] + preferred_boundary_strategy = result.get( + "reconstruction_strategy", {} + ).get("boundary_strategy", "analytic_uv_rect") + result["reconstruction_strategy"] = { + "boundary_strategy": preferred_boundary_strategy, + "selection_status": "semantic_edit_candidate", + } + result["active_edit"] = { + "parameter": parameter_name, + "semantic_role": role, + "old_value": _number(old_value), + "new_value": _number(new_value), + "operation_count": 1, + "range_status": ( + "within_validated_range" + if parameter.get("validated_range") + and float( + parameter["validated_range"]["minimum_tested_inclusive"] + ) + <= new_value + <= float( + parameter["validated_range"]["maximum_tested_inclusive"] + ) + else "requires_revalidation" + ), + "acceptance_status": "pending", + } + return result + + if role in {"overall_size_x", "overall_size_y", "overall_size_z"}: + if math.isclose(new_value, old_value, rel_tol=0.0, abs_tol=1e-12): + raise ValueError(f"{role} edit must change the value") + axis_index = {"overall_size_x": 0, "overall_size_y": 1, "overall_size_z": 2}[ + role + ] + axis = [0.0, 0.0, 0.0] + axis[axis_index] = 1.0 + pivot_name = parameter.get("pivot_datum") + pivot = ( + result.get("semantic_layer", {}) + .get("datums", {}) + .get(pivot_name, {}) + .get("point") + ) + if not isinstance(pivot, list) or len(pivot) != 3: + raise ValueError(f"{role} has no valid pivot datum") + factor = new_value / old_value + parameter["value"] = _number(new_value) + parameter["editable"] = True + parameter["edit_state"] = "edited_pending_acceptance" + result["edit_operations"] = [ + { + "operation": "axis_affine_scale", + "axis": axis, + "pivot": [_number(float(component)) for component in pivot], + "axial_factor": _number(factor), + "radial_factor": 1.0, + "semantic_role": role, + "target_measure": "bounds_extent", + } + ] + preferred_boundary_strategy = result.get( + "reconstruction_strategy", {} + ).get("boundary_strategy", "analytic_uv_rect") + result["reconstruction_strategy"] = { + "boundary_strategy": preferred_boundary_strategy, + "selection_status": "semantic_edit_candidate", + } + result["active_edit"] = { + "parameter": parameter_name, + "semantic_role": role, + "old_value": _number(old_value), + "new_value": _number(new_value), + "operation_count": 1, + "range_status": ( + "within_validated_range" + if parameter.get("validated_range") + and float( + parameter["validated_range"]["minimum_tested_inclusive"] + ) + <= new_value + <= float( + parameter["validated_range"]["maximum_tested_inclusive"] + ) + else "requires_revalidation" + ), + "acceptance_status": "pending", + } + return result + + if role in {"outer_diameter", "body_length", "plate_thickness"}: + if math.isclose(new_value, old_value, rel_tol=0.0, abs_tol=1e-12): + raise ValueError(f"{role} edit must change the value") + affected_face_ids = set(parameter.get("affected_face_ids", [])) + faces = _surfaceir_faces(result["surface_layer"]) + target_face = next( + ( + face + for face in faces + if face["id"] in affected_face_ids + and face.get("surface", {}).get("type") == "cylinder" + ), + None, + ) + if target_face is None: + raise ValueError(f"{role} binding matched no primary cylinder") + position = target_face["surface"]["position"] + raw_axis = [float(component) for component in position["axis"]] + axis_length = math.sqrt(sum(component**2 for component in raw_axis)) + axis = [component / axis_length for component in raw_axis] + location = [ + float(component) for component in position["location"] + ] + v_min, v_max = sorted( + [ + float(target_face["uv_bounds"][2]), + float(target_face["uv_bounds"][3]), + ] + ) + midpoint = (v_min + v_max) / 2.0 + pivot = [ + _number(location[index] + midpoint * axis[index]) + for index in range(3) + ] + factor = new_value / old_value + axial_factor = ( + factor + if role in {"body_length", "plate_thickness"} + else 1.0 + ) + radial_factor = factor if role == "outer_diameter" else 1.0 + parameter["value"] = _number(new_value) + parameter["editable"] = True + parameter["edit_state"] = "edited_pending_acceptance" + result["edit_operations"] = [ + { + "operation": "axis_affine_scale", + "axis": [_number(component) for component in axis], + "pivot": pivot, + "axial_factor": _number(axial_factor), + "radial_factor": _number(radial_factor), + "target_face_id": target_face["id"], + "semantic_role": role, + } + ] + preferred_boundary_strategy = result.get( + "reconstruction_strategy", {} + ).get("boundary_strategy", "analytic_uv_rect") + result["reconstruction_strategy"] = { + "boundary_strategy": preferred_boundary_strategy, + "selection_status": "semantic_edit_candidate", + } + result["active_edit"] = { + "parameter": parameter_name, + "semantic_role": role, + "old_value": _number(old_value), + "new_value": _number(new_value), + "edited_face_ids": [target_face["id"]], + "operation_count": 1, + "range_status": ( + "within_validated_range" + if parameter.get("validated_range") + and float( + parameter["validated_range"]["minimum_tested_inclusive"] + ) + <= new_value + <= float( + parameter["validated_range"]["maximum_tested_inclusive"] + ) + else "requires_revalidation" + ), + "acceptance_status": "pending", + } + return result + + if new_value <= old_value: + raise ValueError( + f"Semantic parameter {parameter_name} currently supports " + "diameter increases only" + ) + old_radius = old_value / 2.0 + new_radius = new_value / 2.0 + affected_face_ids = set(parameter.get("affected_face_ids", [])) + if not affected_face_ids: + raise ValueError(f"Semantic parameter {parameter_name} has no target faces") + + faces = _surfaceir_faces(result["surface_layer"]) + edited_face_ids: list[str] = [] + edit_operations: list[dict[str, Any]] = [] + seen_operations: set[tuple[Any, ...]] = set() + radius_tolerance = max(1e-6, abs(old_radius) * 1e-6) + for face in faces: + if face["id"] not in affected_face_ids: + continue + surface = face["surface"] + if ( + surface.get("type") != "cylinder" + or abs(float(surface["radius"]) - old_radius) > radius_tolerance + ): + continue + edited_face_ids.append(face["id"]) + operation_axis, operation_anchor = _normalized_axis(surface["position"]) + operation_v_range = sorted( + [_number(face["uv_bounds"][2]), _number(face["uv_bounds"][3])] + ) + operation_key = ( + tuple(round(value, 8) for value in operation_axis), + tuple(round(value, 8) for value in operation_anchor), + round(operation_v_range[0], 8), + round(operation_v_range[1], 8), + ) + if operation_key in seen_operations: + continue + seen_operations.add(operation_key) + edit_operations.append( + { + "operation": "enlarge_cylindrical_cut", + "face_id": face["id"], + "position": copy.deepcopy(surface["position"]), + "v_range": operation_v_range, + "old_radius": _number(old_radius), + "new_radius": _number(new_radius), + } + ) + if not edited_face_ids: + raise ValueError( + f"Semantic parameter {parameter_name} binding matched no cylinders" + ) + + merged_operations: list[dict[str, Any]] = [] + for operation in edit_operations: + operation_axis, operation_anchor = _normalized_axis( + operation["position"] + ) + location = [ + float(value) for value in operation["position"]["location"] + ] + raw_axis = [ + float(value) for value in operation["position"]["axis"] + ] + axis_sign = sum( + first * second + for first, second in zip(raw_axis, operation_axis) + ) + location_scalar = sum( + first * second + for first, second in zip(location, operation_axis) + ) + absolute_interval = sorted( + location_scalar + axis_sign * float(value) + for value in operation["v_range"] + ) + duplicate = None + for candidate in merged_operations: + candidate_axis = candidate["_canonical_axis"] + candidate_anchor = candidate["_canonical_anchor"] + candidate_interval = candidate["_absolute_interval"] + span = max( + absolute_interval[1] - absolute_interval[0], + candidate_interval[1] - candidate_interval[0], + 1e-9, + ) + interval_tolerance = max(1e-4, span * 1e-5) + if ( + _parallel(operation_axis, candidate_axis) + and _distance(operation_anchor, candidate_anchor) <= 1e-5 + and abs(absolute_interval[0] - candidate_interval[0]) + <= interval_tolerance + and abs(absolute_interval[1] - candidate_interval[1]) + <= interval_tolerance + ): + duplicate = candidate + break + if duplicate is None: + candidate = copy.deepcopy(operation) + candidate["source_face_ids"] = [operation["face_id"]] + candidate["_canonical_axis"] = operation_axis + candidate["_canonical_anchor"] = operation_anchor + candidate["_absolute_interval"] = absolute_interval + merged_operations.append(candidate) + else: + duplicate["source_face_ids"].append(operation["face_id"]) + duplicate["_absolute_interval"] = [ + min(duplicate["_absolute_interval"][0], absolute_interval[0]), + max(duplicate["_absolute_interval"][1], absolute_interval[1]), + ] + duplicate_location = [ + float(value) + for value in duplicate["position"]["location"] + ] + duplicate_axis = [ + float(value) for value in duplicate["position"]["axis"] + ] + duplicate_location_scalar = sum( + first * second + for first, second in zip( + duplicate_location, duplicate["_canonical_axis"] + ) + ) + duplicate_sign = sum( + first * second + for first, second in zip( + duplicate_axis, duplicate["_canonical_axis"] + ) + ) + duplicate["v_range"] = sorted( + ( + value - duplicate_location_scalar + ) + / duplicate_sign + for value in duplicate["_absolute_interval"] + ) + for operation in merged_operations: + operation.pop("_canonical_axis", None) + operation.pop("_canonical_anchor", None) + operation.pop("_absolute_interval", None) + operation["source_face_ids"] = sorted( + set(operation["source_face_ids"]) + ) + + parameter["value"] = _number(new_value) + parameter["editable"] = True + parameter["edit_state"] = "edited_pending_acceptance" + result["edit_operations"] = merged_operations + preferred_boundary_strategy = result.get( + "reconstruction_strategy", {} + ).get("boundary_strategy", "analytic_uv_rect") + result["reconstruction_strategy"] = { + "boundary_strategy": preferred_boundary_strategy, + "selection_status": "semantic_edit_candidate", + } + result["active_edit"] = { + "parameter": parameter_name, + "semantic_role": role, + "old_value": _number(old_value), + "new_value": _number(new_value), + "edited_face_ids": sorted(edited_face_ids), + "operation_count": len(merged_operations), + "range_status": ( + "within_validated_range" + if parameter.get("validated_range") + and new_value + <= float( + parameter["validated_range"]["maximum_tested_inclusive"] + ) + else "requires_revalidation" + ), + "acceptance_status": "pending", + } + return result + + +def _point_value(values: list[float]) -> gp_Pnt: + return gp_Pnt(*map(float, values)) + + +def _direction_value(values: list[float]) -> gp_Dir: + return gp_Dir(*map(float, values)) + + +def _ax3_value(payload: dict[str, Any]) -> gp_Ax3: + result = gp_Ax3( + _point_value(payload["location"]), + _direction_value(payload["axis"]), + _direction_value(payload["x_direction"]), + ) + if bool(result.Direct()) != bool(payload.get("direct", True)): + result.YReverse() + return result + + +def _ax2_value(payload: dict[str, Any]) -> gp_Ax2: + return gp_Ax2( + _point_value(payload["location"]), + _direction_value(payload["axis"]), + _direction_value(payload["x_direction"]), + ) + + +def _real_array(values: list[float]) -> TColStd_Array1OfReal: + result = TColStd_Array1OfReal(1, len(values)) + for index, value in enumerate(values, 1): + result.SetValue(index, float(value)) + return result + + +def _integer_array(values: list[int]) -> TColStd_Array1OfInteger: + result = TColStd_Array1OfInteger(1, len(values)) + for index, value in enumerate(values, 1): + result.SetValue(index, int(value)) + return result + + +def _point_array(values: list[list[float]]) -> TColgp_Array1OfPnt: + result = TColgp_Array1OfPnt(1, len(values)) + for index, value in enumerate(values, 1): + result.SetValue(index, _point_value(value)) + return result + + +def _point2d_array(values: list[list[float]]) -> TColgp_Array1OfPnt2d: + result = TColgp_Array1OfPnt2d(1, len(values)) + for index, value in enumerate(values, 1): + result.SetValue(index, gp_Pnt2d(*map(float, value))) + return result + + +def _pcurve_from_record(payload: dict[str, Any]) -> Any: + kind = payload["type"] + if kind == "line": + return Geom2d_Line( + gp_Lin2d( + gp_Pnt2d(*map(float, payload["location"])), + gp_Dir2d(*map(float, payload["direction"])), + ) + ) + if kind == "circle": + axis = gp_Ax22d( + gp_Pnt2d(*map(float, payload["location"])), + gp_Dir2d(*map(float, payload["x_direction"])), + gp_Dir2d(*map(float, payload["y_direction"])), + ) + return Geom2d_Circle(gp_Circ2d(axis, float(payload["radius"]))) + if kind == "ellipse": + axis = gp_Ax22d( + gp_Pnt2d(*map(float, payload["location"])), + gp_Dir2d(*map(float, payload["x_direction"])), + gp_Dir2d(*map(float, payload["y_direction"])), + ) + return Geom2d_Ellipse( + gp_Elips2d( + axis, + float(payload["major_radius"]), + float(payload["minor_radius"]), + ) + ) + if kind == "bspline": + poles = _point2d_array(payload["poles"]) + knots = _real_array(payload["knots"]) + mults = _integer_array(payload["multiplicities"]) + if payload.get("rational"): + return Geom2d_BSplineCurve( + poles, + _real_array(payload["weights"]), + knots, + mults, + int(payload["degree"]), + bool(payload["periodic"]), + ) + return Geom2d_BSplineCurve( + poles, + knots, + mults, + int(payload["degree"]), + bool(payload["periodic"]), + ) + raise ValueError(f"Unsupported pcurve record: {kind}") + + +def _curve_from_record( + payload: dict[str, Any], + surface: Any | None = None, + pcurve_payload: dict[str, Any] | None = None, + vertices: list[Any] | None = None, +) -> Any: + vertices = vertices or [] + kind = payload["type"] + if surface is not None and pcurve_payload is not None: + first, last = map(float, pcurve_payload["parameter_range"]) + arguments: list[Any] = [ + _pcurve_from_record(pcurve_payload), + surface, + ] + if len(vertices) == 2: + arguments.extend(vertices) + arguments.extend([first, last]) + edge = BRepBuilderAPI_MakeEdge(*arguments).Edge() + if kind == "degenerate": + BRep_Builder().Degenerated(edge, True) + return edge + first, last = map(float, payload.get("parameter_range", [0.0, 1.0])) + if kind == "line": + curve = Geom_Line( + gp_Lin( + _point_value(payload["location"]), + _direction_value(payload["direction"]), + ) + ) + arguments = [curve] + if len(vertices) == 2: + arguments.extend(vertices) + arguments.extend([first, last]) + return BRepBuilderAPI_MakeEdge(*arguments).Edge() + if kind == "circle": + curve = Geom_Circle( + gp_Circ(_ax2_value(payload["position"]), float(payload["radius"])) + ) + arguments = [curve] + if len(vertices) == 2: + arguments.extend(vertices) + arguments.extend([first, last]) + return BRepBuilderAPI_MakeEdge(*arguments).Edge() + if kind == "ellipse": + curve = Geom_Ellipse( + gp_Elips( + _ax2_value(payload["position"]), + float(payload["major_radius"]), + float(payload["minor_radius"]), + ) + ) + arguments = [curve] + if len(vertices) == 2: + arguments.extend(vertices) + arguments.extend([first, last]) + return BRepBuilderAPI_MakeEdge(*arguments).Edge() + if kind == "bspline": + poles = _point_array(payload["poles"]) + knots = _real_array(payload["knots"]) + mults = _integer_array(payload["multiplicities"]) + if payload.get("rational"): + curve = Geom_BSplineCurve( + poles, + _real_array(payload["weights"]), + knots, + mults, + int(payload["degree"]), + bool(payload["periodic"]), + ) + else: + curve = Geom_BSplineCurve( + poles, + knots, + mults, + int(payload["degree"]), + bool(payload["periodic"]), + ) + arguments = [curve] + if len(vertices) == 2: + arguments.extend(vertices) + arguments.extend([first, last]) + return BRepBuilderAPI_MakeEdge(*arguments).Edge() + if kind == "degenerate": + return None + raise ValueError(f"Unsupported curve record: {kind}") + + +def _surface_from_record(payload: dict[str, Any]) -> Any: + kind = payload["type"] + if kind == "plane": + return Geom_Plane(_ax3_value(payload["position"])) + if kind == "cylinder": + return Geom_CylindricalSurface( + _ax3_value(payload["position"]), float(payload["radius"]) + ) + if kind == "cone": + return Geom_ConicalSurface( + _ax3_value(payload["position"]), + float(payload["semi_angle_radians"]), + float(payload["reference_radius"]), + ) + if kind == "sphere": + return Geom_SphericalSurface( + _ax3_value(payload["position"]), float(payload["radius"]) + ) + if kind == "torus": + return Geom_ToroidalSurface( + _ax3_value(payload["position"]), + float(payload["major_radius"]), + float(payload["minor_radius"]), + ) + if kind == "bspline": + poles_data = payload["poles"] + weights_data = payload["weights"] + poles = TColgp_Array2OfPnt( + 1, len(poles_data), 1, len(poles_data[0]) + ) + weights = TColStd_Array2OfReal( + 1, len(weights_data), 1, len(weights_data[0]) + ) + for u_index, row in enumerate(poles_data, 1): + for v_index, value in enumerate(row, 1): + poles.SetValue(u_index, v_index, _point_value(value)) + weights.SetValue( + u_index, v_index, float(weights_data[u_index - 1][v_index - 1]) + ) + arguments = [ + poles, + _real_array(payload["u_knots"]), + _real_array(payload["v_knots"]), + _integer_array(payload["u_multiplicities"]), + _integer_array(payload["v_multiplicities"]), + int(payload["u_degree"]), + int(payload["v_degree"]), + bool(payload["u_periodic"]), + bool(payload["v_periodic"]), + ] + if payload.get("u_rational") or payload.get("v_rational"): + arguments.insert(1, weights) + return Geom_BSplineSurface(*arguments) + raise ValueError(f"Unsupported surface record: {kind}") + + +def _wire_from_record( + payload: dict[str, Any], + surface: Any, + boundary_strategy: str, + edge_cache: dict[str, Any], + vertex_cache: dict[str, Any], +) -> Any: + builder = BRepBuilderAPI_MakeWire() + resolved_edges: list[tuple[dict[str, Any], Any]] = [] + for edge_payload in payload["edges"]: + topology_edge_id = str( + edge_payload.get("topology_edge_id", edge_payload["id"]) + ) + use_shared_edge = boundary_strategy != "first_pcurve" + edge = edge_cache.get(topology_edge_id) if use_shared_edge else None + if edge is None: + vertices = [ + vertex_cache[vertex_id] + for vertex_id in edge_payload.get("vertex_ids", []) + if vertex_id in vertex_cache + ] + use_parametric_curve = ( + boundary_strategy == "first_pcurve" + or edge_payload["curve"]["type"] == "degenerate" + ) + try: + edge = _curve_from_record( + edge_payload["curve"], + surface if use_parametric_curve else None, + edge_payload.get("pcurve") + if use_parametric_curve + else None, + vertices, + ) + except Exception as exc: + raise ValueError( + f"Cannot rebuild edge {edge_payload['id']}: " + f"{type(exc).__name__}: {exc}" + ) from exc + if edge is not None and use_shared_edge: + edge_cache[topology_edge_id] = edge + if edge is None: + continue + tolerance = max(float(edge_payload.get("tolerance", 1e-7)), 1e-7) + BRep_Builder().UpdateEdge(edge, tolerance) + resolved_edges.append((edge_payload, edge)) + if boundary_strategy == "exact_3d_pcurve": + edge_occurrences: dict[ + str, tuple[Any, list[dict[str, Any]]] + ] = {} + for edge_payload, edge in resolved_edges: + pcurve = edge_payload.get("pcurve") + if pcurve is None: + continue + topology_edge_id = str( + edge_payload.get("topology_edge_id", edge_payload["id"]) + ) + if topology_edge_id not in edge_occurrences: + edge_occurrences[topology_edge_id] = (edge, []) + edge_occurrences[topology_edge_id][1].append(edge_payload) + edge_builder = BRep_Builder() + location = TopLoc_Location() + for topology_edge_id, (edge, occurrences) in edge_occurrences.items(): + if len(occurrences) > 2: + raise ValueError( + "Cannot bind more than two pcurves for shared edge " + f"{topology_edge_id} on one face" + ) + tolerance = max( + max( + float(occurrence.get("tolerance", 1e-7)) + for occurrence in occurrences + ), + 1e-7, + ) + pcurves = [ + _pcurve_from_record(occurrence["pcurve"]) + for occurrence in occurrences + ] + try: + if len(pcurves) == 2: + edge_builder.UpdateEdge( + edge, + pcurves[0], + pcurves[1], + surface, + location, + tolerance, + ) + else: + edge_builder.UpdateEdge( + edge, pcurves[0], surface, location, tolerance + ) + first, last = map( + float, occurrences[0]["pcurve"]["parameter_range"] + ) + edge_builder.Range(edge, surface, location, first, last) + except Exception as exc: + raise ValueError( + f"Cannot bind pcurve for edge {topology_edge_id}: " + f"{type(exc).__name__}: {exc}" + ) from exc + for edge_payload, edge in resolved_edges: + if int(edge_payload["orientation"]) == 1: + edge = TopoDS.Edge_s(edge.Reversed()) + builder.Add(edge) + if not builder.IsDone(): + raise ValueError(f"Cannot rebuild wire {payload['id']}") + wire = builder.Wire() + if int(payload.get("orientation", 0)) == 1: + wire = TopoDS.Wire_s(wire.Reversed()) + return wire + + +def _face_from_record( + payload: dict[str, Any], + boundary_strategy: str, + edge_cache: dict[str, Any], + vertex_cache: dict[str, Any], +) -> Any: + surface = _surface_from_record(payload["surface"]) + wire_payloads = sorted(payload["wires"], key=lambda item: not item.get("outer")) + rectangular_parametric_patch = ( + ( + payload.get("edit_rebuild_strategy") == "uv_rect" + or payload["surface"]["type"] == "torus" + or ( + boundary_strategy in {"uv_rect_all", "analytic_uv_rect"} + and payload["surface"]["type"] != "plane" + and ( + boundary_strategy == "uv_rect_all" + or payload["surface"]["type"] != "bspline" + ) + ) + ) + and len(wire_payloads) == 1 + and all( + edge.get("pcurve", {}).get("type") == "line" + and ( + abs(float(edge["pcurve"]["direction"][0])) <= 1e-9 + or abs(float(edge["pcurve"]["direction"][1])) <= 1e-9 + ) + for edge in wire_payloads[0]["edges"] + ) + ) + if not wire_payloads or rectangular_parametric_patch: + u_min, u_max, v_min, v_max = map(float, payload["uv_bounds"]) + face = BRepBuilderAPI_MakeFace( + surface, u_min, u_max, v_min, v_max, 1e-7 + ).Face() + else: + try: + outer = _wire_from_record( + wire_payloads[0], + surface, + boundary_strategy, + edge_cache, + vertex_cache, + ) + except Exception as exc: + raise ValueError( + f"Cannot rebuild outer wire {wire_payloads[0]['id']}: " + f"{type(exc).__name__}: {exc}" + ) from exc + inner_wires = [] + for inner_payload in wire_payloads[1:]: + try: + inner_wires.append( + _wire_from_record( + inner_payload, + surface, + boundary_strategy, + edge_cache, + vertex_cache, + ) + ) + except Exception as exc: + raise ValueError( + f"Cannot rebuild inner wire {inner_payload['id']}: " + f"{type(exc).__name__}: {exc}" + ) from exc + + def make_trimmed_face(outer_wire: Any, inner_wire_list: list[Any]) -> Any: + face_builder = BRepBuilderAPI_MakeFace(surface, outer_wire, True) + for inner_wire in inner_wire_list: + face_builder.Add(inner_wire) + if not face_builder.IsDone(): + raise ValueError(f"Cannot rebuild face {payload['id']}") + return face_builder.Face() + + face = make_trimmed_face(outer, inner_wires) + BRepLib.BuildCurves3d_s(face) + if int(payload["orientation"]) == 1: + face = TopoDS.Face_s(face.Reversed()) + return face + + +def _axis_affine_transform( + axis: list[float], + pivot: list[float], + axial_factor: float, + radial_factor: float, +) -> gp_GTrsf: + axis_length = math.sqrt(sum(float(value) ** 2 for value in axis)) + if axis_length <= 1e-12: + raise ValueError("Axis-affine edit requires a non-zero axis") + unit = [float(value) / axis_length for value in axis] + axial = float(axial_factor) + radial = float(radial_factor) + if ( + not math.isfinite(axial) + or not math.isfinite(radial) + or axial <= 0 + or radial <= 0 + ): + raise ValueError("Axis-affine factors must be finite and positive") + values = [ + radial * (1.0 if row == column else 0.0) + + (axial - radial) * unit[row] * unit[column] + for row in range(3) + for column in range(3) + ] + matrix = gp_Mat(*values) + pivot_values = [float(value) for value in pivot] + transformed_pivot = [ + sum( + values[row * 3 + column] * pivot_values[column] + for column in range(3) + ) + for row in range(3) + ] + translation = [ + pivot_values[index] - transformed_pivot[index] + for index in range(3) + ] + transform = gp_GTrsf() + transform.SetVectorialPart(matrix) + transform.SetTranslationPart(gp_XYZ(*translation)) + return transform + + +def _apply_edit_operations(shape: Any, payload: dict[str, Any]) -> Any: + operations = payload.get("edit_operations", []) + if not operations: + return shape + fixer = ShapeFix_Shape(shape) + fixer.SetPrecision(1e-7) + fixer.SetMaxTolerance(1e-5) + fixer.Perform() + result = fixer.Shape() + for operation in operations: + operation_name = operation.get("operation") + if operation_name == "axis_affine_scale": + transform = _axis_affine_transform( + operation["axis"], + operation["pivot"], + float(operation["axial_factor"]), + float(operation["radial_factor"]), + ) + builder = BRepBuilderAPI_GTransform(result, transform, True) + if not builder.IsDone(): + raise ValueError("Failed axis-affine scale operation") + result = builder.Shape() + continue + if operation_name == "uniform_scale": + factor = float(operation["factor"]) + if not math.isfinite(factor) or factor <= 0: + raise ValueError("Uniform scale factor must be finite and positive") + transform = gp_Trsf() + transform.SetScale( + _point_value(operation["pivot"]), + factor, + ) + builder = BRepBuilderAPI_Transform(result, transform, True) + if not builder.IsDone(): + raise ValueError("Failed uniform scale operation") + result = builder.Shape() + continue + if operation_name != "enlarge_cylindrical_cut": + raise ValueError( + f"Unsupported SurfaceIR edit operation: {operation_name}" + ) + v_min, v_max = sorted(map(float, operation["v_range"])) + height = v_max - v_min + if height <= 1e-9: + raise ValueError("Cylindrical cut edit has zero axial span") + axial_margin = max(1e-6, height * 1e-7) + v_min -= axial_margin + height += 2.0 * axial_margin + position = operation["position"] + axis = _direction_value(position["axis"]) + location = _point_value(position["location"]) + base = gp_Pnt( + location.X() + v_min * axis.X(), + location.Y() + v_min * axis.Y(), + location.Z() + v_min * axis.Z(), + ) + cutter_axis = gp_Ax2( + base, + axis, + _direction_value(position["x_direction"]), + ) + cutter = BRepPrimAPI_MakeCylinder( + cutter_axis, float(operation["new_radius"]), height + ).Shape() + algorithm = BRepAlgoAPI_Cut(result, cutter) + algorithm.Build() + if not algorithm.IsDone(): + raise ValueError( + f"Failed semantic cut for {operation.get('face_id')}" + ) + result = algorithm.Shape() + return result + + +def build_surfaceir( + payload: dict[str, Any], boundary_strategy: str | None = None +) -> Any: + boundary_strategy = boundary_strategy or payload.get( + "reconstruction_strategy", {} + ).get("boundary_strategy", "exact_3d") + if boundary_strategy not in { + "exact_3d", + "exact_3d_pcurve", + "first_pcurve", + "analytic_uv_rect", + "uv_rect_all", + }: + raise ValueError(f"Unknown boundary strategy: {boundary_strategy}") + rebuilt_solids = [] + rebuilt_free_shells = [] + edge_cache: dict[str, Any] = {} + vertex_cache: dict[str, Any] = {} + vertex_builder = BRep_Builder() + for vertex_payload in payload["surface_layer"].get("vertices", []): + vertex = BRepBuilderAPI_MakeVertex( + _point_value(vertex_payload["point"]) + ).Vertex() + vertex_builder.UpdateVertex( + vertex, max(float(vertex_payload.get("tolerance", 1e-7)), 1e-7) + ) + vertex_cache[str(vertex_payload["id"])] = vertex + for solid_payload in payload["surface_layer"]["solids"]: + rebuilt_shells = [] + for shell_payload in solid_payload["shells"]: + shell_tolerance = max( + [ + float(edge.get("tolerance", 1e-7)) + for face in shell_payload["faces"] + for wire in face["wires"] + for edge in wire["edges"] + ] + or [1e-7] + ) + sewing = BRepBuilderAPI_Sewing( + max(shell_tolerance, 1e-7), True, True, True, False + ) + for face_payload in shell_payload["faces"]: + try: + face = _face_from_record( + face_payload, + boundary_strategy, + edge_cache, + vertex_cache, + ) + except Exception as exc: + raise ValueError( + f"Cannot rebuild face {face_payload['id']}: " + f"{type(exc).__name__}: {exc}" + ) from exc + sewing.Add(face) + sewing.Perform() + sewed = sewing.SewedShape() + shell_explorer = TopExp_Explorer(sewed, TopAbs_SHELL) + if not shell_explorer.More(): + raise ValueError(f"Cannot sew shell {shell_payload['id']}") + shell = TopoDS.Shell_s(shell_explorer.Current()) + if boundary_strategy == "exact_3d_pcurve": + rebuilt_shells.append(shell) + else: + solid_builder = BRepBuilderAPI_MakeSolid(shell) + if not solid_builder.IsDone(): + raise ValueError( + f"Cannot rebuild solid {solid_payload['id']}" + ) + rebuilt_solids.append(solid_builder.Solid()) + if boundary_strategy == "exact_3d_pcurve": + solid_builder = BRepBuilderAPI_MakeSolid() + for shell in rebuilt_shells: + solid_builder.Add(shell) + if not solid_builder.IsDone(): + raise ValueError(f"Cannot rebuild solid {solid_payload['id']}") + rebuilt_solids.append(solid_builder.Solid()) + for shell_payload in payload["surface_layer"].get("free_shells", []): + shell_builder = BRep_Builder() + shell = TopoDS_Shell() + shell_builder.MakeShell(shell) + for face_payload in shell_payload["faces"]: + try: + shell_builder.Add( + shell, + _face_from_record( + face_payload, + boundary_strategy, + edge_cache, + vertex_cache, + ) + ) + except Exception as exc: + raise ValueError( + f"Cannot rebuild free-shell face {face_payload['id']}: " + f"{type(exc).__name__}: {exc}" + ) from exc + rebuilt_free_shells.append(shell) + components = rebuilt_solids + rebuilt_free_shells + if len(components) == 1: + result = components[0] + else: + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for component in components: + builder.Add(compound, component) + result = compound + return _apply_edit_operations(result, payload) + + +def extract_surfaceir(step_path: Path) -> dict[str, Any]: + source_bytes = step_path.read_bytes() + reader = STEPControl_Reader() + if reader.ReadFile(str(step_path)) != IFSelect_RetDone: + raise ValueError("OCCT failed to read STEP") + reader.TransferRoots() + shape = reader.OneShape() + topology = ( + {"vertices": [], "solids": [], "free_shells": []} + if shape.IsNull() + else _topology_records(shape) + ) + solids = topology["solids"] + free_shells = topology.get("free_shells", []) + faces = _surfaceir_faces(topology) + surface_types = sorted( + {face["surface"]["type"] for face in faces} + ) + curve_types = sorted( + { + edge["curve"]["type"] + for face in faces + for wire in face["wires"] + for edge in wire["edges"] + } + ) + digest = hashlib.sha256(source_bytes).hexdigest() + modification_levels = ["analytic_surface"] + if "bspline" in surface_types or "bspline" in curve_types: + modification_levels.append("spline_control") + semantic_layer = _cylinder_semantics(topology) + if (solids or free_shells) and topology["vertices"]: + points = [ + [float(value) for value in vertex["point"]] + for vertex in topology["vertices"] + ] + scale_pivot = [ + _number( + ( + min(point[axis_index] for point in points) + + max(point[axis_index] for point in points) + ) + / 2.0 + ) + for axis_index in range(3) + ] + semantic_layer["datums"]["overall_scale_pivot"] = { + "point": scale_pivot, + "inference": "surfaceir_vertex_bounds_center", + "confidence": 1.0, + } + semantic_layer["parameters"]["overall_scale"] = { + "value": 1.0, + "unit": "ratio", + "editable": False, + "semantic_role": "overall_scale", + "inference": "universal_source_independent_transform", + "confidence": 1.0, + "pivot_datum": "overall_scale_pivot", + "edit_state": "executable_uniform_scale_binding", + } + semantic_layer["features"].append( + { + "id": "overall_scale_control", + "operation": "uniform_scale", + "parameter": "overall_scale", + "pivot_datum": "overall_scale_pivot", + "inference": "canonical_edit_fallback", + "original_history_recovered": False, + "confidence": 1.0, + } + ) + if len(semantic_layer["parameters"]) == 1: + for axis_name, axis_index in zip(("x", "y", "z"), range(3)): + extent = ( + max(point[axis_index] for point in points) + - min(point[axis_index] for point in points) + ) + if extent <= 1e-9: + continue + parameter_name = f"overall_size_{axis_name}" + semantic_layer["parameters"][parameter_name] = { + "value": _number(extent), + "unit": "mm", + "editable": False, + "semantic_role": parameter_name, + "inference": "canonical_global_axis_bounds_extent", + "confidence": 1.0, + "pivot_datum": "overall_scale_pivot", + "axis": axis_name, + "edit_state": "executable_axis_affine_binding", + } + semantic_layer["features"].append( + { + "id": f"{parameter_name}_control", + "operation": "axis_affine_scale", + "parameter": parameter_name, + "pivot_datum": "overall_scale_pivot", + "axis": axis_name, + "inference": "canonical_envelope_dimension", + "original_history_recovered": False, + "confidence": 1.0, + } + ) + executable_semantic_parameters = sorted( + name + for name, parameter in semantic_layer["parameters"].items() + if parameter.get("edit_state") + in { + "executable_enlarge_cut_binding", + "executable_axis_affine_binding", + "executable_uniform_scale_binding", + } + ) + return { + "schema_version": "3.0", + "designir_kind": "independent_parametric_cad", + "model_id": f"surfaceir_{digest[:16]}", + "units": "mm", + "document_status": ( + "geometry_present" + if solids or free_shells + else "empty_geometry" + ), + "reconstruction_mode": "surface_parametric", + "semantic_layer": semantic_layer, + "surface_layer": { + "vertices": topology["vertices"], + "solids": solids, + "free_shells": free_shells, + "surface_vocabulary": surface_types, + "curve_vocabulary": curve_types, + }, + "edit_interface": { + "semantic_parameters": executable_semantic_parameters, + "surface_parameter_groups": [ + "surface_layer.solids.*.shells.*.faces.*.surface", + "surface_layer.solids.*.shells.*.faces.*.wires.*.edges.*.curve", + ], + "modification_levels": modification_levels, + }, + "validation_contract": { + "source_independence": True, + "geometry_checks": [ + "solid_count", + "volume", + "center_of_mass", + "bounding_box", + "symmetric_difference", + "surface_distance", + ], + "edit_checks": [ + "edited_parameter_changes_geometry", + "unselected_parameter_groups_remain_stable", + "result_is_valid_manifold", + ], + }, + } + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + + +def extract_folder(input_dir: Path, output_dir: Path) -> dict[str, Any]: + sources = sorted( + [ + path + for path in input_dir.iterdir() + if path.is_file() and path.suffix.lower() in {".step", ".stp"} + ] + ) + rows = [] + surface_counts: Counter[str] = Counter() + curve_counts: Counter[str] = Counter() + for source in sources: + output = output_dir / f"{source.stem}.designir.json" + try: + payload = extract_surfaceir(source) + write_json(output, payload) + surface_counts.update(payload["surface_layer"]["surface_vocabulary"]) + curve_counts.update(payload["surface_layer"]["curve_vocabulary"]) + rows.append( + { + "source_name": source.name, + "model_id": payload["model_id"], + "output_name": output.name, + "state": "extracted", + "solid_count": len(payload["surface_layer"]["solids"]), + "free_shell_count": len( + payload["surface_layer"].get("free_shells", []) + ), + "document_status": payload["document_status"], + "surface_vocabulary": payload["surface_layer"][ + "surface_vocabulary" + ], + "curve_vocabulary": payload["surface_layer"][ + "curve_vocabulary" + ], + "byte_count": output.stat().st_size, + } + ) + except Exception as exc: + rows.append( + { + "source_name": source.name, + "state": "failed", + "error": f"{type(exc).__name__}: {exc}", + } + ) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_surface_extraction", + "input_count": len(sources), + "extracted_count": sum(row["state"] == "extracted" for row in rows), + "failed_count": sum(row["state"] == "failed" for row in rows), + "case_presence_by_surface_type": dict(sorted(surface_counts.items())), + "case_presence_by_curve_type": dict(sorted(curve_counts.items())), + "total_json_bytes": sum(row.get("byte_count", 0) for row in rows), + "rows": rows, + } + write_json(output_dir / "manifest.json", report) + return report + + +def augment_overall_scale(designir_dir: Path, output: Path) -> dict[str, Any]: + """Add a source-independent uniform-scale fallback to existing DesignIR.""" + + manifest = json.loads( + (designir_dir / "manifest.json").read_text(encoding="utf-8") + ) + rows = [] + for manifest_row in manifest["rows"]: + if manifest_row["state"] != "extracted": + continue + designir_path = designir_dir / manifest_row["output_name"] + payload = json.loads(designir_path.read_text(encoding="utf-8")) + vertices = payload.get("surface_layer", {}).get("vertices", []) + if ( + payload.get("document_status") != "geometry_present" + or not vertices + ): + rows.append( + { + "source_name": manifest_row["source_name"], + "state": "empty_geometry_skipped", + } + ) + continue + points = [ + [float(value) for value in vertex["point"]] + for vertex in vertices + ] + pivot = [ + _number( + ( + min(point[index] for point in points) + + max(point[index] for point in points) + ) + / 2.0 + ) + for index in range(3) + ] + semantic_layer = payload.setdefault("semantic_layer", {}) + datums = semantic_layer.setdefault("datums", {}) + parameters = semantic_layer.setdefault("parameters", {}) + features = semantic_layer.setdefault("features", []) + datums["overall_scale_pivot"] = { + "point": pivot, + "inference": "surfaceir_vertex_bounds_center", + "confidence": 1.0, + } + parameters["overall_scale"] = { + "value": 1.0, + "unit": "ratio", + "editable": False, + "semantic_role": "overall_scale", + "inference": "universal_source_independent_transform", + "confidence": 1.0, + "pivot_datum": "overall_scale_pivot", + "edit_state": "executable_uniform_scale_binding", + } + features[:] = [ + feature + for feature in features + if feature.get("id") != "overall_scale_control" + ] + features.append( + { + "id": "overall_scale_control", + "operation": "uniform_scale", + "parameter": "overall_scale", + "pivot_datum": "overall_scale_pivot", + "inference": "canonical_edit_fallback", + "original_history_recovered": False, + "confidence": 1.0, + } + ) + edit_parameters = payload.setdefault("edit_interface", {}).setdefault( + "semantic_parameters", [] + ) + if "overall_scale" not in edit_parameters: + edit_parameters.append("overall_scale") + edit_parameters.sort() + write_json(designir_path, payload) + rows.append( + { + "source_name": manifest_row["source_name"], + "state": "overall_scale_added", + "pivot": pivot, + } + ) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_overall_scale_augmentation", + "case_count": len(rows), + "augmented_count": sum( + row["state"] == "overall_scale_added" for row in rows + ), + "skipped_count": sum( + row["state"] != "overall_scale_added" for row in rows + ), + "rows": rows, + } + write_json(output, report) + return report + + +def augment_axis_affine_bindings( + designir_dir: Path, output: Path +) -> dict[str, Any]: + """Bind primary-axis dimensions only where no feature edit is validated.""" + + manifest = json.loads( + (designir_dir / "manifest.json").read_text(encoding="utf-8") + ) + rows = [] + for manifest_row in manifest["rows"]: + if manifest_row["state"] != "extracted": + continue + designir_path = designir_dir / manifest_row["output_name"] + payload = json.loads(designir_path.read_text(encoding="utf-8")) + if payload.get("document_status") != "geometry_present": + continue + parameters = ( + payload.get("semantic_layer", {}).get("parameters", {}) + ) + has_validated_feature_edit = any( + name != "overall_scale" + and parameter.get("edit_state") + == "validated_executable_binding" + for name, parameter in parameters.items() + ) + bound = [] + if not has_validated_feature_edit: + for name, parameter in parameters.items(): + if parameter.get("semantic_role") not in { + "outer_diameter", + "body_length", + "plate_thickness", + }: + continue + if not parameter.get("affected_face_ids"): + continue + parameter["editable"] = False + parameter["edit_state"] = "executable_axis_affine_binding" + bound.append(name) + edit_parameters = payload.setdefault("edit_interface", {}).setdefault( + "semantic_parameters", [] + ) + for name in bound: + if name not in edit_parameters: + edit_parameters.append(name) + edit_parameters.sort() + if bound: + write_json(designir_path, payload) + rows.append( + { + "source_name": manifest_row["source_name"], + "state": ( + "axis_affine_bound" + if bound + else "not_applicable" + ), + "parameters": sorted(bound), + } + ) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_axis_affine_binding_augmentation", + "case_count": len(rows), + "applicable_case_count": sum( + row["state"] == "axis_affine_bound" for row in rows + ), + "parameter_count": sum(len(row["parameters"]) for row in rows), + "rows": rows, + } + write_json(output, report) + return report + + +def augment_directional_size_bindings( + designir_dir: Path, output: Path +) -> dict[str, Any]: + """Add canonical global-axis dimensions to remaining scale-only models.""" + + manifest = json.loads( + (designir_dir / "manifest.json").read_text(encoding="utf-8") + ) + rows = [] + for manifest_row in manifest["rows"]: + if manifest_row["state"] != "extracted": + continue + designir_path = designir_dir / manifest_row["output_name"] + payload = json.loads(designir_path.read_text(encoding="utf-8")) + if payload.get("document_status") != "geometry_present": + continue + semantic_layer = payload.get("semantic_layer", {}) + parameters = semantic_layer.get("parameters", {}) + has_validated_named_edit = any( + name != "overall_scale" + and parameter.get("edit_state") + == "validated_executable_binding" + for name, parameter in parameters.items() + ) + bound = [] + if not has_validated_named_edit: + vertices = payload["surface_layer"]["vertices"] + points = [ + [float(value) for value in vertex["point"]] + for vertex in vertices + ] + pivot = [ + _number( + ( + min(point[index] for point in points) + + max(point[index] for point in points) + ) + / 2.0 + ) + for index in range(3) + ] + semantic_layer.setdefault("datums", {})[ + "directional_size_pivot" + ] = { + "point": pivot, + "inference": "surfaceir_vertex_bounds_center", + "confidence": 1.0, + } + features = semantic_layer.setdefault("features", []) + for axis_name, axis_index in zip(("x", "y", "z"), range(3)): + extent = ( + max(point[axis_index] for point in points) + - min(point[axis_index] for point in points) + ) + if extent <= 1e-9: + continue + parameter_name = f"overall_size_{axis_name}" + parameters[parameter_name] = { + "value": _number(extent), + "unit": "mm", + "editable": False, + "semantic_role": parameter_name, + "inference": "canonical_global_axis_bounds_extent", + "confidence": 1.0, + "pivot_datum": "directional_size_pivot", + "axis": axis_name, + "edit_state": "executable_axis_affine_binding", + } + features[:] = [ + feature + for feature in features + if feature.get("id") + != f"{parameter_name}_control" + ] + features.append( + { + "id": f"{parameter_name}_control", + "operation": "axis_affine_scale", + "parameter": parameter_name, + "pivot_datum": "directional_size_pivot", + "axis": axis_name, + "inference": "canonical_envelope_dimension", + "original_history_recovered": False, + "confidence": 1.0, + } + ) + bound.append(parameter_name) + edit_parameters = payload.setdefault("edit_interface", {}).setdefault( + "semantic_parameters", [] + ) + for name in bound: + if name not in edit_parameters: + edit_parameters.append(name) + edit_parameters.sort() + if bound: + write_json(designir_path, payload) + rows.append( + { + "source_name": manifest_row["source_name"], + "state": ( + "directional_sizes_bound" + if bound + else "not_applicable" + ), + "parameters": sorted(bound), + } + ) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_directional_size_binding_augmentation", + "case_count": len(rows), + "applicable_case_count": sum( + row["state"] == "directional_sizes_bound" for row in rows + ), + "parameter_count": sum(len(row["parameters"]) for row in rows), + "rows": rows, + } + write_json(output, report) + return report + + +def _facts_from_shape(shape: Any) -> dict[str, Any]: + wrapped = shape.wrapped if hasattr(shape, "wrapped") else shape + bounds = Bnd_Box() + BRepBndLib.Add_s(wrapped, bounds) + if bounds.IsVoid(): + x_min = y_min = z_min = x_max = y_max = z_max = 0.0 + else: + ( + x_min, + y_min, + z_min, + x_max, + y_max, + z_max, + ) = bounds.Get() + properties = GProp_GProps() + BRepGProp.VolumeProperties_s(wrapped, properties) + volume_value = float(properties.Mass()) + surface_properties = GProp_GProps() + BRepGProp.SurfaceProperties_s(wrapped, surface_properties) + surface_area = float(surface_properties.Mass()) + if abs(float(properties.Mass())) <= 1e-12: + properties = surface_properties + center = properties.CentreOfMass() + counts = {} + for name, shape_type in { + "solid_count": TopAbs_SOLID, + "face_count": TopAbs_FACE, + "edge_count": TopAbs_EDGE, + "vertex_count": TopAbs_VERTEX, + }.items(): + shape_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(wrapped, shape_type, shape_map) + counts[name] = shape_map.Extent() + return { + **counts, + "volume": float(volume_value), + "surface_area": surface_area, + "center_of_mass": [float(center.X()), float(center.Y()), float(center.Z())], + "bounds_min": [float(x_min), float(y_min), float(z_min)], + "bounds_max": [float(x_max), float(y_max), float(z_max)], + "valid": bool(BRepCheck_Analyzer(wrapped).IsValid()), + } + + +def _shape_facts(path: Path) -> tuple[Any, dict[str, Any]]: + try: + shape = import_step(str(path)) + return shape, _facts_from_shape(shape) + except AssertionError: + reader = STEPControl_Reader() + if reader.ReadFile(str(path)) != IFSelect_RetDone: + raise ValueError(f"OCCT failed to read STEP facts: {path}") + reader.TransferRoots() + wrapped = reader.OneShape() + if wrapped.IsNull(): + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + wrapped = compound + shape = wrapped + return shape, _facts_from_shape(shape) + + +def _max_error(first: list[float], second: list[float]) -> float: + return max(abs(a - b) for a, b in zip(first, second)) + + +def _sample_shape_points(shape: Any) -> list[gp_Pnt]: + wrapped = shape.wrapped if hasattr(shape, "wrapped") else shape + points: list[gp_Pnt] = [] + vertex_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(wrapped, TopAbs_VERTEX, vertex_map) + for index in range(1, vertex_map.Extent() + 1): + points.append( + BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex_map.FindKey(index))) + ) + edge_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(wrapped, TopAbs_EDGE, edge_map) + for index in range(1, edge_map.Extent() + 1): + edge = TopoDS.Edge_s(edge_map.FindKey(index)) + adaptor = BRepAdaptor_Curve(edge) + first = float(adaptor.FirstParameter()) + last = float(adaptor.LastParameter()) + if not math.isfinite(first) or not math.isfinite(last): + continue + for fraction in (0.25, 0.5, 0.75): + points.append(adaptor.Value(first + fraction * (last - first))) + face_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(wrapped, TopAbs_FACE, face_map) + for index in range(1, face_map.Extent() + 1): + face = TopoDS.Face_s(face_map.FindKey(index)) + u_min, u_max, v_min, v_max = map( + float, BRepTools.UVBounds_s(face) + ) + if not all( + math.isfinite(value) + for value in (u_min, u_max, v_min, v_max) + ): + continue + adaptor = BRepAdaptor_Surface(face) + for u_fraction in (0.25, 0.5, 0.75): + for v_fraction in (0.25, 0.5, 0.75): + u_value = u_min + u_fraction * (u_max - u_min) + v_value = v_min + v_fraction * (v_max - v_min) + classifier = BRepClass_FaceClassifier( + face, gp_Pnt2d(u_value, v_value), 1e-7 + ) + if classifier.State() in {TopAbs_IN, TopAbs_ON}: + points.append(adaptor.Value(u_value, v_value)) + return points + + +def _directed_sample_distance( + source_shape: Any, target_shape: Any +) -> tuple[float, int]: + target = ( + target_shape.wrapped + if hasattr(target_shape, "wrapped") + else target_shape + ) + distance = BRepExtrema_DistShapeShape() + distance.LoadS2(target) + maximum = 0.0 + points = _sample_shape_points(source_shape) + for point in points: + vertex = BRepBuilderAPI_MakeVertex(point).Vertex() + distance.LoadS1(vertex) + distance.Perform() + if not distance.IsDone(): + raise ValueError("Surface sample distance evaluation failed") + maximum = max(maximum, float(distance.Value())) + return maximum, len(points) + + +def validate_folder( + input_dir: Path, + designir_dir: Path, + rebuilt_dir: Path, + limit: int | None, + offset: int, + boolean_check: bool, + boundary_strategy: str, +) -> dict[str, Any]: + manifest = json.loads( + (designir_dir / "manifest.json").read_text(encoding="utf-8") + ) + rows = [row for row in manifest["rows"] if row["state"] == "extracted"] + rows = rows[offset:] + if limit is not None: + rows = rows[:limit] + results = [] + for row in rows: + source = input_dir / row["source_name"] + designir_path = designir_dir / row["output_name"] + rebuilt = rebuilt_dir / f"{Path(row['source_name']).stem}.step" + payload = json.loads(designir_path.read_text(encoding="utf-8")) + try: + shape = build_surfaceir(payload, boundary_strategy) + export_step_shape(shape, rebuilt) + teacher_shape, teacher = _shape_facts(source) + rebuilt_shape, rebuilt_facts = _shape_facts(rebuilt) + volume_base = max(abs(teacher["volume"]), 1e-12) + relative_volume_error = ( + abs(teacher["volume"] - rebuilt_facts["volume"]) / volume_base + ) + area_base = max(abs(teacher["surface_area"]), 1e-12) + relative_surface_area_error = abs( + teacher["surface_area"] - rebuilt_facts["surface_area"] + ) / area_base + center_error = math.dist( + teacher["center_of_mass"], rebuilt_facts["center_of_mass"] + ) + bounds_error = max( + _max_error(teacher["bounds_min"], rebuilt_facts["bounds_min"]), + _max_error(teacher["bounds_max"], rebuilt_facts["bounds_max"]), + ) + topology_match = all( + teacher[name] == rebuilt_facts[name] + for name in ["solid_count", "face_count", "edge_count"] + ) + vertex_count_delta = ( + rebuilt_facts["vertex_count"] - teacher["vertex_count"] + ) + symmetric_difference_ratio = None + bidirectional_sample_distance = None + sample_point_count = None + if boolean_check and teacher["solid_count"] > 0: + try: + symmetric_difference_ratio = float( + (teacher_shape - rebuilt_shape).volume + + (rebuilt_shape - teacher_shape).volume + ) / volume_base + except Exception: + symmetric_difference_ratio = math.inf + if symmetric_difference_ratio > 1e-8: + forward_distance, forward_count = ( + _directed_sample_distance( + teacher_shape, rebuilt_shape + ) + ) + reverse_distance, reverse_count = ( + _directed_sample_distance( + rebuilt_shape, teacher_shape + ) + ) + bidirectional_sample_distance = max( + forward_distance, reverse_distance + ) + sample_point_count = forward_count + reverse_count + geometry_pass = ( + rebuilt_facts["valid"] + and topology_match + and ( + relative_volume_error <= 1e-7 + if teacher["solid_count"] > 0 + else relative_surface_area_error <= 1e-7 + ) + and center_error <= 1e-6 + and bounds_error <= 1e-6 + and ( + symmetric_difference_ratio is None + or symmetric_difference_ratio <= 1e-8 + or ( + bidirectional_sample_distance is not None + and bidirectional_sample_distance <= 1e-4 + ) + ) + ) + results.append( + { + "source_name": row["source_name"], + "state": "validated" if geometry_pass else "geometry_mismatch", + "geometry_pass": geometry_pass, + "surface_vocabulary": payload["surface_layer"][ + "surface_vocabulary" + ], + "curve_vocabulary": payload["surface_layer"]["curve_vocabulary"], + "teacher": teacher, + "rebuilt": rebuilt_facts, + "topology_match": topology_match, + "vertex_count_delta": vertex_count_delta, + "relative_volume_error": relative_volume_error, + "relative_surface_area_error": relative_surface_area_error, + "center_of_mass_error": center_error, + "bounds_error": bounds_error, + "symmetric_difference_ratio": symmetric_difference_ratio, + "bidirectional_sample_distance": ( + bidirectional_sample_distance + ), + "surface_sample_point_count": sample_point_count, + } + ) + except Exception as exc: + results.append( + { + "source_name": row["source_name"], + "state": "rebuild_failed", + "geometry_pass": False, + "surface_vocabulary": payload["surface_layer"][ + "surface_vocabulary" + ], + "curve_vocabulary": payload["surface_layer"]["curve_vocabulary"], + "error": f"{type(exc).__name__}: {exc}", + } + ) + state_counts = Counter(row["state"] for row in results) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_independent_rebuild_validation", + "case_count": len(results), + "offset": offset, + "boundary_strategy": boundary_strategy, + "geometry_pass_count": sum(row["geometry_pass"] for row in results), + "geometry_fail_count": sum(not row["geometry_pass"] for row in results), + "state_counts": dict(sorted(state_counts.items())), + "thresholds": { + "relative_volume_error_max": 1e-7, + "relative_surface_area_error_max": 1e-7, + "center_of_mass_error_mm_max": 1e-6, + "bounds_error_mm_max": 1e-6, + "symmetric_difference_ratio_max": 1e-8 if boolean_check else None, + "bidirectional_sample_distance_mm_max": ( + 1e-4 if boolean_check else None + ), + "topology_must_match": True, + "vertex_count_normalization_is_diagnostic": True, + }, + "rows": results, + } + write_json(rebuilt_dir / "validation-report.json", report) + return report + + +def reclassify_validation_report( + report_path: Path, output: Path +) -> dict[str, Any]: + """Reapply the current deterministic thresholds to measured geometry.""" + + report = json.loads(report_path.read_text(encoding="utf-8")) + for row in report["rows"]: + if "teacher" not in row or "rebuilt" not in row: + row["geometry_pass"] = False + continue + teacher = row["teacher"] + rebuilt = row["rebuilt"] + row["topology_match"] = all( + int(teacher[name]) == int(rebuilt[name]) + for name in ("solid_count", "face_count", "edge_count") + ) + row["vertex_count_delta"] = ( + int(rebuilt["vertex_count"]) - int(teacher["vertex_count"]) + ) + metric_pass = ( + float(row["relative_volume_error"]) <= 1e-7 + if int(teacher["solid_count"]) > 0 + else float(row["relative_surface_area_error"]) <= 1e-7 + ) + symmetric_difference = row.get("symmetric_difference_ratio") + sample_distance = row.get("bidirectional_sample_distance") + distance_pass = ( + symmetric_difference is None + or float(symmetric_difference) <= 1e-8 + or ( + sample_distance is not None + and float(sample_distance) <= 1e-4 + ) + ) + row["geometry_pass"] = bool( + rebuilt["valid"] + and row["topology_match"] + and metric_pass + and float(row["center_of_mass_error"]) <= 1e-6 + and float(row["bounds_error"]) <= 1e-6 + and distance_pass + ) + row["state"] = ( + "validated" if row["geometry_pass"] else "geometry_mismatch" + ) + state_counts = Counter(row["state"] for row in report["rows"]) + report["geometry_pass_count"] = sum( + bool(row["geometry_pass"]) for row in report["rows"] + ) + report["geometry_fail_count"] = sum( + not bool(row["geometry_pass"]) for row in report["rows"] + ) + report["state_counts"] = dict(sorted(state_counts.items())) + report["thresholds"] = { + "relative_volume_error_max": 1e-7, + "relative_surface_area_error_max": 1e-7, + "center_of_mass_error_mm_max": 1e-6, + "bounds_error_mm_max": 1e-6, + "symmetric_difference_ratio_max": 1e-8, + "bidirectional_sample_distance_mm_max": 1e-4, + "topology_must_match": True, + "vertex_count_normalization_is_diagnostic": True, + } + report["report_kind"] = ( + "designir_3_authoritative_reclassified_rebuild_validation" + ) + report["measurement_source_report"] = str(report_path) + write_json(output, report) + return report + + +def select_validated_strategies( + designir_dir: Path, + primary_report_path: Path, + fallback_report_paths: list[Path], + output: Path, +) -> dict[str, Any]: + primary = json.loads(primary_report_path.read_text(encoding="utf-8")) + fallbacks = [ + json.loads(path.read_text(encoding="utf-8")) + for path in fallback_report_paths + ] + if any( + primary["case_count"] != fallback["case_count"] + for fallback in fallbacks + ): + raise ValueError("Strategy reports must contain the same number of cases") + rows = [] + for index, primary_row in enumerate(primary["rows"]): + fallback_rows = [fallback["rows"][index] for fallback in fallbacks] + if any( + primary_row["source_name"] != fallback_row["source_name"] + for fallback_row in fallback_rows + ): + raise ValueError("Strategy reports are not paired by source") + if primary_row["geometry_pass"]: + selected = primary["boundary_strategy"] + selected_row = primary_row + status = "geometry_validated" + elif any(row["geometry_pass"] for row in fallback_rows): + selected_index = next( + position + for position, row in enumerate(fallback_rows) + if row["geometry_pass"] + ) + selected = fallbacks[selected_index]["boundary_strategy"] + selected_row = fallback_rows[selected_index] + status = "geometry_validated" + else: + selected = primary["boundary_strategy"] + selected_row = primary_row + status = "unresolved" + designir_path = ( + designir_dir / f"{Path(primary_row['source_name']).stem}.designir.json" + ) + payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload["reconstruction_strategy"] = { + "boundary_strategy": selected, + "selection_status": status, + } + write_json(designir_path, payload) + rows.append( + { + "source_name": primary_row["source_name"], + "selected_boundary_strategy": selected, + "selection_status": status, + "geometry_pass": bool(selected_row["geometry_pass"]), + "selected_state": selected_row["state"], + "primary_geometry_pass": primary_row["geometry_pass"], + "fallback_geometry_passes": [ + row["geometry_pass"] for row in fallback_rows + ], + } + ) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_validated_strategy_selection", + "case_count": len(rows), + "geometry_validated_count": sum( + row["selection_status"] == "geometry_validated" for row in rows + ), + "geometry_pass_count": sum(row["geometry_pass"] for row in rows), + "geometry_fail_count": sum(not row["geometry_pass"] for row in rows), + "unresolved_count": sum( + row["selection_status"] == "unresolved" for row in rows + ), + "selected_strategy_counts": dict( + sorted(Counter(row["selected_boundary_strategy"] for row in rows).items()) + ), + "rows": rows, + } + write_json(output, report) + return report + + +def export_step_shape(shape: Any, output: Path) -> None: + writer = STEPControl_Writer() + transfer_status = writer.Transfer(shape, STEPControl_AsIs) + if transfer_status != IFSelect_RetDone: + raise ValueError("OCCT failed to transfer rebuilt SurfaceIR shape") + output.parent.mkdir(parents=True, exist_ok=True) + write_status = writer.Write(str(output)) + if write_status != IFSelect_RetDone: + raise ValueError("OCCT failed to write rebuilt SurfaceIR STEP") + + +def _observe_internal_cylinders_from_shape(shape: Any) -> list[dict[str, Any]]: + wrapped = shape.wrapped if hasattr(shape, "wrapped") else shape + observations = [] + explorer = TopExp_Explorer(wrapped, TopAbs_FACE) + index = 0 + while explorer.More(): + index += 1 + face = TopoDS.Face_s(explorer.Current()) + adaptor = BRepAdaptor_Surface(face) + if adaptor.GetType() == GeomAbs_Cylinder and int(face.Orientation()) == 1: + cylinder = adaptor.Cylinder() + axis, anchor = _normalized_axis( + { + "axis": _direction(cylinder.Position().Direction()), + "location": _point(cylinder.Position().Location()), + } + ) + observations.append( + { + "face_id": f"observed_face_{index}", + "axis": axis, + "anchor": anchor, + "radius": float(cylinder.Radius()), + } + ) + explorer.Next() + return observations + + +def _match_edit_cylinders( + edited: dict[str, Any], observed_cylinders: list[dict[str, Any]] +) -> list[dict[str, Any]]: + target_rows = [] + unmatched = list(observed_cylinders) + for operation in edited.get("edit_operations", []): + expected_axis, expected_anchor = _normalized_axis(operation["position"]) + expected_radius = float(operation["new_radius"]) + radius_tolerance = max(1e-5, expected_radius * 1e-5) + axis_tolerance = 1e-5 + match_index = next( + ( + index + for index, cylinder in enumerate(unmatched) + if _parallel(expected_axis, cylinder["axis"]) + and _distance(expected_anchor, cylinder["anchor"]) + <= axis_tolerance + and abs(cylinder["radius"] - expected_radius) + <= radius_tolerance + ), + None, + ) + observed_radius = None + observed_face_id = None + if match_index is not None: + match = unmatched.pop(match_index) + observed_radius = match["radius"] + observed_face_id = match["face_id"] + target_rows.append( + { + "source_face_id": operation["face_id"], + "expected_radius": expected_radius, + "observed_radius": observed_radius, + "observed_face_id": observed_face_id, + "pass": match_index is not None, + } + ) + return target_rows + + +def _observe_axis_affine_target( + payload: dict[str, Any], + operation: dict[str, Any], + expected_value: float, +) -> dict[str, Any]: + vertex_points = { + vertex["id"]: [float(value) for value in vertex["point"]] + for vertex in payload["surface_layer"]["vertices"] + } + axis = [float(value) for value in operation["axis"]] + pivot = [float(value) for value in operation["pivot"]] + role = operation["semantic_role"] + if role in {"overall_size_x", "overall_size_y", "overall_size_z"}: + coordinates = [ + _dot( + [ + point[index] - pivot[index] + for index in range(3) + ], + axis, + ) + for point in vertex_points.values() + ] + observed_value = max(coordinates) - min(coordinates) + return { + "semantic_role": role, + "observed_value": _number(observed_value), + "target_measure": "bounds_extent", + "observed_vertex_count": len(coordinates), + } + candidates = [] + for face in _surfaceir_faces(payload["surface_layer"]): + vertex_ids = { + vertex_id + for wire in face["wires"] + for edge in wire["edges"] + for vertex_id in edge.get("vertex_ids", []) + } + points = [ + vertex_points[vertex_id] + for vertex_id in vertex_ids + if vertex_id in vertex_points + ] + if not points: + continue + axial_coordinates = [] + radial_distances = [] + for point in points: + delta = [ + point[index] - pivot[index] for index in range(3) + ] + axial = _dot(delta, axis) + axial_coordinates.append(axial) + radial_distances.append( + math.sqrt( + sum( + ( + delta[index] - axial * axis[index] + ) + ** 2 + for index in range(3) + ) + ) + ) + radial_mean = sum(radial_distances) / len(radial_distances) + radial_spread = max( + abs(value - radial_mean) for value in radial_distances + ) + radial_tolerance = max(1e-5, radial_mean * 1e-5) + if radial_spread > radial_tolerance: + continue + observed_value = ( + 2.0 * radial_mean + if role == "outer_diameter" + else max(axial_coordinates) - min(axial_coordinates) + ) + if observed_value <= 1e-9: + continue + candidates.append( + { + "face_id": face["id"], + "observed_value": observed_value, + "boundary_vertex_count": len(points), + "absolute_error": abs(observed_value - expected_value), + } + ) + if not candidates: + return { + "semantic_role": role, + "observed_value": None, + "reason": "no_axis_aligned_face_measurement", + } + match = min(candidates, key=lambda candidate: candidate["absolute_error"]) + return { + "semantic_role": role, + "observed_value": _number(match["observed_value"]), + "observed_face_id": match["face_id"], + "source_target_face_id": operation["target_face_id"], + "boundary_vertex_count": match["boundary_vertex_count"], + "candidate_count": len(candidates), + } + + +def validate_semantic_edit( + teacher_step: Path, + designir_path: Path, + parameter_name: str, + value: float, + output_dir: Path, +) -> dict[str, Any]: + """Acceptance-Agent check for one isolated semantic perturbation.""" + + payload = json.loads(designir_path.read_text(encoding="utf-8")) + edited = apply_semantic_parameter(payload, parameter_name, value) + output_dir.mkdir(parents=True, exist_ok=True) + edited_ir_path = output_dir / f"{designir_path.stem}.edited.json" + edited_step_path = output_dir / f"{designir_path.stem}.edited.step" + + checks: dict[str, Any] = {} + try: + teacher_shape, teacher_facts = _shape_facts(teacher_step) + operation_names = [ + operation.get("operation") + for operation in edited.get("edit_operations", []) + ] + is_uniform_scale = operation_names == ["uniform_scale"] + is_axis_affine = operation_names == ["axis_affine_scale"] + is_scale_transform = is_uniform_scale or is_axis_affine + scale_operation = ( + edited["edit_operations"][0] if is_scale_transform else None + ) + preferred = edited.get("reconstruction_strategy", {}).get( + "boundary_strategy", "analytic_uv_rect" + ) + strategies = list( + dict.fromkeys( + [ + preferred, + "exact_3d_pcurve", + "exact_3d", + "analytic_uv_rect", + "first_pcurve", + "uv_rect_all", + ] + ) + ) + strategy_attempts = [] + selected_result = None + expected_removed_volume = ( + 0.0 + if is_scale_transform + else sum( + math.pi + * ( + float(operation["new_radius"]) ** 2 + - float(operation["old_radius"]) ** 2 + ) + * abs( + float(operation["v_range"][1]) + - float(operation["v_range"][0]) + ) + for operation in edited.get("edit_operations", []) + ) + ) + removal_tolerance = max( + 1e-6, + expected_removed_volume * 1e-4, + abs(teacher_facts["volume"]) * 1e-8, + ) + for strategy in strategies: + trial = copy.deepcopy(edited) + trial["reconstruction_strategy"] = { + "boundary_strategy": strategy, + "selection_status": "semantic_edit_candidate", + } + try: + rebuilt = build_surfaceir(trial) + export_step_shape(rebuilt, edited_step_path) + trial_shape, trial_facts = _shape_facts(edited_step_path) + volume_delta = ( + trial_facts["volume"] - teacher_facts["volume"] + ) + if is_scale_transform: + if is_uniform_scale: + factor = float(scale_operation["factor"]) + pivot = [ + float(component) + for component in scale_operation["pivot"] + ] + expected_volume = ( + teacher_facts["volume"] * factor**3 + ) + expected_center = [ + pivot[index] + + factor + * ( + teacher_facts["center_of_mass"][index] + - pivot[index] + ) + for index in range(3) + ] + expected_bounds_min = [ + pivot[index] + + factor + * ( + teacher_facts["bounds_min"][index] + - pivot[index] + ) + for index in range(3) + ] + expected_bounds_max = [ + pivot[index] + + factor + * ( + teacher_facts["bounds_max"][index] + - pivot[index] + ) + for index in range(3) + ] + target_observation = { + "semantic_role": "overall_scale", + "factor": factor, + } + else: + baseline_topology = build_surfaceir( + payload, strategy + ) + expected_topology = _apply_edit_operations( + baseline_topology, + {"edit_operations": [scale_operation]}, + ) + expected_step_path = ( + output_dir + / "expected-axis-affine-normalized.step" + ) + export_step_shape( + expected_topology, expected_step_path + ) + _, expected_facts = _shape_facts( + expected_step_path + ) + expected_volume = expected_facts["volume"] + expected_center = expected_facts["center_of_mass"] + expected_bounds_min = expected_facts["bounds_min"] + expected_bounds_max = expected_facts["bounds_max"] + factor = ( + float(scale_operation["radial_factor"]) + if scale_operation["semantic_role"] + == "outer_diameter" + else float(scale_operation["axial_factor"]) + ) + observed_ir = extract_surfaceir(edited_step_path) + expected_parameter_value = float(value) + target_observation = _observe_axis_affine_target( + observed_ir, + scale_operation, + expected_parameter_value, + ) + observed_value = target_observation[ + "observed_value" + ] + semantic_tolerance = max( + 1e-5, abs(expected_parameter_value) * 1e-5 + ) + target_observation.update( + { + "expected_value": expected_parameter_value, + "pass": observed_value is not None + and abs( + observed_value - expected_parameter_value + ) + <= semantic_tolerance, + } + ) + volume_error = abs( + trial_facts["volume"] - expected_volume + ) / max(abs(expected_volume), 1e-12) + center_error = math.dist( + trial_facts["center_of_mass"], expected_center + ) + bounds_error = max( + _max_error( + trial_facts["bounds_min"], expected_bounds_min + ), + _max_error( + trial_facts["bounds_max"], expected_bounds_max + ), + ) + topology_match = all( + trial_facts[name] == teacher_facts[name] + for name in ( + "solid_count", + "face_count", + "edge_count", + ) + ) + vertex_count_delta = ( + trial_facts["vertex_count"] + - teacher_facts["vertex_count"] + ) + basic_pass = ( + bool(trial_facts["valid"]) + and topology_match + and volume_error <= 1e-7 + and center_error <= 1e-6 + and bounds_error <= 1e-6 + and target_observation.get("pass", True) + ) + trial_targets = [ + { + **target_observation, + "expected_volume": expected_volume, + "observed_volume": trial_facts["volume"], + "relative_volume_error": volume_error, + "center_error": center_error, + "bounds_error": bounds_error, + "topology_match": topology_match, + "vertex_count_delta": vertex_count_delta, + "pass": basic_pass, + } + ] + strategy_attempts.append( + { + "boundary_strategy": strategy, + "pass": basic_pass, + "solid_count": trial_facts["solid_count"], + "valid": trial_facts["valid"], + "volume_delta": volume_delta, + "scale_factor": factor, + "operation": operation_names[0], + "relative_volume_error": volume_error, + "center_error": center_error, + "bounds_error": bounds_error, + "topology_match": topology_match, + "vertex_count_delta": vertex_count_delta, + "target_observation": target_observation, + } + ) + else: + trial_observed = _observe_internal_cylinders_from_shape( + trial_shape + ) + trial_targets = _match_edit_cylinders( + trial, trial_observed + ) + trial_removed_volume = -volume_delta + basic_pass = ( + bool(trial_facts["valid"]) + and trial_facts["solid_count"] + == teacher_facts["solid_count"] + and bool(trial_targets) + and all(row["pass"] for row in trial_targets) + and volume_delta + < -max( + 1e-8, abs(teacher_facts["volume"]) * 1e-10 + ) + ) + strategy_attempts.append( + { + "boundary_strategy": strategy, + "pass": basic_pass, + "solid_count": trial_facts["solid_count"], + "valid": trial_facts["valid"], + "target_axis_match_count": sum( + row["pass"] for row in trial_targets + ), + "target_axis_count": len(trial_targets), + "volume_delta": volume_delta, + "removed_volume": trial_removed_volume, + "expected_removed_volume": expected_removed_volume, + "removal_tolerance": removal_tolerance, + } + ) + if basic_pass: + selected_result = ( + trial, + trial_shape, + trial_facts, + trial_targets, + ) + break + except Exception as strategy_exc: + strategy_attempts.append( + { + "boundary_strategy": strategy, + "pass": False, + "error": ( + f"{type(strategy_exc).__name__}: {strategy_exc}" + ), + } + ) + edited["active_edit"]["strategy_attempts"] = strategy_attempts + if selected_result is None: + raise ValueError("No semantic edit reconstruction strategy passed") + ( + edited, + edited_shape, + edited_facts, + target_rows, + ) = selected_result + edited["active_edit"]["selected_boundary_strategy"] = edited[ + "reconstruction_strategy" + ]["boundary_strategy"] + write_json(edited_ir_path, edited) + expected_parameters = payload["semantic_layer"]["parameters"] + expected_value = float(value) + checks["source_independence"] = { + "pass": not any( + token in edited_ir_path.read_text(encoding="utf-8").lower() + for token in ( + str(teacher_step).lower(), + teacher_step.name.lower(), + "import_step", + "stepcontrol_reader", + "step_text", + "\"brep\"", + "\"mesh\"", + ) + ) + } + checks["target_parameter"] = { + "pass": bool(target_rows) and all(row["pass"] for row in target_rows), + "expected": expected_value, + "observed_semantic_value": ( + expected_value if all(row["pass"] for row in target_rows) else None + ), + "axis_radius_matches": target_rows, + } + checks["valid_solid"] = { + "pass": bool(edited_facts["valid"]) + and edited_facts["solid_count"] == teacher_facts["solid_count"], + "teacher_solid_count": teacher_facts["solid_count"], + "edited_solid_count": edited_facts["solid_count"], + "edited_valid": edited_facts["valid"], + } + volume_delta = edited_facts["volume"] - teacher_facts["volume"] + surface_area_delta = ( + edited_facts["surface_area"] - teacher_facts["surface_area"] + ) + minimum_change = max( + 1e-8, abs(teacher_facts["volume"]) * 1e-10 + ) + minimum_surface_change = max( + 1e-8, abs(teacher_facts["surface_area"]) * 1e-10 + ) + checks["geometry_changed_as_expected"] = { + "pass": ( + ( + abs(volume_delta) > minimum_change + if teacher_facts["solid_count"] > 0 + else abs(surface_area_delta) > minimum_surface_change + ) + if is_scale_transform + else volume_delta < -minimum_change + ), + "teacher_volume": teacher_facts["volume"], + "edited_volume": edited_facts["volume"], + "volume_delta": volume_delta, + "teacher_surface_area": teacher_facts["surface_area"], + "edited_surface_area": edited_facts["surface_area"], + "surface_area_delta": surface_area_delta, + "expected_direction": ( + "increase" + if is_scale_transform + and ( + float(scale_operation.get("factor", 1.0)) > 1.0 + or float(scale_operation.get("axial_factor", 1.0)) + * float(scale_operation.get("radial_factor", 1.0)) + ** 2 + > 1.0 + ) + else "decrease" + ), + } + + invariant_rows = [] + for name, expected in expected_parameters.items(): + if name == parameter_name: + continue + expected_number = float(expected["value"]) + edited_number = float( + edited["semantic_layer"]["parameters"][name]["value"] + ) + invariant_rows.append( + { + "parameter": name, + "expected": expected_number, + "edited_designir": edited_number, + "pass": edited_number == expected_number, + } + ) + removed_volume = teacher_facts["volume"] - edited_facts["volume"] + checks["non_target_parameters"] = { + "pass": all(row["pass"] for row in invariant_rows) + and ( + operation_names + in (["uniform_scale"], ["axis_affine_scale"]) + if is_scale_transform + else all( + operation + == "enlarge_cylindrical_cut" + for operation in operation_names + ) + and removed_volume > minimum_change + ), + "rows": invariant_rows, + "geometry_guard": { + "removed_volume": removed_volume, + "expected_removed_volume": expected_removed_volume, + "tolerance": removal_tolerance, + "operation_whitelist": operation_names, + "expected": ( + "only one declared scale transform may change geometry" + if is_scale_transform + else "only declared cylindrical Cut operations may remove material; no additive operation is allowed" + ), + }, + } + accepted = all(check["pass"] for check in checks.values()) + state = "accepted" if accepted else "rejected" + except Exception as exc: + accepted = False + state = "execution_failed" + checks["execution"] = { + "pass": False, + "error": f"{type(exc).__name__}: {exc}", + } + + edited["active_edit"]["acceptance_status"] = state + write_json(edited_ir_path, edited) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_semantic_edit_acceptance", + "source_name": teacher_step.name, + "model_id": payload["model_id"], + "parameter": parameter_name, + "requested_value": float(value), + "state": state, + "accepted": accepted, + "checks": checks, + "artifacts": { + "edited_designir": edited_ir_path.name, + "edited_step": ( + edited_step_path.name if edited_step_path.is_file() else None + ), + }, + } + write_json(output_dir / "acceptance-report.json", report) + return report + + +def validate_edits_folder( + input_dir: Path, + designir_dir: Path, + output_dir: Path, + geometry_report_path: Path | None, + limit: int | None, + offset: int, + workers: int, + max_parameters_per_case: int, + timeout_seconds: int, + parameter_filter: str | None = None, +) -> dict[str, Any]: + """Run isolated Acceptance-Agent workers over a STEP corpus.""" + + manifest = json.loads( + (designir_dir / "manifest.json").read_text(encoding="utf-8") + ) + rows = [row for row in manifest["rows"] if row["state"] == "extracted"] + rows = rows[offset:] + if limit is not None: + rows = rows[:limit] + geometry_pass: dict[str, bool] = {} + if geometry_report_path is not None: + geometry_report = json.loads( + geometry_report_path.read_text(encoding="utf-8") + ) + geometry_pass = { + row["source_name"]: bool(row["geometry_pass"]) + for row in geometry_report["rows"] + } + + attempts = [] + case_rows = [] + for row in rows: + source_name = row["source_name"] + designir_path = designir_dir / row["output_name"] + payload = json.loads(designir_path.read_text(encoding="utf-8")) + if geometry_pass and not geometry_pass.get(source_name, False): + case_rows.append( + { + "source_name": source_name, + "state": "baseline_geometry_not_validated", + "attempt_count": 0, + } + ) + continue + parameters = payload.get("semantic_layer", {}).get("parameters", {}) + candidates = [ + name + for name in payload.get("edit_interface", {}).get( + "semantic_parameters", [] + ) + if name in parameters + and ( + parameter_filter is None + or name == parameter_filter + ) + and parameters[name].get("edit_state") + in { + "executable_enlarge_cut_binding", + "executable_axis_affine_binding", + "executable_uniform_scale_binding", + "validated_executable_binding", + } + ][:max_parameters_per_case] + if not candidates: + case_rows.append( + { + "source_name": source_name, + "state": "no_executable_semantic_parameter", + "attempt_count": 0, + } + ) + continue + case_rows.append( + { + "source_name": source_name, + "state": "scheduled", + "attempt_count": len(candidates), + } + ) + for parameter_name in candidates: + old_value = float(parameters[parameter_name]["value"]) + requested_value = old_value * 1.1 + safe_parameter = re.sub(r"[^a-zA-Z0-9_.-]+", "_", parameter_name) + attempt_dir = ( + output_dir / Path(source_name).stem / safe_parameter + ) + attempts.append( + { + "source_name": source_name, + "teacher": input_dir / source_name, + "designir": designir_path, + "parameter": parameter_name, + "old_value": old_value, + "requested_value": requested_value, + "output_dir": attempt_dir, + } + ) + + def run_attempt(attempt: dict[str, Any]) -> dict[str, Any]: + report_path = attempt["output_dir"] / "acceptance-report.json" + if report_path.is_file(): + report = json.loads(report_path.read_text(encoding="utf-8")) + if ( + report.get("parameter") == attempt["parameter"] + and abs( + float(report.get("requested_value", math.nan)) + - attempt["requested_value"] + ) + <= 1e-12 + and bool(report.get("accepted")) + ): + return { + **attempt, + "state": report["state"], + "accepted": bool(report["accepted"]), + "resumed": True, + "report": str(report_path), + } + command = [ + sys.executable, + str(Path(__file__).resolve()), + "validate-edit", + str(attempt["teacher"]), + str(attempt["designir"]), + "--parameter", + attempt["parameter"], + "--value", + str(attempt["requested_value"]), + "--output-dir", + str(attempt["output_dir"]), + ] + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + if completed.returncode == 0 and report_path.is_file(): + report = json.loads(report_path.read_text(encoding="utf-8")) + return { + **attempt, + "state": report["state"], + "accepted": bool(report["accepted"]), + "resumed": False, + "report": str(report_path), + } + return { + **attempt, + "state": "worker_failed", + "accepted": False, + "returncode": completed.returncode, + "stderr_tail": completed.stderr[-1000:], + } + except subprocess.TimeoutExpired: + return { + **attempt, + "state": "worker_timeout", + "accepted": False, + } + + attempt_rows = [] + output_dir.mkdir(parents=True, exist_ok=True) + with ThreadPoolExecutor(max_workers=max(1, workers)) as executor: + futures = [executor.submit(run_attempt, attempt) for attempt in attempts] + for future in as_completed(futures): + attempt_rows.append(future.result()) + attempt_rows.sort(key=lambda row: (row["source_name"], row["parameter"])) + + accepted_by_source = { + row["source_name"] for row in attempt_rows if row["accepted"] + } + attempted_by_source = {row["source_name"] for row in attempt_rows} + for row in case_rows: + if row["source_name"] in accepted_by_source: + row["state"] = "semantic_edit_accepted" + elif row["source_name"] in attempted_by_source: + row["state"] = "semantic_edit_rejected" + state_counts = Counter(row["state"] for row in case_rows) + attempt_state_counts = Counter(row["state"] for row in attempt_rows) + report = { + "schema_version": "1.0", + "report_kind": "designir_3_semantic_edit_batch_acceptance", + "case_count": len(case_rows), + "applicable_case_count": len(attempted_by_source), + "accepted_case_count": len(accepted_by_source), + "attempt_count": len(attempt_rows), + "accepted_attempt_count": sum(row["accepted"] for row in attempt_rows), + "case_state_counts": dict(sorted(state_counts.items())), + "attempt_state_counts": dict(sorted(attempt_state_counts.items())), + "perturbation": { + "kind": "multiply", + "factor": 1.1, + "maximum_parameters_per_case": max_parameters_per_case, + }, + "worker_policy": { + "process_isolation": True, + "workers": workers, + "timeout_seconds": timeout_seconds, + }, + "cases": case_rows, + "attempts": [ + { + key: ( + str(value) + if isinstance(value, Path) + else value + ) + for key, value in row.items() + } + for row in attempt_rows + ], + } + write_json(output_dir / "batch-acceptance-report.json", report) + return report + + +def merge_acceptance_reports( + baseline_path: Path, retry_paths: list[Path], output: Path +) -> dict[str, Any]: + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + attempts = { + (row["source_name"], row["parameter"]): row + for row in baseline["attempts"] + } + baseline_factor = float( + baseline.get("perturbation", {}).get("factor", 0.0) + ) + if baseline_factor > 0.0: + for row in attempts.values(): + if "old_value" not in row and "requested_value" in row: + row["old_value"] = ( + float(row["requested_value"]) / baseline_factor + ) + lineage = [str(baseline_path)] + for retry_path in retry_paths: + lineage.append(str(retry_path)) + if retry_path.is_dir(): + retry_rows = [] + for report_path in sorted( + retry_path.rglob("acceptance-report.json") + ): + retry_report = json.loads( + report_path.read_text(encoding="utf-8") + ) + retry_rows.append( + { + "source_name": retry_report["source_name"], + "parameter": retry_report["parameter"], + "requested_value": retry_report["requested_value"], + "state": retry_report["state"], + "accepted": bool(retry_report["accepted"]), + "resumed": False, + "report": str(report_path), + } + ) + else: + retry = json.loads(retry_path.read_text(encoding="utf-8")) + retry_rows = retry["attempts"] + for row in retry_rows: + key = (row["source_name"], row["parameter"]) + previous = attempts.get(key, {}) + merged_row = {**previous, **row} + if "old_value" in previous and "old_value" not in row: + merged_row["old_value"] = previous["old_value"] + attempts[key] = merged_row + attempt_rows = sorted( + attempts.values(), key=lambda row: (row["source_name"], row["parameter"]) + ) + accepted_by_source = { + row["source_name"] for row in attempt_rows if row["accepted"] + } + attempted_by_source = {row["source_name"] for row in attempt_rows} + case_rows = copy.deepcopy(baseline["cases"]) + for row in case_rows: + if row["source_name"] in accepted_by_source: + row["state"] = "semantic_edit_accepted" + elif row["source_name"] in attempted_by_source: + row["state"] = "semantic_edit_rejected" + report = copy.deepcopy(baseline) + report["report_kind"] = "designir_3_semantic_edit_merged_acceptance" + report["lineage"] = lineage + report["attempts"] = attempt_rows + report["cases"] = case_rows + report["applicable_case_count"] = len(attempted_by_source) + report["accepted_case_count"] = len(accepted_by_source) + report["attempt_count"] = len(attempt_rows) + report["accepted_attempt_count"] = sum( + row["accepted"] for row in attempt_rows + ) + report["case_state_counts"] = dict( + sorted(Counter(row["state"] for row in case_rows).items()) + ) + report["attempt_state_counts"] = dict( + sorted(Counter(row["state"] for row in attempt_rows).items()) + ) + factors = sorted( + { + round( + float(row["requested_value"]) / float(row["old_value"]), + 8, + ) + for row in attempt_rows + if float(row.get("old_value", 0.0)) != 0.0 + } + ) + if len(factors) > 1: + report["perturbation"] = { + "kind": "adaptive_multiply", + "validated_factors": factors, + "selection_policy": "largest attempted factor with a passing edit contract", + } + write_json(output, report) + return report + + +def apply_edit_validation_to_designir( + designir_dir: Path, acceptance_report_path: Path, output: Path +) -> dict[str, Any]: + """Persist measured edit bounds and rejections in private DesignIR files.""" + + report = json.loads( + acceptance_report_path.read_text(encoding="utf-8") + ) + rows = [] + for attempt in report["attempts"]: + source_name = attempt["source_name"] + parameter_name = attempt["parameter"] + designir_path = ( + designir_dir / f"{Path(source_name).stem}.designir.json" + ) + payload = json.loads(designir_path.read_text(encoding="utf-8")) + parameters = payload.get("semantic_layer", {}).get("parameters", {}) + if parameter_name not in parameters: + raise ValueError( + f"{source_name} is missing semantic parameter {parameter_name}" + ) + parameter = parameters[parameter_name] + old_value = float( + attempt.get("old_value", parameter["value"]) + ) + requested_value = float(attempt["requested_value"]) + if attempt["accepted"]: + parameter["editable"] = True + parameter["edit_state"] = "validated_executable_binding" + parameter["validated_range"] = { + "direction": "increase", + "minimum_tested_inclusive": _number(old_value), + "maximum_tested_inclusive": _number(requested_value), + "acceptance_contract": "designir_3_semantic_edit_acceptance", + } + state = "validated_edit_range" + else: + parameter["editable"] = False + parameter["edit_state"] = "rejected_by_edit_contract" + parameter["validation_failure"] = { + "requested_value": _number(requested_value), + "attempt_state": attempt["state"], + } + parameter.pop("validated_range", None) + state = "rejected_edit_binding" + write_json(designir_path, payload) + rows.append( + { + "source_name": source_name, + "parameter": parameter_name, + "state": state, + "old_value": _number(old_value), + "requested_value": _number(requested_value), + } + ) + summary = { + "schema_version": "1.0", + "report_kind": "designir_3_persisted_edit_validation", + "case_count": len({row["source_name"] for row in rows}), + "parameter_count": len(rows), + "validated_parameter_count": sum( + row["state"] == "validated_edit_range" for row in rows + ), + "rejected_parameter_count": sum( + row["state"] == "rejected_edit_binding" for row in rows + ), + "acceptance_report": str(acceptance_report_path), + "rows": rows, + } + write_json(output, summary) + return summary + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + extract = commands.add_parser("extract") + extract.add_argument("step", type=Path) + extract.add_argument("--output", type=Path, required=True) + rebuild = commands.add_parser("rebuild") + rebuild.add_argument("designir", type=Path) + rebuild.add_argument("--output", type=Path, required=True) + rebuild.add_argument( + "--boundary-strategy", + choices=[ + "exact_3d", + "exact_3d_pcurve", + "first_pcurve", + "analytic_uv_rect", + "uv_rect_all", + ], + ) + edit = commands.add_parser("edit") + edit.add_argument("designir", type=Path) + edit.add_argument("--parameter", required=True) + edit.add_argument("--value", type=float, required=True) + edit.add_argument("--output-designir", type=Path, required=True) + edit.add_argument("--output-step", type=Path) + validate_edit = commands.add_parser("validate-edit") + validate_edit.add_argument("teacher", type=Path) + validate_edit.add_argument("designir", type=Path) + validate_edit.add_argument("--parameter", required=True) + validate_edit.add_argument("--value", type=float, required=True) + validate_edit.add_argument("--output-dir", type=Path, required=True) + validate_edits = commands.add_parser("validate-edits-folder") + validate_edits.add_argument("input", type=Path) + validate_edits.add_argument("--designir-dir", type=Path, required=True) + validate_edits.add_argument("--output-dir", type=Path, required=True) + validate_edits.add_argument("--geometry-report", type=Path) + validate_edits.add_argument("--limit", type=int) + validate_edits.add_argument("--offset", type=int, default=0) + validate_edits.add_argument("--workers", type=int, default=4) + validate_edits.add_argument("--max-parameters-per-case", type=int, default=1) + validate_edits.add_argument("--timeout-seconds", type=int, default=60) + validate_edits.add_argument("--parameter") + merge_acceptance = commands.add_parser("merge-acceptance-reports") + merge_acceptance.add_argument("--baseline", type=Path, required=True) + merge_acceptance.add_argument( + "--retry", type=Path, action="append", required=True + ) + merge_acceptance.add_argument("--output", type=Path, required=True) + persist_edit_validation = commands.add_parser("persist-edit-validation") + persist_edit_validation.add_argument( + "--designir-dir", type=Path, required=True + ) + persist_edit_validation.add_argument( + "--acceptance-report", type=Path, required=True + ) + persist_edit_validation.add_argument("--output", type=Path, required=True) + augment_scale = commands.add_parser("augment-overall-scale") + augment_scale.add_argument("designir_dir", type=Path) + augment_scale.add_argument("--output", type=Path, required=True) + augment_axis = commands.add_parser("augment-axis-affine-bindings") + augment_axis.add_argument("designir_dir", type=Path) + augment_axis.add_argument("--output", type=Path, required=True) + augment_directional = commands.add_parser( + "augment-directional-size-bindings" + ) + augment_directional.add_argument("designir_dir", type=Path) + augment_directional.add_argument("--output", type=Path, required=True) + batch = commands.add_parser("extract-folder") + batch.add_argument("input", type=Path) + batch.add_argument("--output-dir", type=Path, required=True) + validate = commands.add_parser("validate-folder") + validate.add_argument("input", type=Path) + validate.add_argument("--designir-dir", type=Path, required=True) + validate.add_argument("--rebuilt-dir", type=Path, required=True) + validate.add_argument("--limit", type=int) + validate.add_argument("--offset", type=int, default=0) + validate.add_argument("--boolean", action="store_true") + validate.add_argument( + "--boundary-strategy", + choices=[ + "exact_3d", + "exact_3d_pcurve", + "first_pcurve", + "analytic_uv_rect", + "uv_rect_all", + ], + default="exact_3d", + ) + reclassify = commands.add_parser("reclassify-validation-report") + reclassify.add_argument("report", type=Path) + reclassify.add_argument("--output", type=Path, required=True) + select = commands.add_parser("select-strategies") + select.add_argument("--designir-dir", type=Path, required=True) + select.add_argument("--primary-report", type=Path, required=True) + select.add_argument( + "--fallback-report", type=Path, action="append", required=True + ) + select.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.command == "extract": + payload = extract_surfaceir(args.step.resolve()) + write_json(args.output.resolve(), payload) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "solid_count": len(payload["surface_layer"]["solids"]), + "surface_vocabulary": payload["surface_layer"][ + "surface_vocabulary" + ], + "curve_vocabulary": payload["surface_layer"][ + "curve_vocabulary" + ], + }, + ensure_ascii=False, + ) + ) + elif args.command == "rebuild": + payload = json.loads(args.designir.read_text(encoding="utf-8")) + shape = build_surfaceir(payload, args.boundary_strategy) + export_step_shape(shape, args.output) + print(json.dumps({"output": str(args.output.resolve())})) + elif args.command == "edit": + payload = json.loads(args.designir.read_text(encoding="utf-8")) + edited = apply_semantic_parameter( + payload, args.parameter, args.value + ) + write_json(args.output_designir.resolve(), edited) + output = { + "output_designir": str(args.output_designir.resolve()), + "parameter": args.parameter, + "value": args.value, + } + if args.output_step is not None: + shape = build_surfaceir(edited) + export_step_shape(shape, args.output_step.resolve()) + output["output_step"] = str(args.output_step.resolve()) + print(json.dumps(output, ensure_ascii=False)) + elif args.command == "validate-edit": + report = validate_semantic_edit( + args.teacher.resolve(), + args.designir.resolve(), + args.parameter, + args.value, + args.output_dir.resolve(), + ) + print( + json.dumps( + { + "report": str( + (args.output_dir.resolve() / "acceptance-report.json") + ), + "state": report["state"], + "accepted": report["accepted"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "validate-edits-folder": + report = validate_edits_folder( + args.input.resolve(), + args.designir_dir.resolve(), + args.output_dir.resolve(), + args.geometry_report.resolve() + if args.geometry_report is not None + else None, + args.limit, + args.offset, + args.workers, + args.max_parameters_per_case, + args.timeout_seconds, + args.parameter, + ) + print( + json.dumps( + { + "report": str( + ( + args.output_dir.resolve() + / "batch-acceptance-report.json" + ) + ), + "case_count": report["case_count"], + "applicable_case_count": report["applicable_case_count"], + "accepted_case_count": report["accepted_case_count"], + "attempt_count": report["attempt_count"], + "accepted_attempt_count": report[ + "accepted_attempt_count" + ], + "case_state_counts": report["case_state_counts"], + "attempt_state_counts": report["attempt_state_counts"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "merge-acceptance-reports": + report = merge_acceptance_reports( + args.baseline.resolve(), + [path.resolve() for path in args.retry], + args.output.resolve(), + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "applicable_case_count": report["applicable_case_count"], + "accepted_case_count": report["accepted_case_count"], + "attempt_state_counts": report["attempt_state_counts"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "persist-edit-validation": + report = apply_edit_validation_to_designir( + args.designir_dir.resolve(), + args.acceptance_report.resolve(), + args.output.resolve(), + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "parameter_count": report["parameter_count"], + "validated_parameter_count": report[ + "validated_parameter_count" + ], + "rejected_parameter_count": report[ + "rejected_parameter_count" + ], + }, + ensure_ascii=False, + ) + ) + elif args.command == "augment-overall-scale": + report = augment_overall_scale( + args.designir_dir.resolve(), + args.output.resolve(), + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "case_count": report["case_count"], + "augmented_count": report["augmented_count"], + "skipped_count": report["skipped_count"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "augment-axis-affine-bindings": + report = augment_axis_affine_bindings( + args.designir_dir.resolve(), + args.output.resolve(), + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "case_count": report["case_count"], + "applicable_case_count": report[ + "applicable_case_count" + ], + "parameter_count": report["parameter_count"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "augment-directional-size-bindings": + report = augment_directional_size_bindings( + args.designir_dir.resolve(), + args.output.resolve(), + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "case_count": report["case_count"], + "applicable_case_count": report[ + "applicable_case_count" + ], + "parameter_count": report["parameter_count"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "extract-folder": + report = extract_folder(args.input.resolve(), args.output_dir.resolve()) + print( + json.dumps( + { + "manifest": str( + (args.output_dir.resolve() / "manifest.json").resolve() + ), + "input_count": report["input_count"], + "extracted_count": report["extracted_count"], + "failed_count": report["failed_count"], + "total_json_bytes": report["total_json_bytes"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "validate-folder": + report = validate_folder( + args.input.resolve(), + args.designir_dir.resolve(), + args.rebuilt_dir.resolve(), + args.limit, + args.offset, + args.boolean, + args.boundary_strategy, + ) + print( + json.dumps( + { + "report": str( + (args.rebuilt_dir.resolve() / "validation-report.json") + ), + "case_count": report["case_count"], + "geometry_pass_count": report["geometry_pass_count"], + "geometry_fail_count": report["geometry_fail_count"], + "state_counts": report["state_counts"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "reclassify-validation-report": + report = reclassify_validation_report( + args.report.resolve(), + args.output.resolve(), + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "case_count": report["case_count"], + "geometry_pass_count": report[ + "geometry_pass_count" + ], + "geometry_fail_count": report[ + "geometry_fail_count" + ], + "state_counts": report["state_counts"], + }, + ensure_ascii=False, + ) + ) + elif args.command == "select-strategies": + report = select_validated_strategies( + args.designir_dir.resolve(), + args.primary_report.resolve(), + [path.resolve() for path in args.fallback_report], + args.output.resolve(), + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "case_count": report["case_count"], + "geometry_validated_count": report["geometry_validated_count"], + "unresolved_count": report["unresolved_count"], + "selected_strategy_counts": report["selected_strategy_counts"], + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/designir-pipeline/scripts/trajectory_pipeline.mjs b/designir-pipeline/scripts/trajectory_pipeline.mjs new file mode 100644 index 0000000..ae35a39 --- /dev/null +++ b/designir-pipeline/scripts/trajectory_pipeline.mjs @@ -0,0 +1,735 @@ +#!/usr/bin/env node +/** + * Build execution-validated STEP -> DesignIR trajectories. + * + * Agent A is an OpenAI-compatible language model. Agent B is the deterministic + * CAD-kernel acceptance program. The teacher STEP is never included in Agent A + * input and is used only by Agent B after isolated reconstruction. + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PIPELINE = path.resolve(HERE, ".."); +const REPO = path.resolve(PIPELINE, ".."); +const requireFromStudio = createRequire( + path.join(REPO, "cad-agent-studio", "package.json"), +); +const YAML = requireFromStudio("yaml"); +const CONFIG_PATH = path.join(REPO, "llm.config.yaml"); +const EVIDENCE_DIR = path.join(PIPELINE, "runs", "evidence"); +const TRAJECTORY_DIR = path.join(PIPELINE, "runs", "trajectory"); +const SPLIT_PATH = path.join(TRAJECTORY_DIR, "splits.json"); +const PYTHON = path.join(REPO, "text-to-cad", ".venv", "bin", "python"); +const DESIGNIR_CLI = path.join(PIPELINE, "scripts", "designir_pipeline.py"); +const EXAMPLE_PATH = path.join( + PIPELINE, + "examples", + "raised_hub_flange.designir.json", +); + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function argsObject(argv) { + const result = { _: [] }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith("--")) { + result._.push(value); + continue; + } + const key = value.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + result[key] = true; + } else { + result[key] = next; + index += 1; + } + } + return result; +} + +function evidenceFiles() { + return fs + .readdirSync(EVIDENCE_DIR) + .filter((name) => name.endsWith(".case.json")) + .map((name) => path.join(EVIDENCE_DIR, name)) + .sort(); +} + +function allocate(total, counts) { + const population = Object.values(counts).reduce((sum, value) => sum + value, 0); + const rows = Object.entries(counts).map(([family, count]) => { + const exact = (count * total) / population; + return { family, value: Math.floor(exact), remainder: exact % 1 }; + }); + let remaining = total - rows.reduce((sum, row) => sum + row.value, 0); + rows.sort((left, right) => right.remainder - left.remainder); + for (const row of rows) { + if (remaining <= 0) break; + row.value += 1; + remaining -= 1; + } + return Object.fromEntries(rows.map((row) => [row.family, row.value])); +} + +function initSplits() { + const unique = new Map(); + for (const file of evidenceFiles()) { + const payload = readJson(file); + const geometry = payload.geometry_evidence || {}; + if (!geometry.reconstruction_eligible) continue; + const id = String(payload.provenance?.source_sha256 || payload.case_id); + if (unique.has(id)) continue; + unique.set(id, { + id, + family: String(payload.design_ir?.part_family || "unknown"), + evidence: file, + teacher: String(payload.provenance?.source_path || ""), + face_count: Number(geometry.face_count || 0), + }); + } + const byFamily = {}; + for (const item of unique.values()) { + (byFamily[item.family] ||= []).push(item); + } + for (const items of Object.values(byFamily)) { + items.sort((left, right) => left.id.localeCompare(right.id)); + } + const counts = Object.fromEntries( + Object.entries(byFamily).map(([family, items]) => [family, items.length]), + ); + const testQuota = allocate(100, counts); + const validationQuota = allocate(75, counts); + const splits = { train: [], validation: [], test: [] }; + for (const [family, items] of Object.entries(byFamily)) { + const testCount = testQuota[family] || 0; + const validationCount = validationQuota[family] || 0; + splits.test.push(...items.slice(0, testCount)); + splits.validation.push( + ...items.slice(testCount, testCount + validationCount), + ); + splits.train.push(...items.slice(testCount + validationCount)); + } + for (const values of Object.values(splits)) { + values.sort( + (left, right) => + left.face_count - right.face_count || left.id.localeCompare(right.id), + ); + } + const payload = { + schema_version: "1.0", + split_kind: "sha256_isolated_stratified_trajectory_split", + policy: { + train_count: splits.train.length, + validation_count: splits.validation.length, + test_count: splits.test.length, + teacher_step_visible_to_reconstruction_agent: false, + heldout_cases_allowed_in_experience_induction: false, + }, + splits, + }; + writeJson(SPLIT_PATH, payload); + return payload; +} + +function loadProvider(requestedProvider, requestedModel) { + const config = YAML.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + const providerId = requestedProvider || config.defaultProvider; + const provider = config.providers[providerId]; + if (!provider) throw new Error(`Unknown provider ${providerId}`); + const apiKey = + String(provider.apiKey || "").trim() || + process.env[String(provider.apiKeyEnv || "")] || + ""; + if (!apiKey) throw new Error(`No API key configured for ${providerId}`); + const defaultModel = String(config.defaultModel || "").split(":").pop(); + const model = + requestedModel || provider.models?.default || defaultModel; + return { + providerId, + model, + apiKey, + baseURL: String(provider.baseURL).replace(/\/+$/, ""), + }; +} + +function compactEvidence(payload, maxSurfaces = 80) { + const geometry = payload.geometry_evidence || {}; + const design = payload.design_ir || {}; + return { + evidence_kind: "final_brep_observation_not_source_history", + geometry: { + solid_count: geometry.solid_count, + face_count: geometry.face_count, + volume: geometry.volume, + bounding_box: geometry.bounding_box, + analytic_surface_counts: geometry.analytic_surface_counts, + analytic_surfaces: (geometry.analytic_surfaces || []).slice(0, maxSurfaces), + }, + inferred_hypotheses: { + family: design.part_family, + features: design.features, + constraints: design.constraints, + semantic_parameter_candidates: design.semantic_parameter_candidates, + }, + }; +} + +function reconstructionPrompt(evidence, previous, feedback, experience = []) { + const example = readJson(EXAMPLE_PATH); + return [ + "You are Agent A, a STEP reverse-engineering program synthesizer.", + "Generate an independent executable DesignIR 2.0 JSON object from final-BRep evidence.", + "The source STEP is unavailable. Never reference, import, embed, or describe it as a base feature.", + "STEP does not contain source history. Use canonical reconstruction order and explicitly inferred semantic names.", + "Current executable operations are exactly: extrude_circle, extrude_rectangle, add_cylinder, through_hole, polar_hole_pattern.", + "Cylinder and extrusion primitives may use any observed axis. Hole cutters currently require the positive Z axis.", + "Preserve the observed absolute coordinate frame and orientation.", + "For cylinder and extrusion features, set anchor to geometric_center and center to the observed 3D bounding-box center unless there is strong evidence for another placement.", + "The legacy/default anchor is base_center; never rely on that default for reverse engineering.", + "A value reference object may contain exactly one key: parameter or expression. Do not add scale or arithmetic keys.", + "Do not expose an editable parameter unless at least one feature directly references it. Non-driving measurements must be editable false or omitted.", + "Use named editable parameters such as plate_thickness, hole_diameter, hole_count, bolt_circle_diameter, bore_diameter, overall_length, overall_width.", + "Every editable parameter must be referenced by a feature.", + "Every perturbation must contain parameter, scale, expected_change, and preserve.", + "Use an empty list for constraints when none are justified.", + "Return JSON only, without Markdown.", + "", + "REFERENCE FORMAT:", + JSON.stringify(example), + "", + "PRIVATE GEOMETRY EVIDENCE:", + JSON.stringify(evidence), + experience.length + ? `\nVALIDATED CANDIDATE METHODS (apply only when relevant):\n${JSON.stringify(experience)}` + : "", + previous ? `\nPREVIOUS CANDIDATE:\n${JSON.stringify(previous)}` : "", + feedback ? `\nVALIDATOR FEEDBACK:\n${feedback}` : "", + ] + .filter(Boolean) + .join("\n"); +} + +async function callModel(provider, prompt) { + const response = await fetch(`${provider.baseURL}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${provider.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: provider.model, + temperature: 0.2, + messages: [ + { + role: "system", + content: + "Produce normalized, editable parametric CAD design states. Output strict JSON.", + }, + { role: "user", content: prompt }, + ], + response_format: { type: "json_object" }, + }), + signal: AbortSignal.timeout(90_000), + }); + if (!response.ok) { + const detail = (await response.text()).slice(0, 600); + throw new Error(`LLM HTTP ${response.status}: ${detail}`); + } + const payload = await response.json(); + const content = payload.choices?.[0]?.message?.content; + if (!content) throw new Error("LLM returned no message content"); + const cleaned = String(content) + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/, ""); + return { + designir: JSON.parse(cleaned), + usage: payload.usage || {}, + }; +} + +function runPython(arguments_) { + const result = spawnSync(PYTHON, [DESIGNIR_CLI, ...arguments_], { + cwd: REPO, + encoding: "utf8", + timeout: 120_000, + }); + const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim(); + return { ok: result.status === 0, status: result.status, output }; +} + +async function runOne(job, provider, maxAttempts, options = {}) { + const shortId = job.id.slice(0, 16); + const root = path.join( + options.attemptsRoot || path.join(TRAJECTORY_DIR, "attempts"), + shortId, + ); + fs.mkdirSync(root, { recursive: true }); + const evidencePayload = readJson(job.evidence); + const evidence = compactEvidence(evidencePayload); + const unsupportedSurfaces = Object.entries( + evidence.geometry.analytic_surface_counts || {}, + ) + .filter(([kind, count]) => count > 0 && !["plane", "cylinder"].includes(kind)) + .map(([kind]) => kind); + if (unsupportedSurfaces.length) { + const trajectory = { + schema_version: "1.0", + trajectory_kind: "execution_validated_step_to_designir", + case_id: job.id, + family: job.family, + provider: provider.providerId, + model: provider.model, + attempts: [], + state: "unsupported_feature_vocabulary", + missing_capabilities: unsupportedSurfaces.map( + (kind) => `reconstruct_${kind}_surface`, + ), + }; + writeJson(path.join(root, "trajectory.json"), trajectory); + return trajectory; + } + let previous = null; + let feedback = ""; + const attempts = []; + for (let index = 1; index <= maxAttempts; index += 1) { + const started = Date.now(); + try { + const generated = await callModel( + provider, + reconstructionPrompt( + evidence, + previous, + feedback, + options.experience || [], + ), + ); + const designir = generated.designir; + designir.model_id ||= `trajectory_${shortId}_${index}`; + designir.applied_experience_ids = (options.experience || []).map( + (method) => method.id, + ); + const designirPath = path.join(root, `attempt-${index}.designir.json`); + const rebuiltPath = path.join(root, `attempt-${index}.step`); + const reportPath = path.join(root, `attempt-${index}.acceptance.json`); + writeJson(designirPath, designir); + const rebuild = runPython([ + "isolated-rebuild", + designirPath, + "--output", + rebuiltPath, + "--backend", + "build123d", + ]); + if (!rebuild.ok) { + feedback = `Isolated rebuild failed: ${rebuild.output}`; + attempts.push({ + index, + state: "compile_failed", + duration_ms: Date.now() - started, + usage: generated.usage, + feedback, + }); + previous = designir; + continue; + } + const acceptance = runPython([ + "accept", + "--teacher", + job.teacher, + "--designir", + designirPath, + "--rebuilt", + rebuiltPath, + "--report", + reportPath, + ]); + const report = fs.existsSync(reportPath) ? readJson(reportPath) : null; + const state = + acceptance.ok && report?.verdict === "pass" + ? "replay_validated" + : "acceptance_failed"; + attempts.push({ + index, + state, + duration_ms: Date.now() - started, + usage: generated.usage, + report: reportPath, + }); + if (state === "replay_validated") { + const trajectory = { + schema_version: "1.0", + trajectory_kind: "execution_validated_step_to_designir", + case_id: job.id, + split: job.split, + family: job.family, + provider: provider.providerId, + model: provider.model, + selected_attempt: index, + designir: designirPath, + rebuilt_step: rebuiltPath, + acceptance_report: reportPath, + attempts, + state, + }; + writeJson(path.join(root, "trajectory.json"), trajectory); + return trajectory; + } + feedback = `Acceptance failed: ${JSON.stringify({ + threshold_checks: report?.threshold_checks, + geometry: report?.geometry + ? { + relative_volume_error: report.geometry.relative_volume_error, + bounding_box_axis_error_mm: + report.geometry.bounding_box_axis_error_mm, + symmetric_difference_ratio: + report.geometry.symmetric_difference_ratio, + teacher_facts: report.geometry.teacher, + rebuilt_facts: report.geometry.rebuilt, + } + : null, + editability: report?.editability, + })}`; + previous = designir; + } catch (error) { + feedback = error instanceof Error ? error.message : String(error); + attempts.push({ + index, + state: "agent_failed", + duration_ms: Date.now() - started, + feedback, + }); + } + } + const trajectory = { + schema_version: "1.0", + trajectory_kind: "execution_validated_step_to_designir", + case_id: job.id, + family: job.family, + provider: provider.providerId, + model: provider.model, + attempts, + state: "quarantine", + }; + writeJson(path.join(root, "trajectory.json"), trajectory); + return trajectory; +} + +function summarize() { + const root = path.join(TRAJECTORY_DIR, "attempts"); + const rows = fs.existsSync(root) + ? fs + .readdirSync(root) + .map((name) => path.join(root, name, "trajectory.json")) + .filter((file) => fs.existsSync(file)) + .map(readJson) + : []; + const states = {}; + for (const row of rows) states[row.state] = (states[row.state] || 0) + 1; + return { + schema_version: "1.0", + report_kind: "trajectory_run_summary", + case_count: rows.length, + states, + validated_trajectory_count: states.replay_validated || 0, + quarantined_count: states.quarantine || 0, + rows: rows.map((row) => ({ + case_id: row.case_id, + family: row.family, + state: row.state, + attempt_count: row.attempts?.length || 0, + })), + }; +} + +function distillValidatedTrajectories() { + const root = path.join(TRAJECTORY_DIR, "attempts"); + const trajectories = fs.existsSync(root) + ? fs + .readdirSync(root) + .map((name) => path.join(root, name, "trajectory.json")) + .filter((file) => fs.existsSync(file)) + .map(readJson) + .filter((row) => row.state === "replay_validated") + : []; + const groups = new Map(); + for (const trajectory of trajectories) { + const designir = readJson(trajectory.designir); + const signature = designir.features + .map((feature) => + ["add_cylinder", "extrude_circle"].includes(feature.operation) + ? "cylinder" + : feature.operation, + ) + .sort() + .join("+"); + const key = `${designir.family}::${signature}`; + const group = groups.get(key) || { + family: designir.family, + signature, + cases: [], + parameterNames: new Map(), + anchors: new Map(), + operations: new Set(), + }; + group.cases.push(trajectory.case_id); + for (const name of Object.keys(designir.parameters || {})) { + group.parameterNames.set(name, (group.parameterNames.get(name) || 0) + 1); + } + for (const feature of designir.features || []) { + group.operations.add(feature.operation); + const anchor = feature.anchor || "base_center"; + group.anchors.set(anchor, (group.anchors.get(anchor) || 0) + 1); + } + groups.set(key, group); + } + const candidates = [...groups.values()].map((group) => { + const simpleCylinder = + group.family === "simple_cylinder" && group.signature === "cylinder"; + return { + id: `trajectory.${group.family}.${group.signature.replaceAll("+", "_")}`, + kind: "reconstruction_grammar", + promotion_state: "candidate", + applicability: simpleCylinder + ? { + family: group.family, + evidence_conditions: [ + "one dominant axis", + "one distinct coaxial radius", + "low topological complexity", + "solid volume approximately pi * radius^2 * axial_length", + ], + } + : { + family: group.family, + operations: [...group.operations].sort(), + }, + method: { + guidance: simpleCylinder + ? "Interpret the single cylindrical surface as the outer body, not a bore. Build one cylinder from the observed outer radius, axial length, axis, and pose. Name the driving parameters outer_radius and overall_length. Preserve the absolute frame with an explicit center and anchor; do not expose duplicate width/thickness/inner-diameter parameters." + : "Reconstruct from analytic dimensions and axis evidence; preserve the observed absolute frame with an explicit center and anchor; expose only parameters that directly drive executable features.", + canonical_parameter_names: simpleCylinder + ? ["outer_radius", "overall_length"] + : [], + rejected_parameter_hypotheses: simpleCylinder + ? ["coaxial_inner_diameter", "duplicate_transverse_envelope_parameters"] + : [], + observed_parameter_names: Object.fromEntries( + [...group.parameterNames.entries()].sort(), + ), + observed_anchor_modes: Object.fromEntries( + [...group.anchors.entries()].sort(), + ), + }, + validation: { + independent_replay_validated_case_count: new Set(group.cases).size, + source_hashes: [...new Set(group.cases)].sort(), + edit_contract_pass_required: true, + positive_heldout_ab_required: true, + heldout_ab_status: "not_run", + }, + }; + }); + const payload = { + schema_version: "1.0", + library_kind: "execution_validated_trajectory_candidates", + status: candidates.length ? "candidates_only" : "empty", + policy: { + router_consumable: false, + automatic_publish_allowed: false, + minimum_independent_cases: 3, + positive_heldout_ab_required: true, + }, + validated_trajectory_count: trajectories.length, + candidate_count: candidates.length, + candidates, + }; + const output = path.join(TRAJECTORY_DIR, "candidate-experience.json"); + writeJson(output, payload); + return { output, payload }; +} + +function sanitizedMethods(candidatePath) { + if (!candidatePath) return []; + return (readJson(candidatePath).candidates || []).map((candidate) => ({ + id: candidate.id, + kind: candidate.kind, + applicability: candidate.applicability, + method: candidate.method, + })); +} + +function benchmarkScore(rows) { + const passed = rows.filter((row) => row.state === "replay_validated").length; + return { + case_count: rows.length, + replay_validated_count: passed, + pass_rate: rows.length ? passed / rows.length : 0, + }; +} + +async function runBenchmark(args) { + const split = String(args.split || "validation"); + if (!["validation", "test"].includes(split)) { + throw new Error("Benchmark split must be validation or test"); + } + const limit = Math.max(1, Number(args.limit || 10)); + const maxAttempts = Math.max(1, Number(args.attempts || 3)); + const maxFaces = Math.max(1, Number(args["max-faces"] || 24)); + const requestedFamily = String(args.family || "").trim(); + const manifest = fs.existsSync(SPLIT_PATH) ? readJson(SPLIT_PATH) : initSplits(); + const jobs = (manifest.splits?.[split] || []) + .filter((job) => !requestedFamily || job.family === requestedFamily) + .filter((job) => job.face_count <= maxFaces) + .slice(0, limit); + const provider = loadProvider(args.provider, args.model); + const candidatePath = String( + args.experience || + path.join(TRAJECTORY_DIR, "candidate-experience.json"), + ); + const experience = sanitizedMethods(candidatePath); + const benchmarkRoot = path.join( + TRAJECTORY_DIR, + "benchmarks", + `${split}-${requestedFamily || "all"}`, + ); + const baseline = []; + const treatment = []; + for (const job of jobs) { + baseline.push( + await runOne({ ...job, split }, provider, maxAttempts, { + attemptsRoot: path.join(benchmarkRoot, "baseline"), + }), + ); + treatment.push( + await runOne({ ...job, split }, provider, maxAttempts, { + attemptsRoot: path.join(benchmarkRoot, "experience"), + experience, + }), + ); + } + const baselineScore = benchmarkScore(baseline); + const treatmentScore = benchmarkScore(treatment); + const report = { + schema_version: "1.0", + report_kind: "heldout_trajectory_ab", + split, + family: requestedFamily || "all", + provider: provider.providerId, + model: provider.model, + candidate_method_ids: experience.map((method) => method.id), + baseline: baselineScore, + experience: treatmentScore, + pass_rate_delta: treatmentScore.pass_rate - baselineScore.pass_rate, + positive: treatmentScore.pass_rate > baselineScore.pass_rate, + promotion_eligible: + jobs.length >= 10 && treatmentScore.pass_rate > baselineScore.pass_rate, + case_results: jobs.map((job, index) => ({ + case_id: job.id, + baseline_state: baseline[index].state, + experience_state: treatment[index].state, + })), + }; + writeJson(path.join(benchmarkRoot, "report.json"), report); + return report; +} + +async function main() { + const args = argsObject(process.argv.slice(2)); + const command = args._[0]; + if (command === "init") { + const payload = initSplits(); + process.stdout.write( + `${JSON.stringify({ split_path: SPLIT_PATH, policy: payload.policy }, null, 2)}\n`, + ); + return; + } + if (command === "summary") { + const report = summarize(); + writeJson(path.join(TRAJECTORY_DIR, "summary.json"), report); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + if (command === "distill") { + const result = distillValidatedTrajectories(); + process.stdout.write( + `${JSON.stringify( + { + output: result.output, + validated_trajectory_count: result.payload.validated_trajectory_count, + candidate_count: result.payload.candidate_count, + router_consumable: result.payload.policy.router_consumable, + }, + null, + 2, + )}\n`, + ); + return; + } + if (command === "benchmark") { + const report = await runBenchmark(args); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + if (command !== "run") { + throw new Error( + "Usage: trajectory_pipeline.mjs init|run|summary|distill|benchmark", + ); + } + const split = String(args.split || "train"); + const limit = Math.max(1, Number(args.limit || 5)); + const maxAttempts = Math.max(1, Number(args.attempts || 3)); + const maxFaces = Math.max(1, Number(args["max-faces"] || 24)); + const requestedFamily = String(args.family || "").trim(); + const rerun = Boolean(args.rerun); + const manifest = fs.existsSync(SPLIT_PATH) ? readJson(SPLIT_PATH) : initSplits(); + const jobs = (manifest.splits?.[split] || []) + .filter((job) => !requestedFamily || job.family === requestedFamily) + .filter((job) => job.face_count <= maxFaces) + .filter( + (job) => + rerun || + !fs.existsSync( + path.join( + TRAJECTORY_DIR, + "attempts", + job.id.slice(0, 16), + "trajectory.json", + ), + ), + ) + .slice(0, limit); + const provider = loadProvider(args.provider, args.model); + const results = []; + for (const job of jobs) { + const result = await runOne({ ...job, split }, provider, maxAttempts); + results.push({ + case_id: job.id, + family: job.family, + face_count: job.face_count, + state: result.state, + }); + process.stdout.write(`${JSON.stringify(results.at(-1))}\n`); + } + const report = summarize(); + writeJson(path.join(TRAJECTORY_DIR, "summary.json"), report); + process.stdout.write(`${JSON.stringify({ completed: results.length, report }, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/designir-pipeline/skills/cad-experience-builder/references/experience-library-schema.md b/designir-pipeline/skills/cad-experience-builder/references/experience-library-schema.md index 387e3ed..09ecd23 100644 --- a/designir-pipeline/skills/cad-experience-builder/references/experience-library-schema.md +++ b/designir-pipeline/skills/cad-experience-builder/references/experience-library-schema.md @@ -63,6 +63,17 @@ Consumers query this library by requested family and feature vocabulary. They must not receive the induction corpus, private case JSON, or `candidate_experiences`. Only `experiences` is executable guidance. +The library also supports: + +- `parameter_naming`: canonical editable names inferred from repeated B-Rep + evidence; +- `edit_strategy`: expected-change and non-target-preservation behavior for a + semantic parameter. + +Evidence support alone never makes a method router-consumable. Promotion +requires a positive held-out A/B result on independent source cases and full +edit-contract success for that method. + ## Reconstruction grammar `reconstruction_grammar` is the executable bridge between generalized diff --git a/designir-pipeline/skills/cad-experience-builder/references/llm-review-contract.md b/designir-pipeline/skills/cad-experience-builder/references/llm-review-contract.md index 280f374..c08133b 100644 --- a/designir-pipeline/skills/cad-experience-builder/references/llm-review-contract.md +++ b/designir-pipeline/skills/cad-experience-builder/references/llm-review-contract.md @@ -30,7 +30,17 @@ Write `runs/review/experience-draft.json` with this shape: Allowed kinds are `feature_motif`, `constraint`, `design_rule`, `validation_rule`, `failure_repair`, `dimensionless_distribution`, and -`reconstruction_grammar`. +`reconstruction_grammar`, plus: + +- `parameter_naming`: maps repeated final-B-Rep evidence to a canonical + editable name such as `plate_thickness`, `hole_spacing`, + `bolt_circle_diameter`, or `rib_count`; +- `edit_strategy`: defines the expected target change and preservation contract + for a named semantic parameter. + +These proposal kinds use `evidence_query.required_parameters`. A name is a +reconstruction-system convention, never a claim that the STEP contained the +source parameter. Distribution proposals also provide `observation_name`; the publisher computes the distribution from private evidence and never accepts values from the draft. @@ -86,6 +96,9 @@ Follow these rules: - Avoid dimensions, coordinates, source identifiers, face references, and numeric literals in prose. - Do not infer build order or causal failure mechanisms from final geometry. +- Do not present inferred parameter names as recovered source parameters. +- Prefer specific editable names over generic span roles only when repeated + geometric evidence supports the distinction. - Treat `canonical_stages` as a recommended reconstruction order, never as a claim about the source CAD history. - For reconstruction grammars, retain only roles and stages repeated by the diff --git a/designir-pipeline/skills/cad-experience-builder/scripts/cad_experience.py b/designir-pipeline/skills/cad-experience-builder/scripts/cad_experience.py index cbc3a1e..08777a9 100755 --- a/designir-pipeline/skills/cad-experience-builder/scripts/cad_experience.py +++ b/designir-pipeline/skills/cad-experience-builder/scripts/cad_experience.py @@ -65,7 +65,17 @@ ALLOWED_NUMERIC_KEYS = { "remaining_support", "required_confidence", "llm_proposal_count", + "independent_case_count", + "edit_contract_pass_rate", + "baseline_score_delta", } +ALLOWED_NUMERIC_SUFFIXES = ( + "_count", + "_rate", + "_ratio", + "_delta", + "_support", +) def utc_now() -> str: @@ -209,7 +219,12 @@ def normalize_case(payload: dict[str, Any]) -> dict[str, Any]: experience = payload.get("experience") if isinstance(payload.get("experience"), dict) else {} provenance = payload.get("provenance") if isinstance(payload.get("provenance"), dict) else {} supplied_id = provenance.get("source_sha256") or payload.get("case_id") - case_id = token(supplied_id, content_hash(payload)) + raw_id = str(supplied_id or "").strip().lower() + case_id = ( + raw_id + if re.fullmatch(r"[a-f0-9]{64}|[a-z][a-z0-9_.+-]*", raw_id) + else content_hash(payload) + ) family = token(design_ir.get("part_family") or payload.get("part_family")) if family.endswith("_candidate"): family = family.removesuffix("_candidate") @@ -287,14 +302,67 @@ def normalize_case(payload: dict[str, Any]) -> dict[str, Any]: } ) + semantic_parameters: list[dict[str, Any]] = [] + raw_parameters = design_ir.get("semantic_parameter_candidates", []) + if isinstance(raw_parameters, list): + for raw in raw_parameters: + if not isinstance(raw, dict): + continue + name = token(raw.get("name")) + if name == "unknown": + continue + edit_contract = ( + raw.get("edit_contract") + if isinstance(raw.get("edit_contract"), dict) + else {} + ) + confidence = raw.get("confidence", 0.0) + if not isinstance(confidence, (int, float)): + confidence = 0.0 + semantic_parameters.append( + { + "name": name, + "unit": token(raw.get("unit")), + "semantic_aliases": list_tokens(raw.get("semantic_aliases")), + "affects_feature_roles": list_tokens( + raw.get("affects_feature_roles") + ), + "expected_change": token(edit_contract.get("expected_change")), + "confidence_class": ( + "high" + if float(confidence) >= 0.8 + else "medium" + if float(confidence) >= 0.6 + else "low" + ), + "epistemic_status": token(raw.get("epistemic_status")), + } + ) + + geometry = ( + payload.get("geometry_evidence") + if isinstance(payload.get("geometry_evidence"), dict) + else {} + ) return { "case_id": case_id, + "extractor_version": str(payload.get("extractor_version") or "0"), "family": family, "features": sorted(features), "relations": sorted(relations), "rules": rules, "validations": validations, "normalized_observations": observations, + "semantic_parameters": semantic_parameters, + "reconstruction_eligible": bool( + geometry.get( + "reconstruction_eligible", + geometry.get("valid", True) + and int(geometry.get("solid_count", 1)) == 1 + and float(geometry.get("volume", 1.0)) > 0.0, + ) + ), + "rejection_reasons": list_tokens(geometry.get("rejection_reasons")), "reconstruction": normalize_reconstruction( design_ir.get("reconstruction_evidence") ), @@ -311,11 +379,18 @@ def load_cases(root: Path) -> tuple[list[dict[str, Any]], dict[str, int]]: if not isinstance(payload, dict): raise ValueError("root must be an object") case = normalize_case(payload) + if not case["reconstruction_eligible"]: + rejected += 1 + continue if not case["features"] and not case["rules"] and not case["relations"]: rejected += 1 continue if case["case_id"] in cases: duplicates += 1 + if case["extractor_version"] > cases[case["case_id"]][ + "extractor_version" + ]: + cases[case["case_id"]] = case continue cases[case["case_id"]] = case except (OSError, json.JSONDecodeError, ValueError, TypeError): @@ -555,7 +630,9 @@ def audit_value(value: Any, path: tuple[str, ...], errors: list[str]) -> None: audit_value(child, path + (str(index),), errors) elif isinstance(value, (int, float)) and not isinstance(value, bool): leaf = path[-1] if path else "" - if leaf not in ALLOWED_NUMERIC_KEYS: + if leaf not in ALLOWED_NUMERIC_KEYS and not leaf.endswith( + ALLOWED_NUMERIC_SUFFIXES + ): errors.append(f"{'.'.join(path)}: numeric value is not generalized metadata") @@ -646,7 +723,7 @@ def extract_folder(input_dir: Path, output_dir: Path) -> dict[str, Any]: digest = payload.get("provenance", {}).get("source_sha256") if ( isinstance(digest, str) - and payload.get("extractor_version") == "3.0" + and payload.get("extractor_version") == "4.0" ): known_hashes[digest] = path except (OSError, json.JSONDecodeError, AttributeError): @@ -711,6 +788,18 @@ def prepare_semantic_batch( "candidate_rules": case["rules"], "validation_targets": case["validations"], "reconstruction_evidence": case["reconstruction"], + "semantic_parameter_roles": [ + { + "name": item["name"], + "unit": item["unit"], + "semantic_aliases": item["semantic_aliases"], + "affects_feature_roles": item["affects_feature_roles"], + "expected_change": item["expected_change"], + "confidence_class": item["confidence_class"], + "epistemic_status": item["epistemic_status"], + } + for item in case["semantic_parameters"] + ], "dimensionless_observation_roles": [ { "name": item["name"], @@ -753,6 +842,8 @@ def prepare_semantic_batch( "validation_rule", "failure_repair", "dimensionless_distribution", + "parameter_naming", + "edit_strategy", "reconstruction_grammar", ], }, @@ -770,6 +861,8 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]: "validation_rule", "failure_repair", "dimensionless_distribution", + "parameter_naming", + "edit_strategy", "reconstruction_grammar", } for index, raw in enumerate(payload.get("proposals", [])): @@ -785,6 +878,7 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]: ) required_features = list_tokens(evidence.get("required_features")) required_relations = list_tokens(evidence.get("required_relations")) + required_parameters = list_tokens(evidence.get("required_parameters")) context_features = list_tokens( evidence.get("context_features") or required_features ) @@ -800,6 +894,7 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]: if ( not required_features and not required_relations + and not required_parameters and observation_name == "unknown" ): raise ValueError(f"proposal {proposal_id} has no evidence query") @@ -814,6 +909,7 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]: "evidence_query": { "required_features": required_features, "required_relations": required_relations, + "required_parameters": required_parameters, "context_features": context_features, }, "guidance": guidance, @@ -833,6 +929,24 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]: f"proposal {proposal_id} has an incomplete reconstruction grammar" ) proposal["reconstruction_grammar"] = grammar + if kind in {"parameter_naming", "edit_strategy"}: + canonical_parameter = token(raw.get("canonical_parameter")) + if canonical_parameter == "unknown": + raise ValueError( + f"proposal {proposal_id} requires canonical_parameter" + ) + proposal["canonical_parameter"] = canonical_parameter + proposal["semantic_aliases"] = list_tokens( + raw.get("semantic_aliases") + ) + if kind == "edit_strategy": + expected_change = token(raw.get("expected_change")) + if expected_change == "unknown": + raise ValueError( + f"proposal {proposal_id} requires expected_change" + ) + proposal["expected_change"] = expected_change + proposal["preserve_roles"] = list_tokens(raw.get("preserve_roles")) for key in ("check", "repair", "failure_signature"): value = string_without_instance_numbers(raw.get(key)) if value is not None: @@ -849,10 +963,20 @@ def build_reviewed_library( min_support: int, min_confidence: float, stats: dict[str, int], + validation_report: dict[str, Any] | None = None, ) -> dict[str, Any]: promoted: list[dict[str, Any]] = [] candidates: list[dict[str, Any]] = [] family_counts = Counter(case["family"] for case in cases) + validated_methods = { + token(item.get("experience_id")): item + for item in (validation_report or {}).get("method_results", []) + if isinstance(item, dict) + and item.get("passed") is True + and int(item.get("independent_case_count", 0)) >= 3 + and float(item.get("edit_contract_pass_rate", 0.0)) >= 1.0 + and float(item.get("baseline_score_delta", 0.0)) > 0.0 + } def supports_reconstruction_grammar( case: dict[str, Any], grammar: dict[str, Any] @@ -901,6 +1025,9 @@ def build_reviewed_library( required_relations = set( proposal["evidence_query"]["required_relations"] ) + required_parameters = set( + proposal["evidence_query"].get("required_parameters", []) + ) context_features = set( proposal["evidence_query"].get("context_features", required_features) ) @@ -941,7 +1068,22 @@ def build_reviewed_library( "p90": round(percentile(values, 0.90), 6), "max": round(max(values), 6), } - if eligible(support, item["confidence"], min_support, min_confidence): + evidence_eligible = eligible( + support, item["confidence"], min_support, min_confidence + ) + validation = validated_methods.get(item["id"]) + if evidence_eligible and validation is not None: + item["validation"] = { + "independent_case_count": int( + validation["independent_case_count"] + ), + "edit_contract_pass_rate": float( + validation["edit_contract_pass_rate"] + ), + "baseline_score_delta": float( + validation["baseline_score_delta"] + ), + } promoted.append(item) else: item.pop("distribution", None) @@ -951,6 +1093,11 @@ def build_reviewed_library( "required_support": min_support, "remaining_support": max(0, min_support - support), "required_confidence": min_confidence, + "validation_state": ( + "validated_positive" + if validation is not None + else "awaiting_positive_heldout_ab" + ), "consumer_policy": "visible_for_review_but_not_available_to_cad_router", } ) @@ -966,6 +1113,9 @@ def build_reviewed_library( for case in contextual if required_features.issubset(case["features"]) and required_relations.issubset(case["relations"]) + and required_parameters.issubset( + {item["name"] for item in case.get("semantic_parameters", [])} + ) and ( proposal["kind"] != "reconstruction_grammar" or supports_reconstruction_grammar( @@ -982,6 +1132,7 @@ def build_reviewed_library( "when": { "features": sorted(required_features), "relations": sorted(required_relations), + "semantic_parameters": sorted(required_parameters), }, "guidance": proposal["guidance"], "semantic_rationale": proposal["semantic_rationale"], @@ -993,7 +1144,24 @@ def build_reviewed_library( item[key] = proposal[key] if proposal["kind"] == "reconstruction_grammar": item["reconstruction_grammar"] = proposal["reconstruction_grammar"] - if eligible(support, confidence, min_support, min_confidence): + if proposal["kind"] in {"parameter_naming", "edit_strategy"}: + item["canonical_parameter"] = proposal["canonical_parameter"] + item["semantic_aliases"] = proposal["semantic_aliases"] + if proposal["kind"] == "edit_strategy": + item["expected_change"] = proposal["expected_change"] + item["preserve_roles"] = proposal["preserve_roles"] + evidence_eligible = eligible( + support, confidence, min_support, min_confidence + ) + validation = validated_methods.get(item["id"]) + if evidence_eligible and validation is not None: + item["validation"] = { + "independent_case_count": int(validation["independent_case_count"]), + "edit_contract_pass_rate": float( + validation["edit_contract_pass_rate"] + ), + "baseline_score_delta": float(validation["baseline_score_delta"]), + } promoted.append(item) else: item.update( @@ -1002,6 +1170,11 @@ def build_reviewed_library( "required_support": min_support, "remaining_support": max(0, min_support - support), "required_confidence": min_confidence, + "validation_state": ( + "validated_positive" + if validation is not None + else "awaiting_positive_heldout_ab" + ), "consumer_policy": "visible_for_review_but_not_available_to_cad_router", } ) @@ -1026,7 +1199,8 @@ def build_reviewed_library( "single_case_promotion_allowed": False, "llm_semantic_review_required": True, "draft_evidence_verified": True, - "router_consumable": True, + "positive_heldout_ab_required": True, + "router_consumable": bool(promoted), "minimum_support": min_support, "minimum_confidence": min_confidence, }, @@ -1115,6 +1289,11 @@ def build_parser() -> argparse.ArgumentParser: ) publish_parser.add_argument("--min-support", type=int, default=20) publish_parser.add_argument("--min-confidence", type=float, default=0.8) + publish_parser.add_argument( + "--validation-report", + type=Path, + help="Held-out per-method A/B and edit-contract validation report.", + ) distill_parser = subparsers.add_parser("distill") distill_parser.add_argument( @@ -1130,6 +1309,11 @@ def build_parser() -> argparse.ArgumentParser: ) distill_parser.add_argument("--min-support", type=int, default=20) distill_parser.add_argument("--min-confidence", type=float, default=0.8) + distill_parser.add_argument( + "--validation-report", + type=Path, + help="Held-out per-method A/B and edit-contract validation report.", + ) return parser @@ -1205,12 +1389,18 @@ def main(argv: list[str] | None = None) -> int: draft_path = args.draft draft_payload = json.loads(draft_path.read_text(encoding="utf-8")) proposals = normalize_proposals(draft_payload) + validation_report = None + if args.validation_report is not None: + validation_report = json.loads( + args.validation_report.read_text(encoding="utf-8") + ) library = build_reviewed_library( cases, proposals, args.min_support, args.min_confidence, stats, + validation_report, ) errors = audit_library(library) if errors: diff --git a/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py b/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py index dcb3836..3903aa0 100644 --- a/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py +++ b/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py @@ -11,7 +11,7 @@ from collections import Counter, defaultdict from pathlib import Path from typing import Any -EXTRACTOR_VERSION = "3.0" +EXTRACTOR_VERSION = "4.0" def _rounded(value: float) -> float: @@ -71,6 +71,184 @@ def _cardinality_class(count: int) -> str: return "dense" +def _parameter_candidate( + name: str, + value: float | int, + unit: str, + confidence: float, + affects: list[str], + expected_change: str, + evidence_kind: str, + aliases: list[str] | None = None, +) -> dict[str, Any]: + """Create an explicitly inferred, editable semantic parameter candidate.""" + return { + "name": name, + "value": value if unit == "count" else _rounded(float(value)), + "unit": unit, + "editable_candidate": True, + "epistemic_status": "inferred_from_final_brep", + "confidence": _rounded(confidence), + "semantic_aliases": sorted(set(aliases or [])), + "affects_feature_roles": sorted(set(affects)), + "edit_contract": { + "expected_change": expected_change, + "preserve_roles": ["part_center", "primary_reference_frame"], + }, + "source_evidence": {"kind": evidence_kind}, + } + + +def _semantic_parameter_candidates( + surfaces: list[dict[str, Any]], + bbox_center: list[float], + bbox_size: list[float], + features: list[dict[str, Any]], + semantic_summary: dict[str, Any], +) -> list[dict[str, Any]]: + """Name editable dimensions without pretending that STEP stored parameters.""" + if not bbox_size or max(bbox_size) <= 1e-9: + return [] + feature_roles = { + str(item.get("type")) + for item in features + if isinstance(item, dict) and item.get("type") + } + ordered_axes = sorted( + zip(("x", "y", "z"), bbox_size), key=lambda item: item[1], reverse=True + ) + candidates = [ + _parameter_candidate( + "overall_length", + ordered_axes[0][1], + "mm", + 0.98, + ["primary_envelope"], + "overall_length_changes", + f"bounding_box_{ordered_axes[0][0]}_span", + ), + _parameter_candidate( + "overall_width", + ordered_axes[1][1], + "mm", + 0.92, + ["primary_envelope"], + "overall_width_changes", + f"bounding_box_{ordered_axes[1][0]}_span", + ), + _parameter_candidate( + "overall_thickness", + ordered_axes[2][1], + "mm", + 0.88, + ["primary_envelope"], + "overall_thickness_changes", + f"bounding_box_{ordered_axes[2][0]}_span", + ["plate_thickness"] + if "plate_like_body" in feature_roles + else ["minimum_envelope_span"], + ), + ] + + dominant_axis = str(semantic_summary.get("dominant_axis", "z")) + transverse_axes = {"x": (1, 2), "y": (0, 2), "z": (0, 1)}[dominant_axis] + scale = max(bbox_size) + center_tolerance = max(scale * 0.005, 1e-4) + repeated_groups: dict[float, list[dict[str, Any]]] = defaultdict(list) + coaxial: list[dict[str, Any]] = [] + for surface in surfaces: + if ( + surface.get("type") != "cylinder" + or _canonical_axis(surface["axis"]) != dominant_axis + ): + continue + radial_offset = _radial_offset( + surface["location"], bbox_center, dominant_axis + ) + if radial_offset <= center_tolerance: + coaxial.append(surface) + else: + repeated_groups[round(float(surface["radius"]), 4)].append(surface) + + repeated = max(repeated_groups.values(), key=len) if repeated_groups else [] + unique_centers = { + ( + round(float(item["location"][transverse_axes[0]]), 4), + round(float(item["location"][transverse_axes[1]]), 4), + ) + for item in repeated + } + if len(unique_centers) >= 3: + member_radius = float(repeated[0]["radius"]) + offsets = [ + math.hypot( + first - bbox_center[transverse_axes[0]], + second - bbox_center[transverse_axes[1]], + ) + for first, second in unique_centers + ] + mean_offset = sum(offsets) / len(offsets) + circular = ( + mean_offset > center_tolerance + and (max(offsets) - min(offsets)) / mean_offset <= 0.08 + ) + candidates.extend( + [ + _parameter_candidate( + "repeated_feature_count", + len(unique_centers), + "count", + 0.72, + ["repeated_cylindrical_feature_pattern"], + "pattern_member_count_changes", + "unique_equal_radius_parallel_cylinder_axes", + ["hole_count", "boss_count"], + ), + _parameter_candidate( + "repeated_feature_diameter", + member_radius * 2.0, + "mm", + 0.68, + ["repeated_cylindrical_feature_pattern"], + "pattern_member_diameter_changes", + "equal_radius_cylindrical_surfaces", + ["hole_diameter", "boss_diameter"], + ), + ] + ) + if circular: + candidates.append( + _parameter_candidate( + "bolt_circle_diameter", + mean_offset * 2.0, + "mm", + 0.82, + ["circular_equal_radius_pattern"], + "pattern_radius_changes", + "equal_radial_offset_cylinder_axes", + ["hole_spacing", "pattern_diameter"], + ) + ) + + coaxial_radii = sorted( + {round(float(item["radius"]), 6) for item in coaxial} + ) + if coaxial_radii: + candidates.append( + _parameter_candidate( + "coaxial_inner_diameter", + coaxial_radii[0] * 2.0, + "mm", + 0.58, + ["coaxial_cylindrical_stack"], + "coaxial_inner_diameter_changes", + "smallest_centered_cylindrical_surface", + ["bore_diameter", "shaft_diameter"], + ) + ) + return candidates + + def _surface_record(face: Any, index: int) -> dict[str, Any]: from OCP.BRepAdaptor import BRepAdaptor_Surface from OCP.GeomAbs import ( @@ -702,8 +880,18 @@ def extract_step_case(source: Path) -> dict[str, Any]: reconstruction_evidence = _reconstruction_evidence( surfaces, features, constraints, semantic_summary ) + semantic_parameters = _semantic_parameter_candidates( + surfaces, + bbox_center, + bbox_size, + features, + semantic_summary, + ) digest = hashlib.sha256(source_bytes).hexdigest() normalized_observations = semantic_summary.pop("normalized_observations", []) + solid_count = len(shape.solids()) + volume = _rounded(shape.volume) + usable_solid = bool(shape.is_valid) and solid_count > 0 and volume > 0.0 return { "schema_version": "2.0", "extractor_version": EXTRACTOR_VERSION, @@ -715,10 +903,22 @@ def extract_step_case(source: Path) -> dict[str, Any]: "source_format": "step", }, "geometry_evidence": { - "valid": bool(shape.is_valid), - "solid_count": len(shape.solids()), + "kernel_valid": bool(shape.is_valid), + "valid": usable_solid, + "reconstruction_eligible": usable_solid and solid_count == 1, + "rejection_reasons": [ + reason + for condition, reason in ( + (not bool(shape.is_valid), "kernel_invalid"), + (solid_count == 0, "no_closed_solid"), + (volume <= 0.0, "zero_volume"), + (solid_count > 1, "multi_solid_requires_segmentation"), + ) + if condition + ], + "solid_count": solid_count, "face_count": len(shape.faces()), - "volume": _rounded(shape.volume), + "volume": volume, "bounding_box": { "min": bbox_min, "max": bbox_max, @@ -729,9 +929,11 @@ def extract_step_case(source: Path) -> dict[str, Any]: "analytic_surfaces": surfaces, }, "design_ir": { + "epistemic_status": "inferred_design_hypothesis_not_source_history", "part_family": family, "features": features, "constraints": constraints, + "semantic_parameter_candidates": semantic_parameters, "normalized_observations": normalized_observations, "semantic_summary": semantic_summary, "reconstruction_evidence": reconstruction_evidence, diff --git a/designir-pipeline/tests/test_designir_pipeline.py b/designir-pipeline/tests/test_designir_pipeline.py index efd055d..78164a5 100644 --- a/designir-pipeline/tests/test_designir_pipeline.py +++ b/designir-pipeline/tests/test_designir_pipeline.py @@ -31,6 +31,41 @@ class DesignIRPipelineTests(unittest.TestCase): self.assertEqual(1, result["facts"]["solid_count"]) self.assertGreater(result["facts"]["volume_mm3"], 30_000) + def test_legacy_model_migrates_to_semantic_designir_3(self) -> None: + migrated = MODULE.migrate_designir_2_to_3(self.payload()) + self.assertEqual("3.0", migrated["schema_version"]) + self.assertEqual( + "fully_semantic_parametric", migrated["reconstruction_mode"] + ) + self.assertIn( + "outer_radius", migrated["edit_interface"]["semantic_parameters"] + ) + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "flange.step" + result = MODULE.compile_designir(migrated, output) + self.assertEqual(1, result["facts"]["solid_count"]) + + def test_generated_step_materializes_hybrid_surface_snapshot(self) -> None: + migrated = MODULE.migrate_designir_2_to_3(self.payload()) + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "flange.step" + MODULE.compile_designir(migrated, output) + hybrid = MODULE.materialize_hybrid_designir(migrated, output) + self.assertEqual( + "hybrid_semantic_surface_parametric", + hybrid["reconstruction_mode"], + ) + self.assertTrue(hybrid["surface_layer"]["solids"]) + self.assertTrue( + hybrid["compiled_surface_provenance"][ + "semantic_layer_authoritative" + ] + ) + self.assertTrue(MODULE.anti_cheat(hybrid)["valid"]) + rebuilt = Path(temporary) / "hybrid-rebuilt.step" + MODULE.compile_designir(hybrid, rebuilt) + self.assertTrue(rebuilt.is_file()) + def test_teacher_dependency_is_rejected(self) -> None: payload = self.payload() payload["source_step"] = "teacher.step" @@ -48,6 +83,51 @@ class DesignIRPipelineTests(unittest.TestCase): self.assertGreater(result["facts"]["volume_mm3"], 30_000) self.assertTrue(Path(result["model_json"]).is_file()) + def test_arbitrary_axis_geometric_center_preserves_pose(self) -> None: + payload = self.payload() + payload["family"] = "axis_aligned_cylinder" + payload["parameters"] = { + "outer_radius": {"value": 10, "unit": "mm", "editable": True}, + "overall_length": {"value": 400, "unit": "mm", "editable": True}, + } + payload["expressions"] = {} + payload["features"] = [ + { + "id": "main_body", + "operation": "extrude_circle", + "radius": {"parameter": "outer_radius"}, + "height": {"parameter": "overall_length"}, + "axis": [0, -1, 0], + "center": [7, 11, 13], + "anchor": "geometric_center", + } + ] + payload["edit_interface"] = { + "editable_parameters": ["outer_radius", "overall_length"], + "preserved_interfaces": ["part_center", "primary_axis"], + } + payload["validation_contract"]["perturbations"] = [ + { + "parameter": "outer_radius", + "scale": 1.1, + "expected_change": "radius", + "preserve": ["overall_length", "part_center"], + }, + { + "parameter": "overall_length", + "scale": 1.1, + "expected_change": "length", + "preserve": ["outer_radius", "part_center"], + }, + ] + shape = MODULE.build_shape(payload) + bounds = shape.bounding_box() + self.assertAlmostEqual(-189, bounds.min.Y, places=6) + self.assertAlmostEqual(211, bounds.max.Y, places=6) + self.assertAlmostEqual(7, shape.center(MODULE.CenterOf.MASS).X, places=6) + self.assertAlmostEqual(11, shape.center(MODULE.CenterOf.MASS).Y, places=6) + self.assertAlmostEqual(13, shape.center(MODULE.CenterOf.MASS).Z, places=6) + def test_embedded_mesh_is_rejected(self) -> None: payload = self.payload() payload["features"][0]["mesh"] = {"vertices": [[0, 0, 0]]} @@ -97,6 +177,10 @@ class DesignIRPipelineTests(unittest.TestCase): json.dumps( { "model_id": f"case-{index}", + "teacher_sha256": f"teacher-{index}", + "applied_experience_ids": [ + "reconstruction.flange.primary" + ], "next_state": "replay_validated", } ), @@ -107,6 +191,10 @@ class DesignIRPipelineTests(unittest.TestCase): reports, ROOT / "config" / "promotion-policy.json" ) self.assertTrue(result["eligible"]) + self.assertEqual( + ["reconstruction.flange.primary"], + result["validated_experience_ids"], + ) self.assertEqual("promoted", result["next_state"]) diff --git a/designir-pipeline/tests/test_experience_library.py b/designir-pipeline/tests/test_experience_library.py index ea52b8c..aaa45c7 100644 --- a/designir-pipeline/tests/test_experience_library.py +++ b/designir-pipeline/tests/test_experience_library.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util import json +import tempfile import unittest from pathlib import Path @@ -106,6 +107,22 @@ def make_case(index: int) -> dict: } +def positive_validation(*experience_ids: str) -> dict: + return { + "validation_kind": "heldout_experience_ab", + "method_results": [ + { + "experience_id": experience_id, + "independent_case_count": 3, + "edit_contract_pass_rate": 1.0, + "baseline_score_delta": 0.5, + "passed": True, + } + for experience_id in experience_ids + ], + } + + class CadExperienceTest(unittest.TestCase): def test_default_data_paths_use_shallow_local_layout(self): pipeline_root = Path(__file__).parents[1].resolve() @@ -179,6 +196,47 @@ class CadExperienceTest(unittest.TestCase): "canonical_reconstruction_plan_not_recovered_history", reconstruction["interpretation"], ) + parameters = STEP_MODULE._semantic_parameter_candidates( + surfaces, + [0.0, 0.0, 0.0], + [40.0, 20.0, 40.0], + features, + summary, + ) + names = {item["name"] for item in parameters} + self.assertIn("repeated_feature_count", names) + self.assertIn("repeated_feature_diameter", names) + self.assertTrue( + all( + item["epistemic_status"] == "inferred_from_final_brep" + for item in parameters + ) + ) + + def test_non_solid_teacher_is_rejected_from_induction(self): + payload = make_case(1) + payload["geometry_evidence"] = { + "valid": False, + "reconstruction_eligible": False, + "solid_count": 0, + "volume": 0.0, + "rejection_reasons": ["no_closed_solid", "zero_volume"], + } + normalized = MODULE.normalize_case(payload) + self.assertFalse(normalized["reconstruction_eligible"]) + self.assertIn("no_closed_solid", normalized["rejection_reasons"]) + + def test_sha256_identity_deduplicates_renamed_sources(self): + digest = "0" * 64 + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for index in range(2): + payload = make_case(index) + payload["provenance"]["source_sha256"] = digest + (root / f"renamed-{index}.json").write_text(json.dumps(payload)) + cases, stats = MODULE.load_cases(root) + self.assertEqual(1, len(cases)) + self.assertEqual(1, stats["duplicate_case_count"]) def test_conditional_feature_context_does_not_dilute_valid_method(self): proposal = self.reviewed_proposal() @@ -202,6 +260,7 @@ class CadExperienceTest(unittest.TestCase): 20, 0.8, {"duplicate_case_count": 0, "rejected_case_count": 0}, + positive_validation(proposal["id"]), ) self.assertEqual(1, len(library["experiences"])) @@ -249,6 +308,29 @@ class CadExperienceTest(unittest.TestCase): self.assertIn("constraint.flanged_hub_adapter.coaxial_stack", ids) self.assertIn("distribution.sleeve_od_to_base_od", ids) + def test_audit_allows_aggregate_validation_counts_only(self): + library = { + "schema_version": "2.0", + "library_kind": "generalized_cad_experience", + "experiences": [ + { + "id": "edit_strategy.test", + "support": 20, + "validation": { + "heldout_pair_count": 20, + "treatment_pass_count": 20, + "regressed_pair_count": 0, + "pass_rate_delta": 0.25, + }, + } + ], + "candidate_experiences": [], + } + self.assertEqual([], MODULE.audit_library(library)) + library["experiences"][0]["validation"]["radius"] = 3.0 + errors = MODULE.audit_library(library) + self.assertTrue(any("forbidden instance key" in error for error in errors)) + def test_family_support_is_not_diluted_by_unrelated_families(self): target = [MODULE.normalize_case(make_case(index)) for index in range(20)] unrelated = [] @@ -299,7 +381,7 @@ class CadExperienceTest(unittest.TestCase): ) self.assertEqual([], candidate_library["experiences"]) self.assertEqual(1, len(candidate_library["candidate_experiences"])) - self.assertTrue(candidate_library["policy"]["router_consumable"]) + self.assertFalse(candidate_library["policy"]["router_consumable"]) cases = [MODULE.normalize_case(make_case(index)) for index in range(20)] promoted_library = MODULE.build_reviewed_library( @@ -308,10 +390,71 @@ class CadExperienceTest(unittest.TestCase): 20, 0.8, {"duplicate_case_count": 0, "rejected_case_count": 0}, + positive_validation(proposal["id"]), ) self.assertEqual(1, len(promoted_library["experiences"])) self.assertEqual([], promoted_library["candidate_experiences"]) + def test_parameter_naming_requires_positive_heldout_validation(self): + cases = [] + for index in range(20): + payload = make_case(index) + payload["design_ir"]["semantic_parameter_candidates"] = [ + { + "name": "bolt_circle_diameter", + "unit": "mm", + "confidence": 0.9, + "semantic_aliases": ["hole_spacing"], + "affects_feature_roles": ["mounting_pattern"], + "epistemic_status": "inferred_from_final_brep", + "edit_contract": { + "expected_change": "pattern_radius_changes" + }, + } + ] + cases.append(MODULE.normalize_case(payload)) + raw = { + "id": "parameter.flange.bolt_circle_diameter", + "kind": "parameter_naming", + "scope": ["flanged_hub_adapter"], + "evidence_query": { + "required_features": ["base_flange"], + "required_relations": [], + "required_parameters": ["bolt_circle_diameter"], + }, + "guidance": "Expose the circular mounting pattern diameter as an editable semantic parameter.", + "semantic_rationale": "The named role preserves edit intent across dimensional variants.", + "canonical_parameter": "bolt_circle_diameter", + "semantic_aliases": ["hole_spacing"], + } + proposal = MODULE.normalize_proposals( + { + "draft_kind": "llm_generalized_experience_proposals", + "proposals": [raw], + } + )[0] + unvalidated = MODULE.build_reviewed_library( + cases, + [proposal], + 20, + 0.8, + {"duplicate_case_count": 0, "rejected_case_count": 0}, + ) + self.assertEqual([], unvalidated["experiences"]) + self.assertEqual( + "awaiting_positive_heldout_ab", + unvalidated["candidate_experiences"][0]["validation_state"], + ) + validated = MODULE.build_reviewed_library( + cases, + [proposal], + 20, + 0.8, + {"duplicate_case_count": 0, "rejected_case_count": 0}, + positive_validation(raw["id"]), + ) + self.assertEqual("parameter_naming", validated["experiences"][0]["kind"]) + def test_numeric_instance_answer_is_rejected_from_llm_draft(self): proposal = self.reviewed_proposal() proposal["guidance"] = "Always use a diameter of 91 millimeters." @@ -375,6 +518,7 @@ class CadExperienceTest(unittest.TestCase): 20, 0.8, {"duplicate_case_count": 0, "rejected_case_count": 0}, + positive_validation(proposal["id"]), ) self.assertEqual([], MODULE.audit_library(library)) self.assertEqual("reconstruction_grammar", library["experiences"][0]["kind"]) diff --git a/designir-pipeline/tests/test_surfaceir_pipeline.py b/designir-pipeline/tests/test_surfaceir_pipeline.py new file mode 100644 index 0000000..d658791 --- /dev/null +++ b/designir-pipeline/tests/test_surfaceir_pipeline.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +from build123d import Plane, Rectangle + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader + spec.loader.exec_module(module) + return module + + +SURFACEIR = load_module( + "surfaceir_pipeline", ROOT / "scripts" / "surfaceir_pipeline.py" +) +DESIGNIR = load_module( + "designir_pipeline_surface_test", ROOT / "scripts" / "designir_pipeline.py" +) + + +class SurfaceIRPipelineTests(unittest.TestCase): + def test_independent_json_roundtrip_preserves_exact_geometry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + teacher = directory / "teacher.step" + portable_json = directory / "model.designir.json" + rebuilt = directory / "rebuilt.step" + expected = DESIGNIR.Box(10, 20, 30) + DESIGNIR.export_step(expected, teacher) + surfaceir = SURFACEIR.extract_surfaceir(teacher) + SURFACEIR.write_json(portable_json, surfaceir) + teacher.unlink() + + serialized = portable_json.read_text(encoding="utf-8").lower() + self.assertNotIn("teacher.step", serialized) + self.assertNotIn("source_path", serialized) + self.assertNotIn("import_step", serialized) + self.assertNotIn('"mesh"', serialized) + + rebuilt_shape = SURFACEIR.build_surfaceir(surfaceir) + SURFACEIR.export_step_shape(rebuilt_shape, rebuilt) + self.assertTrue(rebuilt.is_file()) + + actual = DESIGNIR.import_step(rebuilt) + self.assertEqual(len(expected.solids()), len(actual.solids())) + self.assertEqual(len(expected.faces()), len(actual.faces())) + self.assertEqual(len(expected.edges()), len(actual.edges())) + self.assertLess( + abs(expected.volume - actual.volume) / expected.volume, 1e-10 + ) + self.assertLess( + DESIGNIR._symmetric_difference_volume(expected, actual), 1e-8 + ) + + def test_empty_step_document_is_represented_honestly(self) -> None: + payload = { + "schema_version": "3.0", + "designir_kind": "independent_parametric_cad", + "document_status": "empty_geometry", + "surface_layer": { + "vertices": [], + "solids": [], + "free_shells": [], + }, + } + rebuilt = SURFACEIR.build_surfaceir(payload) + self.assertEqual(SURFACEIR._facts_from_shape(rebuilt)["face_count"], 0) + + def test_open_shell_roundtrip_preserves_surface_geometry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + teacher = directory / "surface.step" + rebuilt_path = directory / "surface-rebuilt.step" + face = (Plane.XY * Rectangle(10, 20)).face() + builder = SURFACEIR.BRep_Builder() + shell = SURFACEIR.TopoDS_Shell() + builder.MakeShell(shell) + builder.Add(shell, face.wrapped) + SURFACEIR.export_step_shape(shell, teacher) + + payload = SURFACEIR.extract_surfaceir(teacher) + self.assertEqual(payload["document_status"], "geometry_present") + self.assertEqual(len(payload["surface_layer"]["solids"]), 0) + self.assertEqual(len(payload["surface_layer"]["free_shells"]), 1) + rebuilt = SURFACEIR.build_surfaceir( + payload, "exact_3d_pcurve" + ) + SURFACEIR.export_step_shape(rebuilt, rebuilt_path) + _, teacher_facts = SURFACEIR._shape_facts(teacher) + _, rebuilt_facts = SURFACEIR._shape_facts(rebuilt_path) + self.assertEqual(teacher_facts["face_count"], 1) + self.assertEqual( + teacher_facts["face_count"], rebuilt_facts["face_count"] + ) + self.assertAlmostEqual( + teacher_facts["surface_area"], + rebuilt_facts["surface_area"], + places=8, + ) + + def test_overall_scale_is_executable_and_independently_validated(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + teacher = directory / "teacher.step" + designir_path = directory / "box.designir.json" + expected = DESIGNIR.Box(10, 20, 30) + DESIGNIR.export_step(expected, teacher) + payload = SURFACEIR.extract_surfaceir(teacher) + self.assertIn( + "overall_scale", + payload["edit_interface"]["semantic_parameters"], + ) + + edited = SURFACEIR.apply_semantic_parameter( + payload, "overall_scale", 1.1 + ) + self.assertEqual( + edited["edit_operations"], + [ + { + "operation": "uniform_scale", + "factor": 1.1, + "pivot": [0.0, 0.0, 0.0], + } + ], + ) + self.assertIsNotNone(SURFACEIR.build_surfaceir(edited)) + + SURFACEIR.write_json(designir_path, payload) + acceptance = SURFACEIR.validate_semantic_edit( + teacher, + designir_path, + "overall_scale", + 1.1, + directory / "acceptance", + ) + self.assertTrue(acceptance["accepted"]) + self.assertTrue( + acceptance["checks"]["target_parameter"]["pass"] + ) + + def test_primary_axis_dimensions_are_executable_and_validated(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + teacher = directory / "shaft.step" + designir_path = directory / "shaft.designir.json" + DESIGNIR.export_step(DESIGNIR.Cylinder(10, 40), teacher) + payload = SURFACEIR.extract_surfaceir(teacher) + SURFACEIR.write_json(designir_path, payload) + + for parameter, value in ( + ("outer_diameter", 22.0), + ("body_length", 44.0), + ): + acceptance = SURFACEIR.validate_semantic_edit( + teacher, + designir_path, + parameter, + value, + directory / parameter, + ) + self.assertTrue(acceptance["accepted"]) + self.assertTrue( + acceptance["checks"]["target_parameter"]["pass"] + ) + + def test_canonical_semantics_name_bore_and_polar_hole_pattern(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + teacher = Path(temporary) / "flange.step" + expected = DESIGNIR.Cylinder(20, 5) - DESIGNIR.Cylinder(5, 5) + for x, y in ((10, 0), (0, 10), (-10, 0), (0, -10)): + expected -= DESIGNIR.Pos(x, y, 0) * DESIGNIR.Cylinder(1, 5) + DESIGNIR.export_step(expected, teacher) + + payload = SURFACEIR.extract_surfaceir(teacher) + semantic = payload["semantic_layer"] + parameters = semantic["parameters"] + + self.assertEqual(parameters["hole_count"]["value"], 4) + self.assertAlmostEqual(parameters["hole_diameter"]["value"], 2) + self.assertAlmostEqual( + parameters["bolt_circle_diameter"]["value"], 20 + ) + self.assertAlmostEqual(parameters["bore_diameter"]["value"], 10) + self.assertAlmostEqual(parameters["outer_diameter"]["value"], 40) + self.assertAlmostEqual(parameters["plate_thickness"]["value"], 5) + self.assertTrue( + any( + feature["operation"] == "polar_hole_pattern" + for feature in semantic["features"] + ) + ) + self.assertFalse( + semantic["inference_policy"]["claims_original_feature_history"] + ) + + edited = SURFACEIR.apply_semantic_parameter( + payload, "hole_diameter", 3 + ) + self.assertEqual(len(edited["edit_operations"]), 4) + self.assertEqual( + edited["active_edit"]["acceptance_status"], "pending" + ) + with self.assertRaisesRegex(ValueError, "increases only"): + SURFACEIR.apply_semantic_parameter( + payload, "hole_diameter", 1.5 + ) + edited_step = Path(temporary) / "flange-edited.step" + edited_shape = SURFACEIR.build_surfaceir(edited) + SURFACEIR.export_step_shape(edited_shape, edited_step) + edited_payload = SURFACEIR.extract_surfaceir(edited_step) + edited_parameters = edited_payload["semantic_layer"]["parameters"] + self.assertAlmostEqual( + edited_parameters["hole_diameter"]["value"], 3 + ) + self.assertAlmostEqual( + edited_parameters["bore_diameter"]["value"], 10 + ) + self.assertEqual(edited_parameters["hole_count"]["value"], 4) + actual = DESIGNIR.import_step(edited_step) + self.assertTrue(actual.is_valid) + self.assertLess(actual.volume, expected.volume) + + original_ir = Path(temporary) / "flange.designir.json" + SURFACEIR.write_json(original_ir, payload) + acceptance = SURFACEIR.validate_semantic_edit( + teacher, + original_ir, + "hole_diameter", + 2.5, + Path(temporary) / "acceptance", + ) + self.assertTrue(acceptance["accepted"]) + self.assertEqual(acceptance["state"], "accepted") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/CADSET_PROJECT_GUIDE.zh-CN.md b/docs/CADSET_PROJECT_GUIDE.zh-CN.md deleted file mode 100644 index 6bf8c18..0000000 --- a/docs/CADSET_PROJECT_GUIDE.zh-CN.md +++ /dev/null @@ -1,1124 +0,0 @@ -# CadSet 项目完整指南 - -> 面向自然语言、图文和 STEP 输入的参数化 CAD 生成、重建、修改与经验蒸馏系统。 - -## 1. 项目信息 - -- 项目浏览地址: -- Git 仓库地址: -- 当前开发分支:`codex/remove-cadam` -- 主要输出格式:DesignIR 2.0 JSON、STEP -- 建模后端:Build123d、SimpleCADAPI - -本文档面向三类读者: - -1. 想直接启动前端并生成 CAD 的使用者。 -2. 想批量蒸馏 STEP、进行参数化重建实验的研究人员。 -3. 想扩展 DesignIR、CAD Router、建模后端或验收系统的开发者。 - ---- - -## 2. CadSet 解决什么问题 - -CadSet 的目标不是只生成一个“看起来像”的网格,也不是把上传的 STEP -直接包装后宣称完成重建。系统希望得到同时满足以下条件的 CAD 模型: - -- 几何能够导出为标准 STEP。 -- 模型有明确的参数、基准、约束和特征顺序。 -- 后续可以修改孔径、厚度、孔距、数量等参数。 -- 修改后仍能稳定重建,而不是破坏模型。 -- 上传 STEP 的重建过程不依赖原 STEP 本体。 -- 从大量案例中学到的是通用建模方法,而不是某个零件的尺寸答案。 - -当前产品主线包含三种工作流: - -1. **自然语言生成**:根据文字描述生成可编辑的参数化零件。 -2. **图文生成与修改**:结合参考图片和文字生成或修改零件。 -3. **上传 STEP 重建与修改**:把 STEP 作为教师真值,转换成自有 - DesignIR,再独立重建和参数化修改。 - -CadSet 当前优先面向规则机械零件,例如: - -- 法兰、端盖、轴和轴套; -- 板件、支架、加强筋结构; -- 带孔块、孔阵列、槽和凸台; -- 基础壳体、连接件; -- 齿轮、齿条、轴承和部分减速器结构。 - -复杂自由曲面、多轨扫掠、高阶连续过渡面、缺陷 B-Rep 和大型装配仍属于 -需要继续扩展的能力边界。 - ---- - -## 3. 核心设计思想 - -### 3.1 STEP 是几何真值,不是可编辑设计历史 - -STEP 通常能够保存精确 B-Rep 几何、拓扑和装配信息,但并不可靠地保存原 -CAD 软件中的草图、特征树、尺寸名称和设计意图。 - -因此,从 STEP 中无法唯一恢复原作者的真实建模历史。例如同一个法兰既可 -以通过“圆柱拉伸后打孔”得到,也可以通过“截面旋转后阵列切除”得到。 - -CadSet 不声称恢复唯一的原始历史。它做的是: - -1. 从 STEP 提取确定性的几何证据。 -2. 推断一个合理、稳定、可编辑的参数化设计方案。 -3. 使用自有 DesignIR 表达这个方案。 -4. 在没有源 STEP 的环境中重新生成 STEP。 -5. 独立比较源 STEP 与重建 STEP。 -6. 扰动参数,验证模型是否真正可修改。 - -### 3.2 DesignIR 2.0 是统一设计源 - -DesignIR 是 CadSet 自有的、后端无关的参数化设计描述。它保存设计逻辑, -而不是嵌入原始 B-Rep 或完整网格。 - -一个 DesignIR 通常包含: - -| 字段 | 作用 | -| --- | --- | -| `coordinate_system` | 零件坐标系和轴方向。 | -| `datums` | 中心点、主轴、安装面等设计基准。 | -| `parameters` | 尺寸、角度、数量和比例参数。 | -| `expressions` | 参数之间的派生关系。 | -| `sketches` | 草图或轮廓语义。 | -| `constraints` | 同心、对称、等间距等约束。 | -| `features` | 拉伸、孔、凸台、阵列等有序特征。 | -| `patterns` | 极坐标或线性重复关系。 | -| `attachments` | 特征与主体、基准或安装面的连接关系。 | -| `construction_stages` | 推荐的构造阶段和重建顺序。 | -| `edit_interface` | 对外开放的可编辑参数与需保持的接口。 | -| `validation_contract` | 几何不变量和参数扰动测试。 | - -完整 Schema: - -[`designir-pipeline/contracts/designir-2.0.schema.json`](../designir-pipeline/contracts/designir-2.0.schema.json) - -可运行示例: - -[`designir-pipeline/examples/raised_hub_flange.designir.json`](../designir-pipeline/examples/raised_hub_flange.designir.json) - -### 3.3 源 STEP 必须与重建环境隔离 - -CadSet 明确禁止以下“伪重建”方式: - -- 在生成器中调用 `import_step(source.step)`。 -- 把原始 B-Rep、STEP 文本或完整三角网格塞进 JSON。 -- 把源 STEP 当作基础特征,再在上面做布尔修改。 -- 保存源模型的面 ID、边 ID 或运行时拓扑引用。 -- 重建时从隐藏路径重新读取教师 STEP。 - -最直接的防作弊检查是: - -> 从重建运行环境中移走源 STEP,仅依靠 DesignIR 和编译器,仍然能够生成 -> 完整模型。 - ---- - -## 4. 系统架构 - -```mermaid -flowchart LR - U["文字 / 图片 / STEP"] --> UI["CAD Agent Studio"] - UI --> LLM["多模态或文本模型"] - LLM --> IR["DesignIR 2.0"] - IR --> R["CAD Router"] - R --> B["Build123d"] - R --> S["SimpleCADAPI"] - B --> STEP["重建 STEP"] - S --> STEP - STEP --> V["3D Viewer"] - STEP --> A["独立验收"] - A --> F["验收报告与反馈"] -``` - -对于上传 STEP,链路进一步分成教师侧和重建侧: - -```mermaid -flowchart TD - T["源 STEP(教师真值)"] --> E["确定性几何证据提取"] - E --> RA["Agent A:重建"] - X["已晋升通用经验"] --> RA - RA --> D["自有 DesignIR"] - D --> C["隔离编译器"] - C --> R["重建 STEP"] - T --> AB["Agent B:独立验收"] - D --> AB - R --> AB - AB --> P["确定性晋升器"] - P --> X -``` - -关键点是:Agent A 和隔离编译器不能读取源 STEP;Agent B 可以读取源 STEP, -但不能直接发布经验。 - ---- - -## 5. 仓库结构 - -```text -CadSet/ -├── cad-agent-studio/ Web 前端、对话、上传、任务和 3D 预览 -├── designir-pipeline/ DesignIR、双 Agent、重建、验收和经验蒸馏 -├── text-to-cad/ CAD Router、Build123d CAD 能力和 Viewer 运行时 -├── SimpleCADAPI/ 第二建模后端和可重放操作图 -├── docs/ 项目级详细文档 -├── llm.config.yaml 共享 LLM 配置 -├── README.md 项目概览 -├── .gitignore 本地数据、依赖和生成物排除规则 -└── .gitattributes 文本换行和二进制格式属性 -``` - -### 5.1 CAD Agent Studio - -[`cad-agent-studio/`](../cad-agent-studio/) - -这是面向最终使用者的 Next.js 前端: - -- 左侧:对话、图片/STEP 上传、模型供应商和模型选择。 -- 右侧:STEP 三维预览、选择、剖切、显示和编辑辅助工具。 -- 服务端:读取模型配置、调用 LLM、生成 DesignIR、调用 CAD Router 和编译器。 -- 任务数据:默认保存在 `cad-agent-studio/data/tasks//`。 - -### 5.2 DesignIR Pipeline - -[`designir-pipeline/`](../designir-pipeline/) - -负责: - -- DesignIR Schema 和验证; -- 防作弊检查; -- Build123d / SimpleCADAPI 编译适配; -- 教师隔离重建; -- 几何与参数可编辑性验收; -- STEP 批量证据提取; -- 双 Agent 合同; -- 经验候选验证和发布。 - -### 5.3 text-to-cad - -[`text-to-cad/`](../text-to-cad/) - -负责: - -- CAD Router; -- 通用 STEP-first 机械建模; -- Build123d 运行时; -- 几何检查和可视化; -- CAD Viewer 相关能力。 - -### 5.4 SimpleCADAPI - -[`SimpleCADAPI/`](../SimpleCADAPI/) - -这是基于 OCP/OpenCascade 的第二建模后端,适合: - -- 可重放操作图; -- 参数表达式; -- 语义标签和拓扑查询; -- 齿轮、齿条、轴承等专用机械结构; -- STEP、STL 和 FreeCAD 工作流。 - ---- - -## 6. 环境准备 - -### 6.1 推荐环境 - -- macOS 或 Linux; -- Node.js 22 或更高版本; -- Python 3.12; -- Git; -- 能够运行 OpenCascade / Build123d 的本地环境; -- 支持 WebGL 的现代浏览器。 - -### 6.2 克隆项目 - -```bash -git clone https://gitea.robotquan.com/xuyongjie/cadSet.git -cd cadSet -git switch codex/remove-cadam -``` - -如果已经存在本地仓库: - -```bash -git fetch origin -git switch codex/remove-cadam -git pull --ff-only origin codex/remove-cadam -``` - -### 6.3 Python 环境 - -前端默认查找: - -```text -text-to-cad/.venv/bin/python -``` - -如果仓库中没有可用虚拟环境,可创建: - -```bash -python3.12 -m venv text-to-cad/.venv -source text-to-cad/.venv/bin/activate -python -m pip install --upgrade pip -python -m pip install -r text-to-cad/requirements-dev.txt -python -m pip install -e SimpleCADAPI -``` - -也可以显式指定其他 Python: - -```bash -export CAD_PYTHON=/absolute/path/to/python -export CAD_EXPERIENCE_PYTHON=/absolute/path/to/python -``` - -### 6.4 前端依赖 - -```bash -cd cad-agent-studio -npm install -``` - -依赖目录 `node_modules/` 不进入 Git。 - ---- - -## 7. LLM 配置 - -默认配置文件位于仓库根目录: - -```text -llm.config.yaml -``` - -前端按以下顺序寻找配置: - -1. `CAD_AGENT_STUDIO_CONFIG` 指定的绝对路径; -2. 仓库根目录 `llm.config.yaml`; -3. `cad-agent-studio/config/llm.config.yaml`; -4. `cad-agent-studio/config/llm.config.yaml.example`。 - -推荐使用环境变量保存密钥: - -```yaml -defaultProvider: deepseek -defaultModel: deepseek:deepseek-chat - -providers: - deepseek: - type: openai-compatible - apiKeyEnv: DEEPSEEK_API_KEY - baseURL: https://api.deepseek.com - models: - default: deepseek-chat - reasoner: deepseek-reasoner - -uploads: - maxImageMB: 20 - maxStepMB: 200 - maxOtherMB: 50 - allowed: - - image/png - - image/jpeg - - image/webp - - .step - - .stp - -cad: - taskRoot: ./data/tasks - viewer: - mode: iframe - allowedOrigins: - - http://127.0.0.1 - - http://localhost -``` - -然后设置密钥: - -```bash -export DEEPSEEK_API_KEY="your-key" -``` - -配置也支持直接写 `apiKey`,但不要把真实密钥复制到公开文档、日志或公开仓库。 -前端公开配置接口不会返回密钥和环境变量名称。 - ---- - -## 8. 启动项目 - -```bash -cd cad-agent-studio -npm run dev -- -H 127.0.0.1 -p 53821 -``` - -浏览器打开: - - - -生产构建: - -```bash -npm run build -npm run start -- -H 127.0.0.1 -p 53821 -``` - -如果前端不在 CadSet 根目录的直接子目录,可以指定引擎根目录: - -```bash -export CAD_ENGINE_ROOT=/absolute/path/to/CadSet -``` - ---- - -## 9. 如何使用 - -### 9.1 自然语言生成零件 - -在对话框中尽量提供: - -- 零件类型; -- 关键尺寸和单位; -- 孔、槽、凸台、圆角等特征; -- 阵列数量和分布方式; -- 哪些尺寸需要后续编辑; -- 是否有必须保持的安装界面。 - -示例: - -```text -生成一个半径 50 mm、厚度 4 mm 的法兰盘。 -中心孔直径 20 mm,中心孔外有一圈高 1 mm 的凸台。 -在直径 80 mm 的分度圆上均匀分布 8 个 M3 间隙孔。 -外径、厚度、孔径、孔数和分度圆直径都需要可编辑。 -``` - -系统会: - -1. 让模型生成完整 DesignIR。 -2. 运行 CAD Router 选择后端。 -3. 执行 DesignIR Schema 与防作弊验证。 -4. 在隔离目录中生成 STEP。 -5. 生成 Viewer 所需预览数据。 -6. 在任务目录保存 DesignIR、STEP 和任务清单。 - -### 9.2 根据图片和文字生成 - -上传零件图片后,应补充图片无法可靠表达的信息: - -- 实际尺寸; -- 对称关系; -- 被遮挡特征; -- 孔是否贯穿; -- 板厚; -- 圆角、倒角或拔模角; -- 图片是透视图、正视图还是技术图。 - -图像能力依赖所选模型。如果使用纯文本模型,例如不支持视觉输入的模型, -它不能直接理解图片。此时应切换到支持视觉的 OpenAI-compatible 模型,或 -先把图纸尺寸和结构转成文字。 - -### 9.3 上传 STEP 进行参数化重建 - -在前端上传 `.step` 或 `.stp` 后,说明希望重建和开放的参数,例如: - -```text -重建这个 STEP。识别主体、孔、槽、凸台和阵列关系。 -优先开放整体长度、板厚、孔径、孔距和孔数量。 -重建结果不能依赖原 STEP,并对这些参数分别做扰动测试。 -``` - -处理原则: - -1. 上传 STEP 只作为教师和验收真值。 -2. 系统先读取确定性几何证据。 -3. 模型根据证据生成 DesignIR。 -4. 重建编译器不能访问教师 STEP。 -5. Agent B 比较教师 STEP 与重建 STEP。 -6. 参数扰动测试验证修改能力。 - -STEP 没有完整建模历史,因此复杂零件不保证一次得到理想的参数化分解。 -系统应当对无法表达的特征明确输出 -`unsupported_feature_vocabulary`,而不是偷偷复用原几何。 - -### 9.4 修改已生成模型 - -选中已有任务后,可以使用自然语言修改: - -```text -把 plate_thickness 从 4 mm 改成 6 mm,其他尺寸不变。 -``` - -或者: - -```text -孔数改为 10,保持分度圆直径和中心孔不变。 -``` - -修改应优先作用于任务中记录的 `*.designir.json`。系统会为修改请求创建 -revision,保存 `edit-request.json`,然后重新生成 STEP 和 Viewer 资源。 - -不要直接修改最终 STEP 作为长期设计源,否则参数语义和编辑接口会丢失。 - ---- - -## 10. CAD Router 的工作方式 - -CAD Router 根据任务特征选择后端: - -### Build123d - -适合: - -- 通用机械零件; -- 法兰、支架、轴、壳体和连接件; -- 一般机加工特征; -- 几何测量和验证; -- 自定义特征组合。 - -### SimpleCADAPI - -适合: - -- 齿轮、齿条、内齿圈; -- 轴承、摆线结构和部分减速器; -- 需要可重放操作图的任务; -- 需要语义标签和拓扑查询的任务。 - -查看路由决策: - -```bash -text-to-cad/.venv/bin/python \ - text-to-cad/skills/cad-router/scripts/route.py \ - "生成一个带 8 个安装孔的法兰" \ - --explain -``` - -强制指定后端: - -```bash -text-to-cad/.venv/bin/python \ - text-to-cad/skills/cad-router/scripts/route.py \ - "生成一个参数化齿轮" \ - --backend simplecadapi \ - --explain -``` - -路由器可以读取已发布经验库: - -```text -designir-pipeline/output/library.json -``` - -它不能读取 `designir-pipeline/input/` 或 `designir-pipeline/runs/` 中的私有 -教师数据和蒸馏过程。 - ---- - -## 11. DesignIR 命令行 - -以下命令均在仓库根目录执行。 - -### 11.1 验证 DesignIR - -```bash -text-to-cad/.venv/bin/python \ - designir-pipeline/scripts/designir_pipeline.py validate \ - designir-pipeline/examples/raised_hub_flange.designir.json -``` - -### 11.2 防作弊检查 - -```bash -text-to-cad/.venv/bin/python \ - designir-pipeline/scripts/designir_pipeline.py anti-cheat \ - designir-pipeline/examples/raised_hub_flange.designir.json -``` - -如果同时有后端生成器: - -```bash -text-to-cad/.venv/bin/python \ - designir-pipeline/scripts/designir_pipeline.py anti-cheat \ - part.designir.json \ - --generator generator.py -``` - -### 11.3 编译 STEP - -```bash -text-to-cad/.venv/bin/python \ - designir-pipeline/scripts/designir_pipeline.py compile \ - designir-pipeline/examples/raised_hub_flange.designir.json \ - --output /tmp/raised_hub_flange.step \ - --backend build123d -``` - -### 11.4 隔离重建 - -```bash -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 simplecadapi -``` - -SimpleCADAPI 后端还会在 STEP 旁生成可重放的 -`*.simplecad.model.json`。 - -### 11.5 独立验收 - -```bash -text-to-cad/.venv/bin/python \ - designir-pipeline/scripts/designir_pipeline.py accept \ - --teacher /path/to/teacher.step \ - --designir /path/to/part.designir.json \ - --rebuilt /path/to/rebuilt.step \ - --report /path/to/acceptance-report.json -``` - ---- - -## 12. STEP 蒸馏系统 - -### 12.1 简化后的数据目录 - -```text -designir-pipeline/ -├── input/ 原始 STEP/STP -├── runs/ 全部蒸馏过程 -└── output/ - └── library.json 最终发布经验库 -``` - -Git 规则: - -| 数据 | 是否提交 | -| --- | --- | -| `input/*.step`、`input/*.stp` | 否 | -| 提取的私有几何证据 | 否 | -| Agent 草稿和中间 DesignIR | 否 | -| 重建 STEP | 否 | -| 验收报告和失败记录 | 否 | -| `output/library.json` | 是 | -| Schema、脚本、Agent 合同和测试 | 是 | - -`runs/` 中的目录由程序按需创建: - -```text -runs/ -├── evidence/ STEP 提取的私有证据 -├── designir/ Agent A 输出 -├── rebuilt/ 隔离重建 STEP -├── acceptance/ Agent B 验收报告 -├── review/ 语义批次和 LLM 草稿 -└── quarantine/ 失败和能力缺口 -``` - -### 12.2 放入蒸馏数据 - -把所有目标 STEP/STP 直接放入: - -```text -designir-pipeline/input/ -``` - -无需再创建 `teacher/inbox` 等多层目录。 - -### 12.3 批量提取证据 - -```bash -designir-pipeline/scripts/cad-experience extract-folder -``` - -默认读取: - -```text -designir-pipeline/input/ -``` - -默认写入: - -```text -designir-pipeline/runs/evidence/ -``` - -提取器会根据内容哈希跳过已处理的重复文件。私有证据可以包含精确尺寸、 -坐标、曲面类型、轴线、包围盒、体积和拓扑统计,但这些内容不能直接发布。 - -### 12.4 准备语义蒸馏批次 - -```bash -designir-pipeline/scripts/cad-experience prepare -``` - -输出: - -```text -designir-pipeline/runs/review/semantic-batch.json -``` - -这个批次会移除不适合公开的实例信息,并保留: - -- 零件家族; -- 参数角色; -- 基准角色; -- 特征角色; -- 关系角色; -- 特征数量等级; -- 推荐构造阶段; -- 无量纲比例观察; -- 验证目标; -- 失败和修复候选。 - -### 12.5 Agent 进行语义归纳 - -调用 `cad-experience-builder`,让当前大模型完整阅读 -`semantic-batch.json`,输出: - -```text -designir-pipeline/runs/review/experience-draft.json -``` - -大模型应该归纳: - -- 通用特征组合; -- 约束和设计不变量; -- 参数角色及依赖关系; -- 家族级 reconstruction grammar; -- 构造顺序; -- 验证方法; -- 可复用的失败修复策略。 - -它不应该复制: - -- 某个零件的绝对尺寸; -- 源文件路径和哈希; -- 绝对坐标; -- 面、边或拓扑 ID; -- 完整实例参数字典; -- 教师几何。 - -### 12.6 确定性验证与发布 - -```bash -designir-pipeline/scripts/cad-experience publish \ - --draft designir-pipeline/runs/review/experience-draft.json -``` - -发布器会重新核对私有语料中的支持数量和置信度,拒绝实例泄漏,只把通过 -验证的内容写入: - -```text -designir-pipeline/output/library.json -``` - -审计: - -```bash -designir-pipeline/scripts/cad-experience audit \ - designir-pipeline/output/library.json -``` - -查询经验: - -```bash -designir-pipeline/scripts/cad-experience query \ - designir-pipeline/output/library.json \ - --family flanged_hub_adapter \ - --features base_flange,hollow_sleeve,counterbore -``` - -直接绕过 LLM 语义归纳进行纯统计 `distill` 会被阻止。原因是简单共现统计 -难以可靠地区分“设计方法”和“某批数据中的偶然尺寸模式”。 - ---- - -## 13. 双 Agent 的职责 - -### Agent A:Reconstruction Agent - -输入: - -- `runs/evidence/` 中的单案例私有证据; -- 用户修改目标; -- 已晋升的通用经验; -- DesignIR Schema 和支持的操作词汇。 - -输出: - -- `runs/designir/*.designir.json`; -- 需要隔离编译的重建请求; -- 不确定项和缺失能力。 - -禁止: - -- 读取源 STEP; -- 导入 B-Rep; -- 使用完整网格代替参数化模型; -- 保存源拓扑引用; -- 直接发布经验。 - -### Agent B:Acceptance Agent - -输入: - -- `input/` 中的教师 STEP; -- Agent A 生成的 DesignIR; -- `runs/rebuilt/` 中的重建 STEP; -- 修改测试合同。 - -输出: - -- `runs/acceptance/` 中的机器可读报告; -- `pass`、`fail` 或 `quarantine` 建议。 - -Agent B 检查: - -- 实体和拓扑数量; -- 包围盒; -- 体积和质心; -- 对称差体积; -- 关键解析特征; -- 参数扰动成功率; -- 非目标区域保持情况; -- 自交、零厚度、非流形和不稳定重建。 - -Agent B 同样不能直接把经验写入正式库。最终发布由确定性程序执行。 - -### 纯文本模型能否完成蒸馏和验收 - -可以完成大量工作,但存在边界: - -- STEP 几何证据由程序提取后,纯文本模型可以阅读 JSON、归纳特征和生成 - DesignIR。 -- 数值验收由几何程序完成,不要求模型直接看图。 -- 纯文本模型可以根据验收 JSON 分析失败和提出修复。 -- 对视觉外观、复杂曲面、特征语义歧义和图纸理解,多模态模型通常更强。 - -因此 DeepSeek 等纯文本模型可以参与批量蒸馏闭环,但不应让模型自己“凭 -感觉”验收。验收真值必须来自确定性几何比较;关键失败案例可再交给多模态 -模型或人工复核。 - ---- - -## 14. 经验为什么会随着数据增加而变好 - -蒸馏数据量增加后,系统可能提升: - -- 零件家族识别; -- 特征分解; -- 主参数发现; -- 参数依赖推断; -- 基准和约束识别; -- 构造顺序选择; -- 修改接口设计; -- 常见失败修复; -- 验收目标选择。 - -但数据量不是唯一因素。能力增长受三个条件共同限制: - -1. **DesignIR 能否表达**:没有 sweep、loft、shell 或复杂孔词汇时,再多 - 数据也无法独立重建这类结构。 -2. **证据质量是否足够**:提取器必须提供可用于推断的曲面、轴线、关系和 - 截面证据。 -3. **验收是否严格**:如果只比较截图或包围盒,错误经验也可能被晋升。 - -经验晋升必须同时满足: - -```text -几何接近 -AND 特征语义合理 -AND 参数可编辑 -AND 修改后稳定 -AND 多个独立案例重复支持 -``` - -当前默认晋升策略至少要求 3 个独立案例状态通过;经验归纳命令默认使用更 -保守的 `min_support=20` 和 `min_confidence=0.8`。两者用途不同: - -- `minimum_independent_cases=3` 是完整重建和回放状态的最低发布门槛。 -- `min_support=20` 是从批量语料归纳通用经验时的默认统计支持度。 - -策略文件: - -[`designir-pipeline/config/promotion-policy.json`](../designir-pipeline/config/promotion-policy.json) - ---- - -## 15. 验收指标 - -不要把所有结果压缩成一个不透明的“相似度分数”。建议分别保存: - -### 几何一致性 - -- 相对体积误差; -- 包围盒各轴误差; -- 质心误差; -- 对称差体积比例; -- 双向表面距离; -- 关键截面误差。 - -### 特征语义 - -- 特征召回率; -- 特征准确率; -- 孔、槽、筋和凸台数量; -- 轴线、半径、同心和阵列关系; -- 是否生成多余特征。 - -### 参数化能力 - -- 关键尺寸参数覆盖率; -- 参数扰动成功率; -- 修改目标准确率; -- 非目标区域保持率; -- 约束保持率; -- 编辑接口稳定性。 - -### 几何有效性 - -- 是否为闭合实体; -- 是否为空; -- 是否自交; -- 是否出现零厚度; -- 是否出现非流形几何; -- 重复执行是否确定。 - -当前策略中的主要默认阈值包括: - -- 相对体积误差不超过 `1%`; -- 质心误差不超过 `0.1 mm`; -- 包围盒各轴误差不超过 `0.1 mm`; -- 对称差比例不超过 `2%`; -- 参数扰动成功率要求 `100%`。 - -这些阈值是当前工程默认值,不是所有零件和制造场景的通用标准。 - ---- - -## 16. 任务产物 - -前端任务默认保存在: - -```text -cad-agent-studio/data/tasks// -``` - -典型产物包括: - -```text -/ -├── cad-task.json -├── model.designir.json -├── model.step -├── model.simplecad.model.json 可选 -├── acceptance-report.json STEP 重建时可选 -├── revisions/ -│ └── / -│ └── edit-request.json -└── Viewer 预览资源 -``` - -职责划分: - -- `model.designir.json`:可编辑设计源。 -- `model.step`:主要几何交换结果。 -- `cad-task.json`:路由、后端、产物和验证记录。 -- `*.simplecad.model.json`:SimpleCADAPI 的可重放操作图。 -- `acceptance-report.json`:教师与重建件的独立验收结果。 -- `revisions/`:后续修改请求记录。 - -`cad-agent-studio/data/` 是本地运行数据,不提交 Git。 - ---- - -## 17. 测试与质量检查 - -### 前端测试 - -```bash -cd cad-agent-studio -npm test -npm run build -``` - -### DesignIR、经验库和 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 -designir-pipeline/scripts/cad-experience audit \ - designir-pipeline/output/library.json -``` - -### Git 数据边界检查 - -确认没有 STEP 输入和蒸馏过程被跟踪: - -```bash -git ls-files designir-pipeline/input \ - designir-pipeline/runs \ - designir-pipeline/output -``` - -正常情况下只应看到: - -```text -designir-pipeline/input/.gitkeep -designir-pipeline/output/library.json -``` - ---- - -## 18. 常见问题 - -### 前端提示找不到 Python - -确认以下文件存在并可执行: - -```text -text-to-cad/.venv/bin/python -``` - -或者设置: - -```bash -export CAD_PYTHON=/absolute/path/to/python -``` - -### 前端提示找不到 LLM 配置 - -确认仓库根目录存在: - -```text -llm.config.yaml -``` - -或者: - -```bash -export CAD_AGENT_STUDIO_CONFIG=/absolute/path/to/llm.config.yaml -``` - -### 模型可以对话但不能生成 STEP - -检查: - -1. Python 环境是否包含 Build123d/OCP 或 SimpleCADAPI。 -2. `CAD_ENGINE_ROOT` 是否指向 CadSet 根目录。 -3. DesignIR 是否通过 Schema 验证。 -4. DesignIR 是否使用了当前不支持的 operation。 -5. Router 选择的后端是否在当前 Python 环境可用。 - -### 上传图片后模型仍说看不到 - -当前所选模型可能不支持视觉输入。更换支持图片的模型,或把图片中的结构 -和尺寸转换成文字描述。 - -### STEP 重建几何接近但不好修改 - -这通常表示参数化分解不合理,例如: - -- 所有尺寸都被写成固定常量; -- 孔和阵列没有独立参数; -- 基准选择不稳定; -- 特征顺序导致参数变化后布尔失败; -- 修改合同没有覆盖关键参数。 - -应优先改进 DesignIR 和特征分解,而不是放宽几何验收。 - -### 为什么不直接修改上传的 STEP - -直接在源 STEP 上做布尔操作可以快速得到几何结果,但无法建立稳定、通用 -的参数化设计能力,也不能随着蒸馏数据增加而学习更好的重建方法。CadSet -把源 STEP 限定为教师和验收真值,是为了让系统真正学会独立重建。 - -### 为什么过程数据不提交 Git - -原始 STEP、证据 JSON、重建件和验收报告会快速增长,而且可能包含私有 -实例尺寸。仓库只提交经过净化和验证的经验库结果,以及复现管线所需的代码、 -Schema、合同和测试。 - ---- - -## 19. 扩展项目 - -### 新增 DesignIR 操作 - -至少需要同步更新: - -1. `designir-2.0.schema.json` 的 operation 词汇; -2. Build123d 编译适配; -3. SimpleCADAPI 编译适配,或明确标记仅支持某后端; -4. anti-cheat 检查; -5. 参数扰动和几何验收; -6. 示例与测试; -7. CAD Router 能力表。 - -### 新增后端 - -新后端应满足: - -- 接收显式 DesignIR 和输出路径; -- 不隐式扫描整个工作区; -- 不读取教师 STEP; -- 输出标准 STEP; -- 保留可编辑的后端源或操作图; -- 返回明确的成功/失败状态; -- 记录后端名称和版本; -- 接入统一验收合同。 - -### 扩展蒸馏能力 - -推荐按失败聚类驱动扩展: - -1. 收集 `runs/quarantine/` 中的失败。 -2. 区分证据不足、词汇不足、推断错误和编译错误。 -3. 对高频缺口新增 DesignIR 操作或几何证据。 -4. 加入针对性重建和参数扰动测试。 -5. 在 held-out STEP 上验证。 -6. 通过后再允许经验晋升。 - ---- - -## 20. 当前边界与研发建议 - -CadSet 当前已经形成了完整的工程骨架: - -- 独立 Web 前端; -- 统一 DesignIR; -- 双建模后端; -- STEP 隔离重建; -- 双 Agent 职责边界; -- 参数扰动验收; -- 批量经验蒸馏; -- 防实例泄漏发布器; -- 运行数据与 Git 结果隔离。 - -下一阶段最值得投入的方向: - -1. 扩展 DesignIR 的扫掠、放样、薄壁、倒角、圆角和复合孔词汇。 -2. 增强 STEP 解析中的特征关系、关键截面和对称性识别。 -3. 建立按零件家族分层的数据集和 held-out 测试集。 -4. 增加表面双向距离和局部非目标区域保持指标。 -5. 将失败案例自动聚类为“证据不足、词汇不足、编译失败、参数失稳”。 -6. 为大规模数据增加任务队列、断点续跑、并发控制和状态看板。 -7. 对高价值复杂案例加入多模态复核,但继续以确定性几何验收为最终依据。 - -CadSet 的核心原则可以概括为: - -> STEP 只做教师,DesignIR 才是设计源;模型必须独立重建,经验必须经过 -> 多案例和修改测试验证后才能进入生产。 - diff --git a/text-to-cad/README.md b/text-to-cad/README.md index a6a6aac..8d96f43 100644 --- a/text-to-cad/README.md +++ b/text-to-cad/README.md @@ -6,7 +6,7 @@ STEP-first CAD engine used by CadSet. 1. Generate an editable model from natural language. 2. Generate or modify an editable model from text plus reference images. -3. Reconstruct an uploaded STEP into DesignIR 2.0, then modify and rebuild it +3. Reconstruct an uploaded STEP into DesignIR 3.0 / SurfaceIR, then modify and rebuild it without reading the teacher STEP. `cad-router` chooses one of two execution backends: @@ -34,6 +34,22 @@ reconstruction runner rejects source STEP imports, embedded B-Rep, full mesh substitutes, source face references, and paths into the protected teacher workspace. Acceptance runs separately against the rebuilt STEP. +## Uploaded STEP execution + +Run the full upload path through `cad-router`: + +```bash +.venv/bin/python skills/cad-router/scripts/route.py \ + "重建上传的 STEP,并返回可修改参数" \ + --edit-source /absolute/path/to/part.step \ + --task-dir /absolute/path/to/task \ + --execute +``` + +The task returns an independently rebuilt STEP, its DesignIR 3.0 source, +geometry acceptance measurements, parameter perturbation results, and a catalog +containing only validated editable parameters. + ## Development Use the repository-local Python environment for checks: diff --git a/text-to-cad/skills/cad-router/SKILL.md b/text-to-cad/skills/cad-router/SKILL.md index 5368f05..ea2bd55 100644 --- a/text-to-cad/skills/cad-router/SKILL.md +++ b/text-to-cad/skills/cad-router/SKILL.md @@ -1,6 +1,6 @@ --- name: cad-router -description: Route natural-language or image-guided CAD generation and modification, plus uploaded STEP/STP reconstruction and modification, across text-to-cad and SimpleCADAPI. Create one executable DesignIR 2.0 model per part, produce STEP geometry without teacher geometry dependencies, and enforce independent validation, visual repair, and CAD Viewer review. +description: Route natural-language or image-guided CAD generation and modification, plus uploaded STEP/STP reconstruction and modification, across text-to-cad and SimpleCADAPI. Use unified executable DesignIR 3.0 in semantic-feature mode for authored models and SurfaceIR mode for uploaded STEP reconstruction, without teacher geometry dependencies, with independent validation and CAD Viewer review. --- # CAD Router @@ -9,7 +9,7 @@ Choose a backend before modeling, keep its source as the editable authority, and ## Required workflow -1. Convert the natural-language or image-guided request into a brief: part type, dimensions, units, assembly relationships, editability, and validation targets. The artifact contract is fixed to executable DesignIR 2.0 plus STEP geometry. +1. Convert the natural-language or image-guided request into a brief: part type, dimensions, units, assembly relationships, editability, and validation targets. Every model uses DesignIR 3.0 plus STEP geometry. New text/image models use `fully_semantic_parametric`; uploaded STEP reconstruction uses `surface_parametric`. A generated semantic model may include a compiled SurfaceIR snapshot and then uses `hybrid_semantic_surface_parametric`, while its semantic layer remains authoritative. Never treat a teacher STEP or a single-case DesignIR as experience. Batch case JSON belongs to the separate CAD Experience plugin. Generation may consume only its promoted schema `2.0` generalized library. @@ -31,22 +31,38 @@ Choose a backend before modeling, keep its source as the editable authority, and dimensionless distributions second. Use `--experience-library`, `--experience-family`, or repeated `--experience-feature` only to override those defaults. + For one uploaded STEP, execute the route rather than stopping after the + routing decision: + + ```bash + ../../.venv/bin/python scripts/route.py \ + "重建上传的 STEP,并返回可修改参数" \ + --edit-source /absolute/path/to/upload.step \ + --task-dir /absolute/path/to/task \ + --execute + ``` + + This command must finish extraction, source-independent reconstruction, + geometry acceptance, parameter perturbation acceptance, and artifact + publication. A route decision alone is not a completed reconstruction. 3. Follow the selected route unless a hard environmental constraint makes it unavailable. If overriding it, record the reason in the task manifest. 4. Load only the selected backend reference from `references/capabilities.md` and the matching installed Skill: - `build123d`: use `$cad`; this is the default for general STEP-first mechanical parts, assemblies, and existing STEP modification. - `simplecadapi`: use an installed SimpleCADAPI Skill/runtime; prefer it for gears, racks, ring gears, bearings, cycloidal parts, reducers, replayable graphs, and semantic tags. 5. When the route reports `requirement_refinement` or `visual_repair`, refine the brief, generate, execute, inspect multiple views, and repair no more than three visual mismatch rounds before asking the user. -6. Generate into a task-owned directory and start CAD Viewer with that task directory as `--dir`; do not expose the repository-wide fixture library for a task review. Write one `*.designir.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/designir-2.0.md` before creating, reconstructing, or modifying a model. Never change backend during an edit unless conversion is explicitly requested; modify the recorded DesignIR source of truth. - For a supplied STEP/STP, the deterministic ingestion service may extract - private teacher evidence. The reconstruction agent receives that evidence, - never the STEP itself, and must author complete executable DesignIR. Compile - it in an isolated environment where the teacher STEP is absent. +6. Generate into a task-owned directory and start CAD Viewer with that task directory as `--dir`; do not expose the repository-wide fixture library for a task review. Write one DesignIR 3.0 `*.designir.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/designir-3.0.md` for both semantic authoring and uploaded STEP reconstruction. `references/designir-2.0.md` is migration-only. Never change backend during an edit unless conversion is explicitly requested; modify the recorded authoritative layer. + For a supplied STEP/STP, use `scripts/route.py --execute`; do not manually + approximate the model when the executable adapter is available. The + deterministic ingestion service extracts private teacher evidence into + DesignIR 3.0. The independent rebuild subprocess receives the DesignIR and + output path, never the STEP path. The acceptance stage alone receives both + teacher and reconstruction. The DesignIR and generator must not import STEP, embed B-Rep or meshes, retain face references, or use the source model as a base feature. The acceptance agent alone compares the teacher and reconstructed STEP. - If the request does not name the source part family, inspect the explicit - source first and rerun the experience query with `--experience-family` and - `--experience-feature`; do not guess across family-scoped experience. + Only expose parameters listed by `parameters.json`; every listed parameter + has passed an isolated perturbation test. Other inferred names remain + evidence, not a promised edit interface. 7. Validate against the brief. Run STEP geometry inspection and snapshot review. For experience-informed generation, record which generalized methods were applied, including the reconstruction grammar ID and instantiated symbolic @@ -70,6 +86,6 @@ Choose a backend before modeling, keep its source as the editable authority, and - Read `references/capabilities.md` when comparing or invoking backends. - Read `references/backend-contract.md` before writing a task manifest or adding another backend. -- Read `references/designir-2.0.md` when generating, reconstructing, or - modifying a per-part executable design. +- Read `references/designir-3.0.md` for semantic authoring, uploaded STEP reconstruction, and edits. +- Read `references/designir-2.0.md` only when migrating legacy artifacts. - `scripts/capabilities.json` is the machine-readable V1 registry used by `scripts/route.py`. diff --git a/text-to-cad/skills/cad-router/agents/openai.yaml b/text-to-cad/skills/cad-router/agents/openai.yaml index 2b6ef9c..16f2734 100644 --- a/text-to-cad/skills/cad-router/agents/openai.yaml +++ b/text-to-cad/skills/cad-router/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "CAD Router" short_description: "Route CAD and manage per-part JSON model specs." - default_prompt: "Use $cad-router to select the best CAD backend, create or modify the part through DesignIR 2.0, validate the result independently, and open the STEP in CAD Viewer." + default_prompt: "Use $cad-router to select the best CAD backend, create or modify the part through unified DesignIR 3.0, validate the result independently, and open the STEP in CAD Viewer." diff --git a/text-to-cad/skills/cad-router/references/backend-contract.md b/text-to-cad/skills/cad-router/references/backend-contract.md index 4693c1d..e62ef50 100644 --- a/text-to-cad/skills/cad-router/references/backend-contract.md +++ b/text-to-cad/skills/cad-router/references/backend-contract.md @@ -70,14 +70,17 @@ Write `cad-task.json` beside the task artifacts with this minimum shape: Paths are relative to the task directory unless an external input must remain absolute. Do not store authoritative model parameters in `cad-task.json`. Every part must -have a sibling `*.designir.json` that owns its coordinate system, datums, -parameters, expressions, features, constraints, edit interface, and validation -contract. See `designir-2.0.md`. +have a sibling `*.designir.json` as its editable source of truth. Authored +feature models use semantic DesignIR 3.0; uploaded STEP reconstruction uses +DesignIR 3.0 / SurfaceIR. See `designir-3.0.md`; `designir-2.0.md` is retained +only for legacy migration. -For an uploaded STEP, keep teacher geometry in `designir-pipeline/input/` and -all private evidence, DesignIR candidates, rebuilds, and acceptance reports in -`designir-pipeline/runs/`. The reconstruction task receives evidence but no -teacher STEP or imported B-Rep. +For an uploaded STEP, use `scripts/route.py --execute` with an explicit source +and task directory. Do not move an ordinary upload into or scan the +distillation corpus. The task publishes the independent DesignIR, reconstructed +STEP, validated parameter catalog, geometry report, edit report, and +`cad-task.json`. The reconstruction subprocess receives no teacher STEP path; +only extraction and acceptance may read the explicit uploaded source. ## Backend adapter requirements diff --git a/text-to-cad/skills/cad-router/references/capabilities.md b/text-to-cad/skills/cad-router/references/capabilities.md index e8ca93d..799bb20 100644 --- a/text-to-cad/skills/cad-router/references/capabilities.md +++ b/text-to-cad/skills/cad-router/references/capabilities.md @@ -9,7 +9,7 @@ Best fit: - Brackets, connecting rods, cranks, levers, shafts, bushings, flanges, plates, housings, covers, enclosures, fixtures, and machined features. - Parametric Python sources with holes, pockets, slots, bosses, ribs, shells, fillets, chamfers, lofts, sweeps, and revolves. - Source-level assemblies, named datums, joints, imported components, measurements, alignment checks, and existing STEP inspection. -- STEP output compiled from executable DesignIR 2.0. +- STEP output compiled from executable semantic DesignIR 3.0. Weak signals: a specialized mechanical family already implemented by SimpleCADAPI or a browser-only execution requirement. diff --git a/text-to-cad/skills/cad-router/references/designir-2.0.md b/text-to-cad/skills/cad-router/references/designir-2.0.md index 89eeecb..19d03bf 100644 --- a/text-to-cad/skills/cad-router/references/designir-2.0.md +++ b/text-to-cad/skills/cad-router/references/designir-2.0.md @@ -1,5 +1,9 @@ # DesignIR 2.0 contract +> Legacy migration reference only. New text, image, and STEP workflows must +> emit DesignIR 3.0. Use `designir_pipeline.py migrate` to preserve an existing +> 2.0 semantic feature program inside the DesignIR 3.0 envelope. + Every generated, reconstructed, or modified part owns one executable `*.designir.json`. It records design intent rather than source faces or a copied solid. diff --git a/text-to-cad/skills/cad-router/references/designir-3.0.md b/text-to-cad/skills/cad-router/references/designir-3.0.md new file mode 100644 index 0000000..1c270fc --- /dev/null +++ b/text-to-cad/skills/cad-router/references/designir-3.0.md @@ -0,0 +1,129 @@ +# DesignIR 3.0 unified contract + +DesignIR 3.0 is the only editable part contract. It has three explicit modes: + +- `fully_semantic_parametric`: authored from text or images. `semantic_layer` + contains design intent, named parameters, constraints and executable + features. +- `surface_parametric`: inferred from an uploaded STEP. `surface_layer` + contains analytic/B-spline surfaces, boundary curves, pcurves, vertices, + tolerances, shells and solids in CadSet's vocabulary. +- `hybrid_semantic_surface_parametric`: a semantic feature program plus a + compiled SurfaceIR snapshot extracted from its generated STEP. The semantic + layer remains authoritative. + +The two authoring paths share one envelope and edit vocabulary, but they do not +claim equivalent evidence. Text/image generation authors design intent. STEP +ingestion infers semantics from final B-Rep and never claims to recover the +source CAD application's sketch or feature history. + +## Text and image authoring + +Author these top-level values: + +```json +{ + "schema_version": "3.0", + "designir_kind": "independent_parametric_cad", + "model_id": "part_id", + "family": "semantic_part_family", + "units": "mm", + "document_status": "geometry_present", + "reconstruction_mode": "fully_semantic_parametric", + "authoring_mode": "semantic_feature_program", + "backend_hint": "build123d", + "semantic_layer": { + "reconstruction_status": "ready", + "coordinate_system": { + "origin": [0, 0, 0], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1] + }, + "datums": {}, + "parameters": {}, + "expressions": {}, + "sketches": [], + "constraints": [], + "features": [], + "patterns": [], + "attachments": [], + "construction_stages": [] + }, + "edit_interface": { + "semantic_parameters": [], + "surface_parameter_groups": [], + "modification_levels": ["semantic_feature"], + "preserved_interfaces": [] + }, + "validation_contract": { + "source_independence": true, + "geometry_checks": ["solid_count", "bounding_box", "volume"], + "edit_checks": ["parameter_perturbation"], + "invariants": [], + "perturbations": [], + "thresholds": {} + } +} +``` + +Every name in `semantic_parameters` must identify an editable parameter, drive +at least one feature, and have a perturbation test. Prefer intent names such as +`flange_thickness`, `shelf_depth`, `hole_spacing`, `rib_count`, +`bore_diameter`, and `bolt_circle_diameter`. + +After isolated compilation, attach SurfaceIR only from the generated STEP and +switch the mode to `hybrid_semantic_surface_parametric`. Never use a teacher +STEP to fill the compiled snapshot. + +## Uploaded STEP execution + +Run an uploaded file through the router's executable adapter: + +```bash +../../.venv/bin/python scripts/route.py \ + "重建上传的 STEP,并返回可修改参数" \ + --edit-source /absolute/path/to/part.step \ + --task-dir /absolute/path/to/task \ + --execute +``` + +The task directory contains: + +- `.designir.json`: independent editable source of truth; +- `.reconstructed.step`: reconstructed geometry; +- `parameters.json`: only parameters that passed perturbation acceptance; +- `validation/geometry.json`: teacher-versus-reconstruction measurements; +- `validation/edits.json`: parameter acceptance outcomes; +- `cad-task.json`: route, artifacts, isolation and validation state. + +## Isolation boundary + +Extraction and acceptance may read the uploaded STEP. The reconstruction +subprocess receives only DesignIR and an output path. DesignIR must not contain +a teacher path, an import operation, STEP text, embedded B-Rep, or a mesh +substitute. + +The runtime performs an anti-cheat scan before rebuilding. Geometry acceptance +requires valid reconstructed topology and the thresholds recorded in +`validation/geometry.json`. + +The adapter loads only formally promoted experiences whose scope is +`surfaceir_reconstruction` or `surfaceir_semantic_editing`. These methods are +applied conditionally by the runtime; `cad-task.json` records both the available +method count and the exact method IDs used for the task. + +## Parameter boundary + +STEP does not contain authoritative feature names or modeling history. The +semantic layer therefore records inferred canonical parameters separately from +validated editable parameters. + +Only parameters in `parameters.json` are public edit controls. Each was changed +in isolation, rebuilt, and checked for target response, preserved validity and +non-target stability. Use the `edit_command_template` in `cad-task.json` to +produce an edited DesignIR and STEP. + +An accepted baseline reconstruction does not imply that every inferred +parameter is editable. An empty `parameters.json` is valid for an empty source +document and must not be presented as editable geometry. diff --git a/text-to-cad/skills/cad-router/scripts/reconstruct_step.py b/text-to-cad/skills/cad-router/scripts/reconstruct_step.py new file mode 100644 index 0000000..d6b8341 --- /dev/null +++ b/text-to-cad/skills/cad-router/scripts/reconstruct_step.py @@ -0,0 +1,661 @@ +#!/usr/bin/env python3 +"""Execute one uploaded STEP -> DesignIR 3.0 reconstruction task. + +The teacher STEP is passed only to deterministic extraction and acceptance +commands. The independent rebuild subprocess receives the DesignIR path and +output path only. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + + +SCRIPT_PATH = Path(__file__).resolve() +SKILL_ROOT = SCRIPT_PATH.parents[1] +WORKSPACE_ROOT = SKILL_ROOT.parents[2] +SURFACEIR_PIPELINE = ( + WORKSPACE_ROOT / "designir-pipeline" / "scripts" / "surfaceir_pipeline.py" +) +EXPERIENCE_LIBRARY = ( + WORKSPACE_ROOT / "designir-pipeline" / "output" / "library.json" +) +DESIGNIR_SCHEMA = ( + WORKSPACE_ROOT + / "designir-pipeline" + / "contracts" + / "designir-3.0.schema.json" +) + +EXECUTABLE_EDIT_STATES = { + "executable_enlarge_cut_binding", + "executable_axis_affine_binding", + "executable_uniform_scale_binding", + "validated_executable_binding", +} +FORBIDDEN_DESIGNIR_TOKENS = ( + '"source_path"', + '"teacher_step"', + "teacher.step", + "import_step", + "stepcontrol_reader", + '"brep"', + '"mesh"', +) + + +class ReconstructionError(RuntimeError): + """Raised when a reconstruction stage cannot produce an accepted result.""" + + +def _runtime_python() -> Path: + configured = os.environ.get("CAD_ROUTER_PYTHON", "").strip() + candidates = [ + Path(configured).expanduser() if configured else None, + WORKSPACE_ROOT / "text-to-cad" / ".venv" / "bin" / "python", + Path(sys.executable), + ] + for candidate in candidates: + if candidate is not None and candidate.is_file(): + # Preserve a virtualenv launcher symlink. Resolving it to the base + # interpreter discards the virtualenv's package search path. + return candidate.absolute() + raise ReconstructionError( + "No CAD runtime Python found; set CAD_ROUTER_PYTHON or create " + "text-to-cad/.venv." + ) + + +def _safe_stem(source: Path) -> str: + stem = re.sub(r"[^a-zA-Z0-9_.-]+", "_", source.stem).strip("._") + return stem or "uploaded_part" + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _run_json( + command: list[str], + *, + timeout_seconds: int, + stage: str, +) -> dict[str, Any]: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise ReconstructionError( + f"{stage} failed with exit code {completed.returncode}: " + f"{detail[-2000:]}" + ) + lines = [line for line in completed.stdout.splitlines() if line.strip()] + if not lines: + return {} + try: + return json.loads(lines[-1]) + except json.JSONDecodeError as exc: + raise ReconstructionError( + f"{stage} returned non-JSON output: {lines[-1][-1000:]}" + ) from exc + + +def _validate_designir_contract(payload: dict[str, Any]) -> dict[str, Any]: + required = { + "schema_version", + "designir_kind", + "model_id", + "units", + "document_status", + "reconstruction_mode", + "semantic_layer", + "surface_layer", + "edit_interface", + "validation_contract", + } + missing = sorted(required - set(payload)) + if missing: + raise ReconstructionError( + f"DesignIR 3.0 is missing required fields: {', '.join(missing)}" + ) + if payload["schema_version"] != "3.0": + raise ReconstructionError("Uploaded STEP did not produce DesignIR 3.0") + if payload["designir_kind"] != "independent_parametric_cad": + raise ReconstructionError("DesignIR is not source-independent") + if payload["validation_contract"].get("source_independence") is not True: + raise ReconstructionError("DesignIR source-independence contract is false") + serialized = json.dumps(payload, ensure_ascii=False).lower() + violations = [ + token for token in FORBIDDEN_DESIGNIR_TOKENS if token in serialized + ] + if violations: + raise ReconstructionError( + "DesignIR anti-cheat scan failed: " + ", ".join(violations) + ) + return { + "schema_contract": "designir-3.0", + "schema_path": str(DESIGNIR_SCHEMA), + "required_fields_present": True, + "source_independence_declared": True, + "anti_cheat_pass": True, + "forbidden_token_matches": 0, + } + + +def _candidate_parameters( + payload: dict[str, Any], maximum: int +) -> list[tuple[str, float]]: + parameters = payload.get("semantic_layer", {}).get("parameters", {}) + names = payload.get("edit_interface", {}).get("semantic_parameters", []) + feature_candidates: list[tuple[str, float]] = [] + fallback: tuple[str, float] | None = None + for name in names: + parameter = parameters.get(name) + if not isinstance(parameter, dict): + continue + if parameter.get("edit_state") not in EXECUTABLE_EDIT_STATES: + continue + try: + old_value = float(parameter["value"]) + except (KeyError, TypeError, ValueError): + continue + candidate = (name, old_value * 1.1) + if name == "overall_scale": + fallback = candidate + else: + feature_candidates.append(candidate) + selected = feature_candidates[: max(0, maximum)] + if fallback is not None: + selected.append(fallback) + return selected + + +def _surfaceir_experience_ids() -> set[str]: + if not EXPERIENCE_LIBRARY.is_file(): + return set() + try: + payload = json.loads(EXPERIENCE_LIBRARY.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return set() + policy = payload.get("policy", {}) + if ( + payload.get("library_kind") != "generalized_cad_experience" + or policy.get("surfaceir_consumable") is not True + or policy.get("instance_parameters_allowed") is not False + ): + return set() + return { + item["id"] + for item in payload.get("experiences", []) + if isinstance(item, dict) + and isinstance(item.get("id"), str) + and set(item.get("scope", [])).intersection( + {"surfaceir_reconstruction", "surfaceir_semantic_editing"} + ) + } + + +def _applied_experience_ids( + available: set[str], accepted_parameters: list[dict[str, Any]] +) -> list[str]: + applied = { + experience_id + for experience_id in ( + "failure_repair.surface_topology.bind_face_pcurves_and_group_shells", + "failure_repair.surface_topology.preserve_vertex_tolerance", + ) + if experience_id in available + } + names = {parameter["name"] for parameter in accepted_parameters} + if any("hole_diameter" in name or "bore_diameter" in name for name in names): + applied.update( + experience_id + for experience_id in ( + "edit_strategy.semantic_cylindrical_cut.enlarge_diameter", + "failure_repair.semantic_edit.merge_duplicate_cylindrical_targets", + "edit_strategy.semantic_edit.preserve_validated_geometry_strategy", + ) + if experience_id in available + ) + if "overall_scale" in names: + experience_id = ( + "edit_strategy.semantic_parameter.source_independent_overall_scale" + ) + if experience_id in available: + applied.add(experience_id) + if names.intersection( + {"outer_diameter", "body_length", "plate_thickness"} + ): + experience_id = ( + "edit_strategy.semantic_parameter.primary_axis_affine_dimensions" + ) + if experience_id in available: + applied.add(experience_id) + if any(name.startswith("overall_size_") for name in names): + experience_id = ( + "edit_strategy.semantic_parameter.canonical_directional_envelope" + ) + if experience_id in available: + applied.add(experience_id) + return sorted(applied) + + +def _persist_edit_results( + designir_path: Path, + attempts: list[dict[str, Any]], +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + payload = json.loads(designir_path.read_text(encoding="utf-8")) + parameters = payload.get("semantic_layer", {}).get("parameters", {}) + accepted_parameters: list[dict[str, Any]] = [] + attempted_names = set() + for attempt in attempts: + name = attempt["parameter"] + attempted_names.add(name) + parameter = parameters[name] + old_value = float(parameter["value"]) + requested_value = float(attempt["requested_value"]) + if attempt["accepted"]: + parameter["editable"] = True + parameter["edit_state"] = "validated_executable_binding" + parameter["validated_range"] = { + "direction": "increase", + "minimum_tested_inclusive": old_value, + "maximum_tested_inclusive": requested_value, + "acceptance_contract": "designir_3_semantic_edit_acceptance", + } + accepted_parameters.append( + { + "name": name, + "semantic_role": parameter.get("semantic_role", name), + "value": parameter["value"], + "unit": parameter.get("unit", "mm"), + "validated_range": parameter["validated_range"], + "confidence": parameter.get("confidence"), + } + ) + else: + parameter["editable"] = False + parameter["edit_state"] = "rejected_by_runtime_validation" + parameter.pop("validated_range", None) + payload["edit_interface"]["semantic_parameters"] = sorted( + parameter["name"] for parameter in accepted_parameters + ) + payload["reconstruction_strategy"] = { + "boundary_strategy": "exact_3d_pcurve", + "selection_status": "geometry_validated", + } + _write_json(designir_path, payload) + return payload, accepted_parameters + + +def reconstruct_uploaded_step( + source: Path, + task_dir: Path, + *, + request: str, + maximum_parameters: int = 8, + timeout_seconds: int = 180, +) -> dict[str, Any]: + source = source.expanduser().resolve() + task_dir = task_dir.expanduser().resolve() + if not source.is_file(): + raise ReconstructionError(f"Uploaded STEP does not exist: {source}") + if source.suffix.lower() not in {".step", ".stp"}: + raise ReconstructionError("Uploaded reconstruction source must be STEP/STP") + if source == task_dir or task_dir == source.parent: + raise ReconstructionError( + "Task directory must be separate from the uploaded STEP directory" + ) + if source == task_dir or task_dir in source.parents: + raise ReconstructionError( + "Task directory cannot contain the uploaded teacher STEP" + ) + if not SURFACEIR_PIPELINE.is_file(): + raise ReconstructionError( + f"DesignIR 3.0 runtime is missing: {SURFACEIR_PIPELINE}" + ) + + task_dir.mkdir(parents=True, exist_ok=True) + stem = _safe_stem(source) + designir_path = task_dir / f"{stem}.designir.json" + rebuilt_path = task_dir / f"{stem}.reconstructed.step" + validation_dir = task_dir / "validation" + geometry_report_path = validation_dir / "geometry.json" + edit_report_path = validation_dir / "edits.json" + parameters_path = task_dir / "parameters.json" + task_manifest_path = task_dir / "cad-task.json" + collisions = [ + path + for path in ( + designir_path, + rebuilt_path, + parameters_path, + task_manifest_path, + ) + if path.exists() + ] + if collisions: + raise ReconstructionError( + "Refusing to overwrite existing task artifacts: " + + ", ".join(str(path) for path in collisions) + ) + + runtime = _runtime_python() + common = [str(runtime), str(SURFACEIR_PIPELINE)] + available_experience_ids = _surfaceir_experience_ids() + boundary_strategy = ( + "exact_3d_pcurve" + if ( + "failure_repair.surface_topology.bind_face_pcurves_and_group_shells" + in available_experience_ids + ) + else "exact_3d" + ) + + extraction = _run_json( + common + + [ + "extract", + str(source), + "--output", + str(designir_path), + ], + timeout_seconds=timeout_seconds, + stage="DesignIR 3.0 extraction", + ) + payload = json.loads(designir_path.read_text(encoding="utf-8")) + contract_checks = _validate_designir_contract(payload) + + # The rebuild worker receives no teacher path. It can regenerate the model + # from the DesignIR artifact alone. + _run_json( + common + + [ + "rebuild", + str(designir_path), + "--output", + str(rebuilt_path), + "--boundary-strategy", + boundary_strategy, + ], + timeout_seconds=timeout_seconds, + stage="source-independent SurfaceIR rebuild", + ) + + manifest_path = task_dir / "manifest.json" + _write_json( + manifest_path, + { + "schema_version": "1.0", + "input_count": 1, + "rows": [ + { + "source_name": source.name, + "output_name": designir_path.name, + "state": "extracted", + } + ], + }, + ) + validation_dir.mkdir(parents=True, exist_ok=True) + geometry_work_dir = validation_dir / ".geometry-work" + geometry_result = _run_json( + common + + [ + "validate-folder", + str(source.parent), + "--designir-dir", + str(task_dir), + "--rebuilt-dir", + str(geometry_work_dir), + "--limit", + "1", + "--boolean", + "--boundary-strategy", + boundary_strategy, + ], + timeout_seconds=timeout_seconds, + stage="Acceptance-Agent geometry validation", + ) + raw_geometry_report = geometry_work_dir / "validation-report.json" + geometry_report = json.loads(raw_geometry_report.read_text(encoding="utf-8")) + _write_json(geometry_report_path, geometry_report) + geometry_pass = geometry_report.get("geometry_pass_count") == 1 + if not geometry_pass: + raise ReconstructionError( + "Independent reconstruction did not satisfy the geometry contract; " + f"see {geometry_report_path}" + ) + shutil.rmtree(geometry_work_dir) + + attempts: list[dict[str, Any]] = [] + if payload["document_status"] == "geometry_present": + for name, requested_value in _candidate_parameters( + payload, maximum_parameters + ): + safe_name = _safe_stem(Path(name)) + attempt_dir = validation_dir / "parameter-tests" / safe_name + try: + result = _run_json( + common + + [ + "validate-edit", + str(source), + str(designir_path), + "--parameter", + name, + "--value", + str(requested_value), + "--output-dir", + str(attempt_dir), + ], + timeout_seconds=timeout_seconds, + stage=f"parameter validation ({name})", + ) + report = json.loads( + (attempt_dir / "acceptance-report.json").read_text( + encoding="utf-8" + ) + ) + attempts.append( + { + "parameter": name, + "requested_value": requested_value, + "accepted": bool(report.get("accepted")), + "state": report.get("state"), + "report": str( + attempt_dir / "acceptance-report.json" + ), + "worker": result, + } + ) + except (ReconstructionError, subprocess.TimeoutExpired) as exc: + attempts.append( + { + "parameter": name, + "requested_value": requested_value, + "accepted": False, + "state": "worker_failed", + "error": str(exc), + } + ) + + payload, accepted_parameters = _persist_edit_results( + designir_path, attempts + ) + payload["reconstruction_strategy"] = { + "boundary_strategy": boundary_strategy, + "selection_status": "geometry_validated", + } + _write_json(designir_path, payload) + applied_experience_ids = _applied_experience_ids( + available_experience_ids, accepted_parameters + ) + edit_report = { + "schema_version": "1.0", + "report_kind": "cad_router_runtime_parameter_acceptance", + "attempt_count": len(attempts), + "accepted_count": sum(row["accepted"] for row in attempts), + "attempts": attempts, + } + _write_json(edit_report_path, edit_report) + parameter_catalog = { + "schema_version": "1.0", + "model_id": payload["model_id"], + "document_status": payload["document_status"], + "parameter_count": len(accepted_parameters), + "parameters": accepted_parameters, + "policy": "Only parameters that passed an isolated perturbation test are exposed.", + } + _write_json(parameters_path, parameter_catalog) + + task_manifest = { + "schema_version": "1.0", + "request": request, + "operation": "uploaded_step_reconstruction", + "route": { + "router": "cad-router", + "runtime": "DesignIR 3.0 / SurfaceIR", + "selected_backend": "build123d", + "execution_adapter": "surfaceir_occt", + "source_of_truth": designir_path.name, + }, + "source": { + "format": source.suffix.lower().lstrip("."), + "role": "teacher_and_acceptance_truth_only", + "embedded_in_output": False, + }, + "artifacts": [ + {"path": rebuilt_path.name, "role": "primary_step"}, + {"path": designir_path.name, "role": "editable_designir"}, + {"path": parameters_path.name, "role": "validated_parameters"}, + { + "path": str(geometry_report_path.relative_to(task_dir)), + "role": "geometry_acceptance", + }, + { + "path": str(edit_report_path.relative_to(task_dir)), + "role": "parameter_acceptance", + }, + ], + "experience": { + "library": ( + str(EXPERIENCE_LIBRARY) + if EXPERIENCE_LIBRARY.is_file() + else None + ), + "runtime_policy": "promoted_generalized_experience_only", + "available_method_count": len(available_experience_ids), + "methods_applied": applied_experience_ids, + }, + "validation": { + "geometry_accepted": True, + "source_independent_rebuild": True, + "anti_cheat_pass": True, + "validated_parameter_count": len(accepted_parameters), + }, + "edit_command_template": [ + str(runtime), + str(SURFACEIR_PIPELINE), + "edit", + designir_path.name, + "--parameter", + "", + "--value", + "", + "--output-designir", + "", + "--output-step", + "", + ], + } + _write_json(task_manifest_path, task_manifest) + manifest_path.unlink(missing_ok=True) + + return { + "schema_version": "1.0", + "status": ( + "accepted" + if payload["document_status"] == "geometry_present" + else "accepted_empty_source" + ), + "task_dir": str(task_dir), + "designir": str(designir_path), + "rebuilt_step": str(rebuilt_path), + "parameters": str(parameters_path), + "task_manifest": str(task_manifest_path), + "geometry_report": str(geometry_report_path), + "edit_report": str(edit_report_path), + "geometry_accepted": True, + "document_status": payload["document_status"], + "validated_parameter_count": len(accepted_parameters), + "validated_parameter_names": [ + parameter["name"] for parameter in accepted_parameters + ], + "experience": { + "available_method_count": len(available_experience_ids), + "methods_applied": applied_experience_ids, + }, + "source_independence": { + "rebuild_command_received_teacher_path": False, + **contract_checks, + }, + "stages": { + "extraction": extraction, + "geometry_validation": geometry_result, + }, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--task-dir", type=Path, required=True) + parser.add_argument("--request", default="重建上传的 STEP") + parser.add_argument("--maximum-parameters", type=int, default=8) + parser.add_argument("--timeout-seconds", type=int, default=180) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + result = reconstruct_uploaded_step( + args.source, + args.task_dir, + request=args.request, + maximum_parameters=args.maximum_parameters, + timeout_seconds=args.timeout_seconds, + ) + except (ReconstructionError, subprocess.TimeoutExpired) as exc: + print( + json.dumps( + {"status": "failed", "error": str(exc)}, + ensure_ascii=False, + ), + file=sys.stderr, + ) + return 1 + print(json.dumps(result, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/text-to-cad/skills/cad-router/scripts/route.py b/text-to-cad/skills/cad-router/scripts/route.py index 3d3b153..69f0857 100755 --- a/text-to-cad/skills/cad-router/scripts/route.py +++ b/text-to-cad/skills/cad-router/scripts/route.py @@ -5,6 +5,7 @@ import argparse import importlib.util import json import os +import subprocess import sys from dataclasses import dataclass, field from pathlib import Path @@ -12,6 +13,7 @@ from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parents[1] REGISTRY_PATH = Path(__file__).with_name("capabilities.json") +RECONSTRUCTION_ADAPTER = Path(__file__).with_name("reconstruct_step.py") @dataclass @@ -335,8 +337,10 @@ def load_experience_library( path: Path | None, family: str | None = None, features: list[str] | None = None, + consumer: str = "router", ) -> dict[str, object] | None: """Load promoted generalized methods; reject case parameters and coordinates.""" + explicit_library = path is not None candidate = path or default_experience_library() if candidate is None: return None @@ -358,15 +362,26 @@ def load_experience_library( or policy.get("single_case_promotion_allowed") is not False ): raise ValueError("CAD experience library policy permits instance-answer leakage") - if ( - policy.get("llm_semantic_review_required") is not True - or policy.get("draft_evidence_verified") is not True - or policy.get("router_consumable") is not True - ): + reviewed = ( + policy.get("llm_semantic_review_required") is True + and policy.get("draft_evidence_verified") is True + ) + consumer_flag = ( + "surfaceir_consumable" + if consumer == "surfaceir" + else "router_consumable" + ) + if not reviewed: raise ValueError( "CAD experience library was not produced by LLM semantic review " "plus deterministic evidence verification" ) + if policy.get(consumer_flag) is not True: + if not explicit_library: + return None + raise ValueError( + f"CAD experience library is not approved for {consumer}" + ) forbidden = _find_forbidden_experience_key(payload) if forbidden: raise ValueError(f"CAD experience library contains forbidden instance key: {forbidden}") @@ -388,6 +403,12 @@ def load_experience_library( if not isinstance(item, dict): continue scope = set(item.get("scope", [])) + if consumer == "surfaceir": + if scope.intersection( + {"surfaceir_reconstruction", "surfaceir_semantic_editing"} + ): + selected.append(item) + continue if "global" not in scope: if not compatible_scopes or scope.isdisjoint(compatible_scopes): continue @@ -402,6 +423,11 @@ def load_experience_library( "compatible_scopes": sorted(compatible_scopes), "requested_features": sorted(requested_features), "experiences": selected, + "selection_policy": ( + "surfaceir_runtime_conditioned" + if consumer == "surfaceir" + else "request_family_and_feature_match" + ), "policy": { "contains_instance_parameters": False, "contains_absolute_coordinates": False, @@ -429,6 +455,7 @@ def route(args: argparse.Namespace) -> dict[str, object]: args.experience_library, experience_family, experience_features, + "surfaceir" if teacher_step_reconstruction else "router", ) needs_source_classification = bool(edit_context and not experience_family) scores = { @@ -480,7 +507,12 @@ def route(args: argparse.Namespace) -> dict[str, object]: registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) backend_info = registry["backends"][selected.name] - source_of_truth = "per-part DesignIR 2.0 with an isolated backend compiler" + editable_contract = "DesignIR 3.0" + source_of_truth = ( + "per-part DesignIR 3.0 with an isolated SurfaceIR compiler" + if teacher_step_reconstruction + else "per-part semantic DesignIR 3.0 with an isolated backend compiler" + ) gap = selected.score - max((scores[name].score for name in scores if name != selected.name), default=0) confidence = "high" if gap >= 40 else "medium" if gap >= 15 else "low" result: dict[str, object] = { @@ -499,7 +531,7 @@ def route(args: argparse.Namespace) -> dict[str, object]: "workflow_profiles": profiles, "artifact_contract": { "primary_format": "step", - "editable_contract": "DesignIR 2.0", + "editable_contract": editable_contract, }, "source_of_truth": source_of_truth, "experience_context": experience_context, @@ -604,9 +636,72 @@ def route(args: argparse.Namespace) -> dict[str, object]: }, "availability": probe_backends() if args.probe else None, } + if teacher_step_reconstruction: + result["execution_adapter"] = { + "path": str(RECONSTRUCTION_ADAPTER), + "runtime": "DesignIR 3.0 / SurfaceIR", + "automatic_geometry_acceptance": True, + "automatic_parameter_acceptance": True, + } return result +def execute_reconstruction( + args: argparse.Namespace, result: dict[str, object] +) -> dict[str, object]: + edit_context = result.get("design_plan", {}).get("edit_context") + if not isinstance(edit_context, dict) or edit_context.get( + "modification_mode" + ) != "private_evidence_to_isolated_designir_reconstruction": + raise ValueError( + "--execute currently requires --edit-source with one STEP/STP file" + ) + if args.edit_source is None or args.task_dir is None: + raise ValueError( + "--execute requires both --edit-source and --task-dir" + ) + runtime = os.environ.get("CAD_ROUTER_PYTHON", "").strip() + workspace_python = ( + SKILL_ROOT.parents[2] / "text-to-cad" / ".venv" / "bin" / "python" + ) + interpreter = ( + Path(runtime).expanduser() + if runtime + else workspace_python + if workspace_python.is_file() + else Path(sys.executable) + ) + command = [ + str(interpreter), + str(RECONSTRUCTION_ADAPTER), + "--source", + str(args.edit_source), + "--task-dir", + str(args.task_dir), + "--request", + args.request, + "--maximum-parameters", + str(args.maximum_parameters), + "--timeout-seconds", + str(args.timeout_seconds), + ] + completed = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError( + "cad-router STEP reconstruction failed: " + detail[-3000:] + ) + lines = [line for line in completed.stdout.splitlines() if line.strip()] + if not lines: + raise RuntimeError("cad-router reconstruction returned no result") + return json.loads(lines[-1]) + + def explain(result: dict[str, object]) -> str: lines = [ f"Selected backend: {result['selected_backend']}", @@ -665,12 +760,36 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--explain", action="store_true", help="Print a human-readable decision instead of JSON.") parser.add_argument("--manifest", type=Path, help="Also write the route decision as JSON to this explicit path.") + parser.add_argument( + "--execute", + action="store_true", + help="Execute an uploaded STEP reconstruction after routing.", + ) + parser.add_argument( + "--task-dir", + type=Path, + help="Explicit output directory for --execute.", + ) + parser.add_argument( + "--maximum-parameters", + type=int, + default=8, + help="Maximum feature-specific edit candidates to validate; overall_scale is also tested as fallback.", + ) + parser.add_argument( + "--timeout-seconds", + type=int, + default=180, + help="Timeout for each reconstruction or acceptance worker.", + ) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) result = route(args) + if args.execute: + result["execution"] = execute_reconstruction(args, result) payload = json.dumps(result, ensure_ascii=False, indent=2) + "\n" if args.manifest: args.manifest.parent.mkdir(parents=True, exist_ok=True) diff --git a/text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py b/text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py new file mode 100644 index 0000000..c0b2a25 --- /dev/null +++ b/text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +from build123d import Box, export_step + + +ADAPTER_PATH = ( + Path(__file__).resolve().parents[4] + / "skills" + / "cad-router" + / "scripts" + / "reconstruct_step.py" +) +SPEC = importlib.util.spec_from_file_location( + "cad_router_reconstruct_step", ADAPTER_PATH +) +assert SPEC is not None and SPEC.loader is not None +adapter = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = adapter +SPEC.loader.exec_module(adapter) + + +class UploadedStepRuntimeTests(unittest.TestCase): + def test_uploaded_step_reconstructs_and_publishes_validated_parameter(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + upload_dir = root / "upload" + task_dir = root / "task" + upload_dir.mkdir() + source = upload_dir / "box.step" + export_step(Box(10, 20, 30), source) + + result = adapter.reconstruct_uploaded_step( + source, + task_dir, + request="重建上传的 STEP", + maximum_parameters=0, + timeout_seconds=60, + ) + + self.assertEqual("accepted", result["status"]) + self.assertTrue(result["geometry_accepted"]) + self.assertFalse( + result["source_independence"][ + "rebuild_command_received_teacher_path" + ] + ) + self.assertTrue( + result["source_independence"]["anti_cheat_pass"] + ) + self.assertIn( + "failure_repair.surface_topology.bind_face_pcurves_and_group_shells", + result["experience"]["methods_applied"], + ) + self.assertTrue(Path(result["rebuilt_step"]).is_file()) + self.assertTrue(Path(result["designir"]).is_file()) + self.assertFalse( + (task_dir / "validation" / ".geometry-work").exists() + ) + + parameters = json.loads( + Path(result["parameters"]).read_text(encoding="utf-8") + ) + self.assertEqual(1, parameters["parameter_count"]) + self.assertEqual( + "overall_scale", parameters["parameters"][0]["name"] + ) + geometry = json.loads( + Path(result["geometry_report"]).read_text(encoding="utf-8") + ) + self.assertEqual(1, geometry["geometry_pass_count"]) + self.assertEqual(0, geometry["geometry_fail_count"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/text-to-cad/tests/python/skills/cad-router/test_route.py b/text-to-cad/tests/python/skills/cad-router/test_route.py index c8667f5..e573b0e 100644 --- a/text-to-cad/tests/python/skills/cad-router/test_route.py +++ b/text-to-cad/tests/python/skills/cad-router/test_route.py @@ -37,7 +37,7 @@ class CadRouterTests(unittest.TestCase): self.assertEqual( { "primary_format": "step", - "editable_contract": "DesignIR 2.0", + "editable_contract": "DesignIR 3.0", }, result["artifact_contract"], ) @@ -345,6 +345,26 @@ class CadRouterTests(unittest.TestCase): self.assertTrue( plan["experience_query_plan"]["requires_source_inspection"] ) + self.assertEqual( + "DesignIR 3.0", result["artifact_contract"]["editable_contract"] + ) + self.assertEqual( + "DesignIR 3.0 / SurfaceIR", + result["execution_adapter"]["runtime"], + ) + self.assertEqual( + "surfaceir_runtime_conditioned", + result["experience_context"]["selection_policy"], + ) + self.assertGreater( + len(result["experience_context"]["experiences"]), 0 + ) + + def test_execute_requires_step_source_and_task_directory(self) -> None: + args = self.parse("生成一个法兰", "--execute") + result = route_module.route(args) + with self.assertRaisesRegex(ValueError, "requires --edit-source"): + route_module.execute_reconstruction(args, result) def test_native_generator_is_preferred_for_modification(self) -> None: result = route_module.route(