接入ai
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
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 _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.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")})
|
||||
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)
|
||||
Reference in New Issue
Block a user