1056 lines
47 KiB
Python
1056 lines
47 KiB
Python
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)
|