Files
reducers/backend/src/toolchain/api.py
T

1056 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import mimetypes
import re
import shutil
import subprocess
import sys
import traceback
import zipfile
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
from ..cli import _prepare_structured_run_dir
from ..joint_module.auto_design import DEFAULT_PLATFORM_PROFILE, run_auto_joint_design
from ..joint_module.generator import run_joint_module
from ..joint_module.urdf_exporter import export_urdf
from ..models import read_json, write_json, write_model_json
from ..parameter_solver import load_requirement, solve_parameters
from ..kinematics import motion_samples, solve_instance
from ..placement import solve_reducer_placements
from ..cad.simplecad_generator import generate_simplecad
from ..reducer_urdf_exporter import export_reducer_urdf
from ..topology import build_graph, validate_topology
from ..topology_runtime import load_default_template
from ..validators import validate_from_files
from ..task_store import (
BACKEND_ROOT,
RUN_ROOT,
artifact_url,
ensure_task,
list_tasks,
read_task_manifest,
safe_task_id,
task_dir,
update_task,
write_upload,
)
DEFAULT_RUN_NAME = "frontend_split_motor_7nm"
DEFAULT_JOINT_REQUIREMENT = BACKEND_ROOT / "input" / "requirements" / "joint_modules" / "3500_motor_housing.json"
JOINT_REQUIREMENT_TEMPLATES = {
"simple_2k_h": DEFAULT_JOINT_REQUIREMENT,
"simple_2k_h_cascade": BACKEND_ROOT / "input" / "requirements" / "joint_modules" / "3500_motor_housing_cascade.json",
}
CAD_ROUTER_RECONSTRUCTOR = Path("/Users/lk/Projects/cadSet/text-to-cad/skills/cad-router/scripts/reconstruct_step.py")
REDUCER_REQUIREMENT_TEMPLATES = {
"simple_2k_h": BACKEND_ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_5.json",
"simple_2k_h_cascade": BACKEND_ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_spur.json",
"ferguson_wolfrom": BACKEND_ROOT / "input" / "requirements" / "reducers" / "ferguson_wolfrom" / "ferguson_wolfrom_ratio_531p25_spur.json",
}
SUPPORTED_REQUIREMENT_OVERRIDES = {
"topology_family",
"target_ratio",
"tooth_form",
"module_mm",
"planet_count",
"helix_angle_deg",
"pressure_angle_deg",
"face_width_mm",
"backlash_mm",
"ring_rim_thickness_mm",
"max_outer_diameter_mm",
}
def _send_json(handler: BaseHTTPRequestHandler, status: int, payload: object) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Access-Control-Allow-Origin", "*")
handler.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
handler.end_headers()
handler.wfile.write(body)
def _send_file(handler: BaseHTTPRequestHandler, path: Path) -> None:
if not path.exists() or not path.is_file():
_send_json(handler, 404, {"error": f"file_not_found: {path}"})
return
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
body = path.read_bytes()
handler.send_response(200)
handler.send_header("Content-Type", content_type)
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Access-Control-Allow-Origin", "*")
handler.end_headers()
handler.wfile.write(body)
def _read_body(handler: BaseHTTPRequestHandler) -> bytes:
length = int(handler.headers.get("Content-Length") or 0)
return handler.rfile.read(length) if length else b""
def _read_json(handler: BaseHTTPRequestHandler) -> dict[str, object]:
body = _read_body(handler)
if not body.strip():
return {}
payload = json.loads(body.decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("request_body_must_be_json_object")
return payload
def _safe_relative_file(task_id: str, relative: str) -> Path:
root = task_dir(task_id).resolve()
path = (root / unquote(relative)).resolve()
try:
path.relative_to(root)
except ValueError as exc:
raise ValueError("invalid_artifact_path") from exc
return path
def _first_number(text: str, patterns: list[str], *, integer: bool = False) -> int | float | None:
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
value = float(match.group(1))
return int(value) if integer else value
return None
def _request_requirement_overrides(request: str) -> dict[str, object]:
text = str(request or "")
overrides: dict[str, object] = {}
if re.search(r"(双级|两级|二级|串联|cascade|two[- ]stage)", text, flags=re.IGNORECASE):
overrides["topology_family"] = "simple_2k_h_cascade"
elif re.search(r"(ferguson|wolfrom|沃尔夫罗姆|弗格森)", text, flags=re.IGNORECASE):
overrides["topology_family"] = "ferguson_wolfrom"
elif re.search(r"(单级|simple[_ -]?2k[_ -]?h)", text, flags=re.IGNORECASE):
overrides["topology_family"] = "simple_2k_h"
tooth_form_match = re.search(r"(斜齿|helical|直齿|spur)", text, flags=re.IGNORECASE)
if tooth_form_match:
overrides["tooth_form"] = (
"helical"
if re.search(r"(斜齿|helical)", tooth_form_match.group(1), flags=re.IGNORECASE)
else "spur"
)
numeric_patterns: dict[str, list[str]] = {
"target_ratio": [
r"(?:传动比|减速比|速比|ratio)\s*(?:为|是|设为|=|:||to)?\s*([0-9]+(?:\.[0-9]+)?)",
r"([0-9]+(?:\.[0-9]+)?)\s*(?::|)\s*1\s*(?:传动比|减速比)?",
],
"module_mm": [
r"(?:齿轮)?模数\s*(?:为|是|设为|=|:||to)?\s*([0-9]+(?:\.[0-9]+)?)",
r"module(?:_mm)?\s*(?:=|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
],
"planet_count": [
r"(?:行星轮数量|行星轮数|行星轮)\s*(?:为|是|设为|=|:||to)?\s*([0-9]+)",
r"([0-9]+)\s*(?:个|颗|只)?\s*行星轮",
r"planet_count\s*(?:=|:|to)?\s*([0-9]+)",
],
"helix_angle_deg": [
r"(?:螺旋角|helix_angle(?:_deg)?)\s*(?:为|是|设为|=|:||to)?\s*([0-9]+(?:\.[0-9]+)?)",
],
"pressure_angle_deg": [
r"(?:压力角|pressure_angle(?:_deg)?)\s*(?:为|是|设为|=|:||to)?\s*([0-9]+(?:\.[0-9]+)?)",
],
"face_width_mm": [
r"(?:齿宽|face_width(?:_mm)?)\s*(?:为|是|设为|=|:||to)?\s*([0-9]+(?:\.[0-9]+)?)",
],
"backlash_mm": [
r"(?:齿侧间隙|侧隙|backlash(?:_mm)?)\s*(?:为|是|设为|=|:||to)?\s*([0-9]+(?:\.[0-9]+)?)",
],
"ring_rim_thickness_mm": [
r"(?:齿圈轮缘厚度|轮缘厚度|ring_rim_thickness(?:_mm)?)\s*(?:为|是|设为|=|:||to)?\s*([0-9]+(?:\.[0-9]+)?)",
],
"max_outer_diameter_mm": [
r"(?:最大外径|外径)\s*(?:小于|不超过|限制为|为|是|=|:||to|≤|<=)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:mm|毫米)?",
r"max_outer_diameter(?:_mm)?\s*(?:=|:|to|<=)?\s*([0-9]+(?:\.[0-9]+)?)",
],
}
for name, patterns in numeric_patterns.items():
value = _first_number(text, patterns, integer=name == "planet_count")
if value is not None:
overrides[name] = value
return overrides
def _normalized_requirement_overrides(payload: dict[str, object]) -> dict[str, object]:
overrides = _request_requirement_overrides(str(payload.get("request") or ""))
explicit = payload.get("requirementOverrides")
if explicit is not None and not isinstance(explicit, dict):
raise ValueError("requirementOverrides_must_be_an_object")
if isinstance(explicit, dict):
unknown = sorted(set(explicit) - SUPPORTED_REQUIREMENT_OVERRIDES)
if unknown:
raise ValueError(f"unsupported_requirement_overrides: {unknown}")
overrides.update({key: value for key, value in explicit.items() if value is not None})
return overrides
def _apply_requirement_overrides(
requirement: dict[str, object],
overrides: dict[str, object],
) -> dict[str, object]:
result = json.loads(json.dumps(requirement))
topology = str(overrides.get("topology_family") or result.get("topology_family") or "simple_2k_h")
result["topology_family"] = topology
direct_fields = {
"target_ratio",
"tooth_form",
"helix_angle_deg",
"pressure_angle_deg",
"face_width_mm",
"backlash_mm",
"ring_rim_thickness_mm",
}
for name in direct_fields:
if name in overrides:
result[name] = overrides[name]
if "module_mm" in overrides:
result["module_candidates_mm"] = [float(overrides["module_mm"])]
if "planet_count" in overrides:
result["planet_count_candidates"] = [int(overrides["planet_count"])]
if "max_outer_diameter_mm" in overrides:
constraints = dict(result.get("constraints") or {})
constraints["max_outer_diameter_mm"] = float(overrides["max_outer_diameter_mm"])
result["constraints"] = constraints
if result.get("tooth_form") == "helical" and not result.get("helix_angle_deg"):
result["helix_angle_deg"] = 15.0
if result.get("tooth_form") == "spur":
result.pop("helix_angle_deg", None)
stages = result.get("stages")
if isinstance(stages, list):
for stage_value in stages:
if not isinstance(stage_value, dict):
continue
if "module_mm" in overrides:
stage_value["module_candidates_mm"] = [float(overrides["module_mm"])]
if "planet_count" in overrides:
stage_value["planet_count_candidates"] = [int(overrides["planet_count"])]
for name in {
"tooth_form",
"helix_angle_deg",
"pressure_angle_deg",
"face_width_mm",
"backlash_mm",
"ring_rim_thickness_mm",
}:
if name in overrides:
stage_value[name] = overrides[name]
if result.get("tooth_form") == "spur":
stage_value.pop("helix_angle_deg", None)
return result
def _materialize_reducer_requirement(
task_id: str,
payload: dict[str, object],
) -> tuple[Path, dict[str, object]]:
overrides = _normalized_requirement_overrides(payload)
requested_path = payload.get("requirementPath")
if requested_path:
base_path = Path(str(requested_path))
if not base_path.is_absolute():
base_path = BACKEND_ROOT / base_path
else:
topology = str(overrides.get("topology_family") or "simple_2k_h")
if topology not in REDUCER_REQUIREMENT_TEMPLATES:
raise ValueError(f"unsupported_topology_family: {topology}")
base_path = REDUCER_REQUIREMENT_TEMPLATES[topology]
requirement = read_json(base_path)
if overrides:
requirement = _apply_requirement_overrides(requirement, overrides)
topology = str(requirement.get("topology_family") or "")
if topology == "ferguson_wolfrom":
requested_ratio = float(requirement.get("target_ratio") or 0)
if abs(requested_ratio - 531.25) > 1e-9:
raise ValueError(
"ferguson_wolfrom currently supports only the validated ratio 531.25"
)
target = task_dir(task_id) / "requirements" / "active_reducer_requirement.json"
write_json(target, requirement)
return target, overrides
def _joint_reducer_requirement_path(joint_requirement_path: Path) -> Path:
payload = read_json(joint_requirement_path)
reducer_path = Path(str(payload.get("reducer_requirement") or ""))
if not str(reducer_path):
raise ValueError("joint_requirement_has_no_reducer_requirement")
if not reducer_path.is_absolute():
reducer_path = joint_requirement_path.parent / reducer_path
return reducer_path.resolve()
def _materialize_joint_requirement(
task_id: str,
joint_requirement_path: Path,
overrides: dict[str, object],
) -> tuple[Path, Path]:
joint_requirement = read_json(joint_requirement_path)
reducer_path = _joint_reducer_requirement_path(joint_requirement_path)
reducer_requirement = _apply_requirement_overrides(read_json(reducer_path), overrides)
reducer_target = task_dir(task_id) / "requirements" / "active_reducer_requirement.json"
joint_target = task_dir(task_id) / "requirements" / "active_joint_requirement.json"
write_json(reducer_target, reducer_requirement)
joint_requirement["reducer_requirement"] = str(reducer_target.resolve())
if "platform_profile" in joint_requirement:
profile_path = Path(str(joint_requirement["platform_profile"]))
if not profile_path.is_absolute():
profile_path = (joint_requirement_path.parent / profile_path).resolve()
joint_requirement["platform_profile"] = str(profile_path)
write_json(joint_target, joint_requirement)
return joint_target, reducer_target
def _project_summary(run_name: str) -> dict[str, object]:
run = RUN_ROOT / run_name
formula = read_json(run / "reducer" / "formula" / "formula_instance.json")
kinematics = read_json(run / "reducer" / "kinematics" / "kinematic_solution.json")
reducer_validation = read_json(run / "reducer" / "validation" / "validation_report.json")
joint_validation_path = run / "joint_module_validation_report.json"
joint_validation = read_json(joint_validation_path) if joint_validation_path.exists() else {"summary": {"total": 0, "passed": 0, "failed": 0}, "run_id": ""}
return {
"source": f"output/runs/{run_name}",
"runId": joint_validation.get("run_id") or formula.get("run_id"),
"ratio": formula["derived"]["ratio"],
"parameters": formula["parameters"],
"derived": formula["derived"],
"speeds": kinematics["speeds"],
"reducerValidation": reducer_validation["summary"],
"jointValidation": joint_validation["summary"],
}
def _design_catalog() -> list[dict[str, object]]:
title_by_topology = {
"simple_2k_h": "单级 2K-H 关节模组",
"simple_2k_h_cascade": "两级 2K-H 串联关节",
"ferguson_wolfrom": "高传动比复合行星减速器",
}
designs: list[dict[str, object]] = []
for run_dir in sorted(RUN_ROOT.iterdir() if RUN_ROOT.exists() else []):
if not run_dir.is_dir():
continue
urdf_dir = run_dir / "exports" / "urdf"
urdf_path = urdf_dir / "joint_module.urdf"
kind = "joint_module"
if not urdf_path.exists():
urdf_path = urdf_dir / "reducer.urdf"
kind = "reducer"
if not urdf_path.exists():
continue
formula_candidates = [
run_dir / "joint" / "reducer" / "formula" / "formula_instance.json",
run_dir / "reducer" / "formula" / "formula_instance.json",
run_dir / "formula" / "formula_instance.json",
]
formula_path = next((path for path in formula_candidates if path.exists()), None)
formula = read_json(formula_path) if formula_path else {}
topology = str(formula.get("topology_family") or "planetary_reducer")
derived = formula.get("derived") if isinstance(formula.get("derived"), dict) else {}
parameters = formula.get("parameters") if isinstance(formula.get("parameters"), dict) else {}
motion_path = urdf_dir / ("joint_motion_demo.json" if kind == "joint_module" else "reducer_motion_demo.json")
motion = read_json(motion_path) if motion_path.exists() else {}
ratio = motion.get("ratio") or derived.get("ratio")
task_manifest = read_task_manifest(run_dir.name)
request = str((task_manifest or {}).get("request") or "")
joint_validation_path = run_dir / "joint_module_validation_report.json"
reducer_validation_candidates = [
run_dir / "reducer" / "validation" / "validation_report.json",
run_dir / "joint" / "reducer" / "validation" / "validation_report.json",
run_dir / "validation" / "validation_report.json",
]
reducer_validation_path = next(
(path for path in reducer_validation_candidates if path.exists()),
None,
)
validation_path = (
joint_validation_path
if kind == "joint_module" and joint_validation_path.exists()
else reducer_validation_path
)
validation = read_json(validation_path) if validation_path else {}
validation_summary = (
validation.get("summary")
if isinstance(validation.get("summary"), dict)
else {}
)
failed_checks = [
{
"code": str(check.get("code") or "validation_failed"),
"message": str(check.get("message") or "验证未通过"),
}
for check in validation.get("checks", [])
if isinstance(check, dict) and check.get("passed") is False
]
physically_verified = bool(validation) and validation.get("overall_passed") is True
source = "api_task" if task_manifest else "project_preset_run"
is_test_artifact = bool(task_manifest) and bool(
re.search(r"(?:集成验证|测试|\btest\b)", request, flags=re.IGNORECASE)
)
root = ET.fromstring(urdf_path.read_text(encoding="utf-8"))
link_names = [str(node.get("name") or "") for node in root.findall("link")]
continuous_joints = [
str(node.get("name") or "")
for node in root.findall("joint")
if node.get("type") in {"continuous", "revolute"}
]
title = title_by_topology.get(topology, "行星传动方案")
if task_manifest and request:
title = request
designs.append({
"id": run_dir.name,
"runId": run_dir.name,
"kind": kind,
"topology": topology,
"title": title,
"request": request,
"ratio": ratio,
"parameters": parameters,
"derived": derived,
"urdfUrl": f"/api/design-assets/{run_dir.name}/{urdf_path.name}",
"assetBase": f"/api/design-assets/{run_dir.name}",
"motion": motion,
"linkCount": len(link_names),
"movingJointCount": len(continuous_joints),
"motorIncluded": any(
link_name == "motor_link" or link_name.startswith("motor_")
for link_name in link_names
),
# A successful generation task only means that files were written.
# Catalog eligibility is based on the relevant physical validation
# report, never on the presence of a URDF or on task status alone.
"status": "verified" if physically_verified else "invalid",
"eligible": physically_verified,
"source": source,
"sourceLabel": "CAD API 测试任务" if is_test_artifact else (
"CAD API 生成" if task_manifest else "项目预设生成"
),
"catalogClass": "test_artifact" if is_test_artifact else "design",
"validationScope": "关节模组" if kind == "joint_module" else "减速器",
"validationSummary": validation_summary,
"failedChecks": failed_checks,
"validationReport": str(validation_path.relative_to(run_dir)) if validation_path else "",
})
preferred = {
"frontend_split_motor_7nm": 0,
"frontend_split_motor_7nm_cascade": 1,
"ferguson_wolfrom_ratio_531p25_spur": 2,
}
return sorted(designs, key=lambda item: (preferred.get(str(item["id"]), 10), str(item["id"])))
def _artifact_record(task_id: str, name: str, path: Path, role: str, kind: str) -> dict[str, object]:
return {
"name": name,
"role": role,
"kind": kind,
"path": str(path.resolve()),
"url": artifact_url(task_id, path),
"size": path.stat().st_size if path.exists() else 0,
}
def _summarize_reducer_task(task_id: str, run_dir: Path, request: str) -> dict[str, object]:
formula = read_json(run_dir / "formula" / "formula_instance.json")
validation = read_json(run_dir / "validation" / "validation_report.json")
manifest = read_json(run_dir / "cad" / "assembly_manifest.json")
artifacts = [
_artifact_record(task_id, "reducer.step", run_dir / "cad" / "reducer.step", "primary", "step"),
_artifact_record(task_id, "assembly_manifest.json", run_dir / "cad" / "assembly_manifest.json", "manifest", "json"),
_artifact_record(task_id, "formula_instance.json", run_dir / "formula" / "formula_instance.json", "formula", "json"),
_artifact_record(task_id, "validation_report.json", run_dir / "validation" / "validation_report.json", "validation", "json"),
]
return {
"status": "success" if validation.get("overall_passed") else "error",
"kind": "reducer",
"request": request,
"summary": f"{formula['topology_family']} reducer ratio={formula['derived']['ratio']}",
"ratio": formula["derived"]["ratio"],
"parameters": formula["parameters"],
"derived": formula["derived"],
"validation": validation,
"assemblyManifest": manifest,
"artifacts": artifacts,
"route": {
"selected_backend": "simplecadapi",
"effective_execution_engine": "simplecadapi",
"runner_skill": "cad-router",
"project": "planetary-reducer-system",
"fallback_order": ["build123d"],
"workflow_profiles": ["requirement_refinement", "visual_repair"],
},
"source": {
"path": "formula/formula_instance.json",
"format": "json",
"backend": "simplecadapi",
"requirement_path": "input_snapshot",
},
"validation": [validation],
}
def _summarize_joint_task(task_id: str, run_dir: Path, request: str) -> dict[str, object]:
joint_dir = run_dir / "joint" if (run_dir / "joint").exists() else run_dir
reducer_dir = joint_dir / "reducer"
formula = read_json(reducer_dir / "formula" / "formula_instance.json")
reducer_validation = read_json(reducer_dir / "validation" / "validation_report.json")
joint_validation = read_json(joint_dir / "joint_module_validation_report.json")
joint_manifest = read_json(joint_dir / "joint_module_manifest.json")
artifacts = [
_artifact_record(task_id, "joint_module.step", joint_dir / "joint_module.step", "primary", "step"),
_artifact_record(task_id, "joint_module_manifest.json", joint_dir / "joint_module_manifest.json", "manifest", "json"),
_artifact_record(task_id, "joint_module_validation_report.json", joint_dir / "joint_module_validation_report.json", "validation", "json"),
_artifact_record(task_id, "formula_instance.json", reducer_dir / "formula" / "formula_instance.json", "formula", "json"),
]
exports_dir = run_dir / "exports" / "urdf"
if (exports_dir / "joint_module.urdf").exists():
artifacts.append(_artifact_record(task_id, "joint_module.urdf", exports_dir / "joint_module.urdf", "viewer", "urdf"))
return {
"status": "success" if joint_validation.get("overall_passed") else "error",
"kind": "joint_module",
"request": request,
"summary": f"planetary joint module ratio={formula['derived']['ratio']}",
"ratio": formula["derived"]["ratio"],
"parameters": formula["parameters"],
"derived": formula["derived"],
"reducerValidation": reducer_validation,
"jointValidation": joint_validation,
"jointManifest": joint_manifest,
"artifacts": artifacts,
"route": {
"selected_backend": "simplecadapi",
"effective_execution_engine": "simplecadapi",
"runner_skill": "cad-router",
"project": "planetary-reducer-system",
"fallback_order": ["build123d"],
"workflow_profiles": ["requirement_refinement", "visual_repair"],
},
"source": {
"path": "joint/reducer/formula/formula_instance.json" if (run_dir / "joint").exists() else "reducer/formula/formula_instance.json",
"format": "json",
"backend": "simplecadapi",
"requirement_path": "generated_joint_requirement.json",
},
"validation": [reducer_validation, joint_validation],
}
def _write_zip(source_dir: Path, target: Path) -> Path:
target.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(source_dir.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(source_dir))
return target
def _find_requirement_snapshot(task_id: str) -> Path:
snapshot_dir = task_dir(task_id) / "input_snapshot"
candidates = sorted(snapshot_dir.glob("*.json"))
if not candidates:
candidates = sorted((task_dir(task_id) / "joint").glob("**/input_snapshot/*.json"))
if not candidates:
raise ValueError("task_has_no_requirement_snapshot")
return candidates[0]
def _parameter_patch(parameter: str, value: object) -> dict[str, object]:
name = str(parameter or "").strip()
if not name:
raise ValueError("parameter_is_required")
if name in {"module", "module_mm"}:
return {"module_candidates_mm": [float(value)]}
if name in {"planet_count", "planetCount"}:
return {"planet_count_candidates": [int(value)]}
if name in {"target_ratio", "ratio"}:
return {"target_ratio": float(value)}
if name in {"tooth_form", "toothForm"}:
normalized = str(value).strip().lower()
if normalized not in {"spur", "helical"}:
raise ValueError("tooth_form_must_be_spur_or_helical")
return {"tooth_form": normalized}
if name in {"helix_angle_deg", "pressure_angle_deg", "face_width_mm", "backlash_mm", "ring_rim_thickness_mm"}:
return {name: float(value)}
raise ValueError(f"parameter_not_editable: {name}")
def edit_task_parameter(task_id: str, parameter: str, value: object) -> dict[str, object]:
task = read_task_manifest(task_id)
if not task:
raise ValueError("task_not_found")
kind = str(task.get("kind") or "")
if kind not in {"reducer", "joint_module"}:
raise ValueError("parameter_edit_requires_generated_reducer_or_joint_task")
run_dir = task_dir(task_id)
active_requirement = task.get("activeReducerRequirementPath")
requirement_path = Path(str(active_requirement)) if active_requirement else (
run_dir / "generated_reducer_requirement.json"
if kind == "joint_module" and (run_dir / "generated_reducer_requirement.json").exists()
else _find_requirement_snapshot(task_id)
)
requirement = read_json(requirement_path)
requirement.update(_parameter_patch(parameter, value))
revision = int(task.get("revision") or 0) + 1
edited_requirement = run_dir / "revisions" / f"reducer_requirement_{revision}.json"
edited_requirement.parent.mkdir(parents=True, exist_ok=True)
write_json(edited_requirement, requirement)
request = f"{task.get('request', 'CAD task')} · edit {parameter}={value}"
if kind == "joint_module":
joint_requirement_path = run_dir / "generated_joint_requirement.json"
if not joint_requirement_path.exists():
raise ValueError("joint_task_has_no_generated_joint_requirement")
joint_requirement = read_json(joint_requirement_path)
joint_requirement["reducer_requirement"] = str(edited_requirement.resolve())
edited_joint_requirement = run_dir / "revisions" / f"joint_requirement_{revision}.json"
write_json(edited_joint_requirement, joint_requirement)
payload = {
"request": request,
"requirementPath": str(edited_joint_requirement),
"mode": "requirement",
"kind": "joint_module",
}
else:
payload = {
"request": request,
"requirementPath": str(edited_requirement),
"mode": "requirement",
"kind": "reducer",
}
update_task(task_id, {
"status": "running",
"revision": revision,
"parentTaskId": task_id,
"editedParameter": {"name": parameter, "value": value},
})
result = (
generate_joint_task(task_id, payload)
if kind == "joint_module"
else generate_reducer_task(task_id, payload)
)
return update_task(task_id, {
**result,
"editedParameter": {"name": parameter, "value": value},
"revision": revision,
"activeReducerRequirementPath": str(edited_requirement.resolve()),
})
def reconstruct_uploaded_task(task_id: str, request: str = "重建上传的 STEP") -> dict[str, object]:
manifest = read_task_manifest(task_id)
if not manifest:
raise ValueError("task_not_found")
uploads = [item for item in manifest.get("uploads", []) if item.get("kind") == "step"]
if not uploads:
raise ValueError("step_upload_required")
attachment = uploads[-1]
source = task_dir(task_id) / str(attachment["path"])
if not source.exists():
raise ValueError("uploaded_step_not_found")
if not CAD_ROUTER_RECONSTRUCTOR.exists():
raise ValueError(f"cad_router_reconstructor_not_found: {CAD_ROUTER_RECONSTRUCTOR}")
teacher_root = RUN_ROOT / "_reconstruction_sources" / safe_task_id(task_id)
teacher_root.mkdir(parents=True, exist_ok=True)
teacher = teacher_root / source.name
shutil.copy2(source, teacher)
revision = int(manifest.get("reconstructionRevision") or 0) + 1
reconstruction_dir = task_dir(task_id) / "revisions" / f"reconstruction_{revision}"
completed = subprocess.run(
[
sys.executable,
str(CAD_ROUTER_RECONSTRUCTOR),
"--source",
str(teacher),
"--task-dir",
str(reconstruction_dir),
"--request",
request,
],
capture_output=True,
text=True,
timeout=900,
check=False,
)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(f"cad_router_reconstruction_failed: {detail[-3000:]}")
lines = [line for line in completed.stdout.splitlines() if line.strip()]
result = json.loads(lines[-1]) if lines else {}
artifact_paths = []
for path in sorted(reconstruction_dir.rglob("*")):
if path.is_file() and path.name not in {"cad-task.json"}:
artifact_paths.append(_artifact_record(task_id, path.name, path, "reconstruction", path.suffix.lstrip(".") or "file"))
return update_task(task_id, {
"status": "success",
"kind": "step_reconstruction",
"request": request,
"route": {
"selected_backend": result.get("selected_backend", "build123d"),
"effective_execution_engine": "surfaceir",
"runner_skill": "cad-router",
"project": "text-to-cad",
},
"source": {
"path": str(attachment["path"]),
"format": "step",
"backend": "uploaded",
"source_editability": "reconstruction_reference",
},
"reconstruction": result,
"reconstructionRevision": revision,
"artifacts": artifact_paths,
})
def export_task_robot(task_id: str, format_name: str) -> dict[str, object]:
manifest = read_task_manifest(task_id)
if not manifest:
raise ValueError("task_not_found")
if format_name not in {"urdf", "mjcf"}:
raise ValueError("export_format_must_be_urdf_or_mjcf")
run_dir = task_dir(task_id)
urdf_output = run_dir / "exports" / "urdf"
if manifest.get("kind") == "reducer":
export_manifest = export_reducer_urdf(
assembly_manifest_path=run_dir / "cad" / "assembly_manifest.json",
reducer_instance_path=run_dir / "formula" / "formula_instance.json",
out_dir=urdf_output,
)
elif manifest.get("kind") == "joint_module":
export_manifest = export_urdf(
joint_manifest_path=run_dir / "joint" / "joint_module_manifest.json" if (run_dir / "joint").exists() else run_dir / "joint_module_manifest.json",
reducer_instance_path=run_dir / "joint" / "reducer" / "formula" / "formula_instance.json" if (run_dir / "joint").exists() else run_dir / "reducer" / "formula" / "formula_instance.json",
out_dir=urdf_output,
)
else:
raise ValueError("robot_export_requires_generated_task")
if format_name == "mjcf":
try:
import mujoco # type: ignore
import pymeshlab # type: ignore
except ImportError as exc:
raise ValueError("mjcf_export_requires_mujoco_and_pymeshlab") from exc
output = run_dir / "exports" / "mjcf"
mesh_dir = output / "meshes"
mesh_dir.mkdir(parents=True, exist_ok=True)
for source in sorted((urdf_output / "meshes").glob("*.stl")):
mesh_set = pymeshlab.MeshSet()
mesh_set.load_new_mesh(str(source))
mesh_set.save_current_mesh(str(mesh_dir / source.name), binary=True)
urdf_path = (
urdf_output / "joint_module.urdf"
if (urdf_output / "joint_module.urdf").exists()
else urdf_output / "reducer.urdf"
)
if not urdf_path.exists():
raise ValueError("urdf_intermediate_not_found")
root = ET.fromstring(urdf_path.read_text(encoding="utf-8"))
for mesh in root.findall(".//mesh"):
filename = str(mesh.get("filename") or "")
mesh.set("filename", str((mesh_dir / Path(filename).name).resolve()))
intermediate = output / "mujoco-input.urdf"
intermediate.write_text(
ET.tostring(root, encoding="unicode", xml_declaration=True),
encoding="utf-8",
)
description = output / "robot.mjcf.xml"
model = mujoco.MjModel.from_xml_path(str(intermediate))
mujoco.mj_saveLastXML(str(description), model)
intermediate.unlink(missing_ok=True)
validation = {
"engine": f"mujoco {mujoco.__version__}",
"parsed": True,
"body_count": int(model.nbody),
"joint_count": int(model.njnt),
"mesh_count": int(model.nmesh),
}
write_json(output / "mjcf_validation.json", validation)
package = _write_zip(output, run_dir / "exports" / "mjcf.zip")
export_manifest = {
"format": "mjcf",
"description_path": str(description.resolve()),
"validation": validation,
}
else:
output = urdf_output
package = _write_zip(output, run_dir / "exports" / "urdf.zip")
artifacts = list(manifest.get("artifacts", []))
for path in [
package,
output / "joint_module.urdf",
output / "reducer.urdf",
output / "robot.mjcf.xml",
output / "urdf_export_manifest.json",
output / "export_manifest.json",
output / "mjcf_validation.json",
]:
if path.exists():
artifacts.append(_artifact_record(task_id, path.name, path, "robot_export", path.suffix.lstrip(".") or "file"))
return update_task(task_id, {
"status": "success",
"artifacts": artifacts,
"robotExport": {
"format": format_name,
"packageUrl": artifact_url(task_id, package),
"manifest": export_manifest,
},
})
def generate_reducer_task(task_id: str, payload: dict[str, object]) -> dict[str, object]:
request = str(payload.get("request") or "Generate planetary reducer")
requirement_path, overrides = _materialize_reducer_requirement(task_id, payload)
run_dir = task_dir(task_id)
paths = _prepare_structured_run_dir(run_dir)
requirement = load_requirement(requirement_path)
template = load_default_template(requirement.topology_family)
topology_errors = validate_topology(template, requirement)
if topology_errors:
raise ValueError(f"topology_invalid: {topology_errors}")
instance, report = solve_parameters(requirement=requirement, template=template)
solution = solve_instance(instance)
samples = motion_samples(instance, solution)
placement_plan = solve_reducer_placements(instance)
shutil.copy2(requirement_path, paths["input_snapshot"] / requirement_path.name)
write_model_json(paths["formula"] / "formula_instance.json", instance)
write_model_json(paths["formula"] / "parameter_search_report.json", report)
write_model_json(paths["kinematics"] / "kinematic_solution.json", solution)
write_model_json(paths["kinematics"] / "motion_samples.json", samples)
write_model_json(paths["placement"] / "placement_plan.json", placement_plan)
if instance.industrial_parameters is not None:
write_model_json(paths["formula"] / "industrial_parameters.json", instance.industrial_parameters)
if instance.assembly_topology is not None:
write_model_json(paths["formula"] / "assembly_topology.json", instance.assembly_topology)
generate_simplecad(instance, paths["cad"])
if (paths["cad"] / "placement_plan.json").exists():
shutil.copy2(paths["cad"] / "placement_plan.json", paths["placement"] / "placement_plan.json")
validate_from_files(
instance=instance,
model_path=paths["cad"] / "reducer.model.json",
manifest_path=paths["cad"] / "assembly_manifest.json",
build_meta_path=paths["cad"] / "build_meta.json",
snapshot_out=paths["validation"] / "assembly_snapshot.json",
report_out=paths["validation"] / "validation_report.json",
)
result = _summarize_reducer_task(task_id, run_dir, request)
return update_task(task_id, {
**result,
"requirementOverrides": overrides,
"activeReducerRequirementPath": str(requirement_path.resolve()),
})
def generate_joint_task(task_id: str, payload: dict[str, object]) -> dict[str, object]:
request = str(payload.get("request") or "Generate planetary joint module")
run_dir = task_dir(task_id)
joint_dir = run_dir / "joint"
mode = str(payload.get("mode") or "auto")
overrides = _normalized_requirement_overrides(payload)
topology = str(overrides.get("topology_family") or "simple_2k_h")
if topology not in JOINT_REQUIREMENT_TEMPLATES:
raise ValueError(f"joint_module currently supports only {sorted(JOINT_REQUIREMENT_TEMPLATES)}")
if mode == "requirement":
requirement_path = Path(str(payload.get("requirementPath") or JOINT_REQUIREMENT_TEMPLATES[topology]))
if not requirement_path.is_absolute():
requirement_path = BACKEND_ROOT / requirement_path
active_joint_requirement, active_reducer_requirement = _materialize_joint_requirement(
task_id,
requirement_path,
overrides,
)
run_joint_module(requirement_path=active_joint_requirement, out_dir=joint_dir)
else:
platform_profile = Path(str(payload.get("platformProfile") or DEFAULT_PLATFORM_PROFILE))
if not platform_profile.is_absolute():
platform_profile = BACKEND_ROOT / platform_profile
legacy_motor_step = payload.get("motorStep")
legacy_housing_source = payload.get("housingPython")
run_auto_joint_design(
out_dir=run_dir,
platform_profile=platform_profile,
motor_step=Path(str(legacy_motor_step)) if legacy_motor_step else None,
housing_source=Path(str(legacy_housing_source)) if legacy_housing_source else None,
tooth_form=str(payload.get("toothForm") or "spur"),
radial_clearance_mm=float(payload.get("radialClearanceMm") or 14.0),
reducer_axial_allowance_mm=float(payload.get("reducerAxialAllowanceMm") or 42.0),
reducer_radial_clearance_mm=float(payload.get("reducerRadialClearanceMm") or 8.0),
reducer_requirement_overrides=overrides,
)
reducer_dir = joint_dir / "reducer"
export_urdf(
joint_manifest_path=joint_dir / "joint_module_manifest.json",
reducer_instance_path=reducer_dir / "formula" / "formula_instance.json",
out_dir=run_dir / "exports" / "urdf",
)
result = _summarize_joint_task(task_id, run_dir, request)
active_reducer_requirement = (
run_dir / "generated_reducer_requirement.json"
if mode != "requirement"
else active_reducer_requirement
)
return update_task(task_id, {
**result,
"requirementOverrides": overrides,
"activeReducerRequirementPath": str(active_reducer_requirement.resolve()),
})
class ToolchainHandler(BaseHTTPRequestHandler):
run_name = DEFAULT_RUN_NAME
def do_OPTIONS(self) -> None: # noqa: N802
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
try:
if parsed.path == "/api/project-summary":
_send_json(self, 200, _project_summary(self.run_name))
return
if parsed.path == "/api/tasks":
_send_json(self, 200, {"tasks": list_tasks()})
return
if parsed.path == "/api/designs":
_send_json(self, 200, {"designs": _design_catalog()})
return
if parsed.path.startswith("/api/design-assets/"):
parts = parsed.path.split("/")
if len(parts) < 5:
_send_json(self, 400, {"error": "invalid_design_asset_path"})
return
run_name = safe_task_id(parts[3])
relative_path = Path(unquote("/".join(parts[4:])))
if relative_path.is_absolute() or ".." in relative_path.parts:
_send_json(self, 400, {"error": "invalid_design_asset_path"})
return
_send_file(self, RUN_ROOT / run_name / "exports" / "urdf" / relative_path)
return
if parsed.path.startswith("/api/tasks/"):
parts = parsed.path.split("/")
if len(parts) >= 4:
task_id = safe_task_id(parts[3])
if len(parts) == 4:
manifest = read_task_manifest(task_id)
_send_json(self, 200 if manifest else 404, manifest or {"error": "task_not_found"})
return
if len(parts) == 5 and parts[4] == "parameters":
manifest = read_task_manifest(task_id)
_send_json(self, 200 if manifest else 404, {
"taskId": task_id,
"parameters": (manifest or {}).get("parameters", {}),
})
return
if len(parts) >= 6 and parts[4] == "artifacts":
relative = "/".join(parts[5:])
_send_file(self, _safe_relative_file(task_id, relative))
return
if parsed.path.startswith("/project-assets/urdf/"):
relative_path = Path(unquote(parsed.path[len("/project-assets/urdf/"):]))
if relative_path.is_absolute() or ".." in relative_path.parts:
_send_json(self, 400, {"error": "invalid_asset_path"})
return
_send_file(self, RUN_ROOT / self.run_name / "exports" / "urdf" / relative_path)
return
_send_json(self, 404, {"error": "not_found"})
except Exception as exc: # noqa: BLE001
_send_json(self, 500, {"error": str(exc)})
def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path)
try:
if parsed.path == "/api/tasks":
payload = _read_json(self)
task = ensure_task(str(payload.get("taskId") or "") or None, request=str(payload.get("request") or "CAD Agent Studio task"))
_send_json(self, 200, task)
return
if parsed.path == "/api/generate":
payload = _read_json(self)
kind = str(payload.get("kind") or "joint_module")
task = ensure_task(str(payload.get("taskId") or "") or None, request=str(payload.get("request") or "CAD generation"))
task_id = task["taskId"]
update_task(task_id, {
"status": "running",
"kind": kind,
"request": payload.get("request") or task["manifest"].get("request"),
"planner": payload.get("planner") if isinstance(payload.get("planner"), dict) else {},
})
try:
result = generate_reducer_task(task_id, payload) if kind == "reducer" else generate_joint_task(task_id, payload)
_send_json(self, 200, result)
except Exception as exc: # noqa: BLE001
error_manifest = update_task(task_id, {
"status": "error",
"error": {
"type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
},
})
_send_json(self, 500, error_manifest)
return
if parsed.path.startswith("/api/tasks/") and parsed.path.endswith("/reconstruct"):
task_id = safe_task_id(parsed.path.split("/")[3])
payload = _read_json(self)
result = reconstruct_uploaded_task(
task_id,
request=str(payload.get("request") or "重建上传的 STEP"),
)
_send_json(self, 200, result)
return
if parsed.path.startswith("/api/tasks/") and parsed.path.endswith("/parameters"):
task_id = safe_task_id(parsed.path.split("/")[3])
payload = _read_json(self)
result = edit_task_parameter(
task_id,
str(payload.get("parameter") or ""),
payload.get("value"),
)
_send_json(self, 200, result)
return
if parsed.path.startswith("/api/tasks/") and "/exports/" in parsed.path:
parts = parsed.path.split("/")
task_id = safe_task_id(parts[3])
format_name = str(parts[5] if len(parts) > 5 else "").lower()
result = export_task_robot(task_id, format_name)
_send_json(self, 200, result)
return
if parsed.path.startswith("/api/upload/"):
task_id = safe_task_id(parsed.path.rsplit("/", 1)[-1])
filename = unquote(self.headers.get("X-Filename") or "upload.bin")
mime = self.headers.get("Content-Type") or "application/octet-stream"
attachment = write_upload(task_id, filename, _read_body(self), mime)
_send_json(self, 200, attachment)
return
_send_json(self, 404, {"error": "not_found"})
except Exception as exc: # noqa: BLE001
_send_json(self, 500, {"error": str(exc)})
def create_toolchain_app(host: str, port: int, *, run_name: str = DEFAULT_RUN_NAME) -> ThreadingHTTPServer:
ToolchainHandler.run_name = run_name
return ThreadingHTTPServer((host, port), ToolchainHandler)