Initial commit: step2urdf tool with handtuned JSON import.

Includes FastAPI backend, vendored step2urdf frontend, and A7 handtuned arm JSON for URDF generation.
This commit is contained in:
sunxianghui
2026-08-26 15:32:47 +08:00
commit 93773f3887
128 changed files with 20119 additions and 0 deletions
View File
+46
View File
@@ -0,0 +1,46 @@
"""STEP → URDF conversion tool (Web MVP).
Pipeline:
STEP upload → assembly/part parse → profile link grouping
→ Zhipu joint proposal → human review → URDF (+ optional meshes)
"""
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT = Path(__file__).resolve().parents[1]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=str(ROOT / ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
zhipu_api_key: str = ""
zhipu_model: str = "glm-4-plus"
zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
host: str = "127.0.0.1"
port: int = 8787
data_dir: Path = ROOT / "data"
default_step: str = ""
@property
def uploads_dir(self) -> Path:
return self.data_dir / "uploads"
@property
def jobs_dir(self) -> Path:
return self.data_dir / "jobs"
settings = Settings()
settings.uploads_dir.mkdir(parents=True, exist_ok=True)
settings.jobs_dir.mkdir(parents=True, exist_ok=True)
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.models import ExportRequest, ProposeRequest
from app.services.mesh_export import geometry_backend_status
from app.services.pipeline import create_job_from_upload, export_package, load_parse, propose_draft
ROOT = Path(__file__).resolve().parents[1]
LEGACY_STATIC = ROOT / "app" / "static"
FRONTEND_DIST = ROOT / "frontend" / "dist"
app = FastAPI(title="step2urdf-tool", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://127.0.0.1:5678",
"http://localhost:5678",
"http://127.0.0.1:8787",
"http://localhost:8787",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
async def health():
return {
"ok": True,
"zhipu_configured": bool(settings.zhipu_api_key),
"zhipu_model": settings.zhipu_model,
"geometry": geometry_backend_status(),
"primary_ui": "step2urdf frontend (Vite :5678 in dev)",
}
@app.post("/api/parse")
async def parse_step(file: UploadFile = File(...)):
"""Optional server-side STEP text parse (legacy / debug). Geometry stays in the browser."""
suffix = Path(file.filename or "model.STEP").suffix or ".STEP"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp_path = Path(tmp.name)
shutil.copyfileobj(file.file, tmp)
try:
result = create_job_from_upload(tmp_path, file.filename or "model.STEP")
finally:
tmp_path.unlink(missing_ok=True)
return result
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
try:
return load_parse(job_id)
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
@app.post("/api/propose")
async def propose(req: ProposeRequest):
"""Zhipu-assisted link/joint proposal.
Preferred body (from step2urdf UI)::
{"part_names": [...], "profile": "a7", "robot_name": "A7", "extra_hint": "..."}
Legacy: ``job_id`` from ``/api/parse``.
"""
names = list(req.part_names or [])
if req.solid_names:
# Deduplicate while preserving order
seen: set[str] = set()
merged: list[str] = []
for n in names + list(req.solid_names):
if n and n not in seen:
seen.add(n)
merged.append(n)
names = merged
try:
draft = await propose_draft(
req.job_id,
profile=req.profile,
robot_name=req.robot_name,
extra_hint=req.extra_hint,
part_names=names or None,
solids_geom=list(req.solids or []),
)
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e: # noqa: BLE001
raise HTTPException(502, f"Zhipu/propose failed: {e}") from e
return draft
@app.post("/api/export")
async def export(req: ExportRequest):
"""Legacy server-side URDF write. Prefer Export URDF in the step2urdf UI."""
try:
meta = export_package(req.job_id, req.draft, include_meshes=req.include_meshes)
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
return meta
@app.get("/api/jobs/{job_id}/download/{robot_name}")
async def download_urdf(job_id: str, robot_name: str):
path = settings.jobs_dir / job_id / "export" / robot_name / f"{robot_name}.urdf"
if not path.exists():
raise HTTPException(404, "URDF not found; export first")
return FileResponse(path, filename=f"{robot_name}.urdf", media_type="application/xml")
@app.get("/")
async def root():
if FRONTEND_DIST.is_dir() and (FRONTEND_DIST / "index.html").exists():
return FileResponse(FRONTEND_DIST / "index.html")
return RedirectResponse(url="/legacy/")
# Built step2urdf UI (after `pnpm build` in frontend/)
if FRONTEND_DIST.is_dir():
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="frontend-assets")
# Previous MVP static UI kept under /legacy only
if LEGACY_STATIC.is_dir():
app.mount("/legacy", StaticFiles(directory=str(LEGACY_STATIC), html=True), name="legacy")
def main() -> None:
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
reload=True,
)
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class PartNode(BaseModel):
id: str
name: str
parent_id: str | None = None
children: list[str] = Field(default_factory=list)
class AxisCandidate(BaseModel):
id: str
origin: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
direction: list[float] = Field(default_factory=lambda: [0.0, 0.0, 1.0])
kind: str = "unknown"
score: float = 0.0
note: str = ""
class LinkDef(BaseModel):
name: str
part_names: list[str] = Field(default_factory=list)
mesh: str | None = None
class JointDef(BaseModel):
name: str
joint_type: Literal["revolute", "prismatic", "fixed", "continuous"] = "revolute"
parent: str
child: str
origin_xyz: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
origin_rpy: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
axis: list[float] = Field(default_factory=lambda: [0.0, 0.0, 1.0])
lower: float = -3.14
upper: float = 3.14
effort: float = 100.0
velocity: float = 1.0
rationale: str = ""
class RobotDraft(BaseModel):
name: str = "robot"
profile: str = "generic"
links: list[LinkDef] = Field(default_factory=list)
joints: list[JointDef] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
raw_parts: list[str] = Field(default_factory=list)
axis_candidates: list[AxisCandidate] = Field(default_factory=list)
llm_raw: dict[str, Any] | None = None
class ParseResult(BaseModel):
job_id: str
filename: str
root_name: str
parts: list[PartNode]
product_names: list[str]
stats: dict[str, Any] = Field(default_factory=dict)
class ProposeRequest(BaseModel):
"""Zhipu propose: prefer client ``part_names``; optional legacy ``job_id``."""
job_id: str | None = None
profile: Literal["a7", "generic"] = "generic"
robot_name: str = "robot"
total_mass_kg: float | None = None
extra_hint: str = ""
part_names: list[str] = Field(default_factory=list)
solid_names: list[str] = Field(default_factory=list)
solids: list[dict[str, Any]] = Field(default_factory=list)
class ExportRequest(BaseModel):
job_id: str
draft: RobotDraft
include_meshes: bool = True
View File
+120
View File
@@ -0,0 +1,120 @@
"""A7 / ARM7 gold kinematics from lkls73_i1_arm_description/urdf/ARM7_urdf.urdf.
STEP Viewer / step2urdf UI stores linear joint origins in **millimetres**;
export applies ×0.001 → metres. Values below are therefore in mm so they match
the interactive UI and still export to the gold URDF metre numbers.
"""
from __future__ import annotations
from app.models import JointDef, LinkDef, RobotDraft
# UI / CAD units (mm). Gold URDF metres × 1000.
A7_LINK_ORDER = [
"base_link",
"A1_Link",
"A2_Link",
"A3_Link",
"A4_Link",
"A5_Link",
"A6_Link",
"A7_Link",
"A8_Link",
]
# (name, parent, child, type, xyz_mm, axis, lower, upper, effort, velocity)
A7_GOLD_JOINTS_MM: list[tuple] = [
("A1_joint", "base_link", "A1_Link", "revolute", [0.0, 0.0, 9.0], [0.0, 0.0, -1.0], -2.9, 1.0, 80.0, 10.47),
("A2_joint", "A1_Link", "A2_Link", "revolute", [0.0, 0.0, 59.5], [1.0, 0.0, 0.0], -0.15, 3.14, 80.0, 10.47),
("A3_joint", "A2_Link", "A3_Link", "revolute", [0.0, 0.0, 123.0], [0.0, 0.0, -1.0], -2.35, 2.35, 36.0, 9.1),
("A4_joint", "A3_Link", "A4_Link", "revolute", [0.0, -0.10871, 187.1], [1.0, 0.0, 0.0], 0.0, 2.2, 36.0, 9.1),
("A5_joint", "A4_Link", "A5_Link", "revolute", [0.0, -0.13906, 124.0], [0.0, 0.0, -1.0], -2.35, 2.35, 36.0, 9.1),
("A6_joint", "A5_Link", "A6_Link", "revolute", [0.0, -0.20755, 88.099], [1.0, 0.0, 0.0], -1.57, 1.57, 36.0, 9.1),
("A7_joint", "A6_Link", "A7_Link", "revolute", [0.60033, 0.45533, 65.501], [0.0, 1.0, 0.0], -1.57, 1.57, 36.0, 9.1),
("A8_joint", "A7_Link", "A8_Link", "fixed", [-0.041388, -1.1787, 44.999], [0.0, 0.0, 0.0], 0.0, 0.0, 0.0, 0.0),
]
def gold_joint_defs() -> list[JointDef]:
out: list[JointDef] = []
for name, parent, child, jtype, xyz, axis, lo, hi, eff, vel in A7_GOLD_JOINTS_MM:
out.append(
JointDef(
name=name,
joint_type=jtype, # type: ignore[arg-type]
parent=parent,
child=child,
origin_xyz=list(xyz),
origin_rpy=[0.0, 0.0, 0.0],
axis=list(axis),
lower=lo,
upper=hi,
effort=eff,
velocity=vel,
rationale="ARM7_urdf.urdf gold kinematics (origins in mm for step2urdf UI).",
)
)
return out
def empty_gold_links() -> list[LinkDef]:
return [LinkDef(name=n, part_names=[]) for n in A7_LINK_ORDER]
def enforce_a7_gold_kinematics(draft: RobotDraft) -> RobotDraft:
"""Keep part_names from draft when link names match; always replace joints with gold.
Also remaps common aliases (link1 → A1_Link, etc.) so LLM/profile seeds still work.
"""
alias = {
"link1": "A1_Link",
"link2": "A2_Link",
"link3": "A3_Link",
"link4": "A4_Link",
"link5": "A5_Link",
"link6": "A6_Link",
"link7": "A7_Link",
"link_1": "A1_Link",
"link_2": "A2_Link",
"link_3": "A3_Link",
"link_4": "A4_Link",
"link_5": "A5_Link",
"link_6": "A6_Link",
"link_7": "A7_Link",
"a1_link": "A1_Link",
"a2_link": "A2_Link",
"a3_link": "A3_Link",
"a4_link": "A4_Link",
"a5_link": "A5_Link",
"a6_link": "A6_Link",
"a7_link": "A7_Link",
"a8_link": "A8_Link",
}
buckets: dict[str, list[str]] = {n: [] for n in A7_LINK_ORDER}
for link in draft.links:
key = alias.get(link.name, alias.get(link.name.lower(), link.name))
if key not in buckets:
# Unknown link → fold into base
key = "base_link"
for pn in link.part_names:
if pn not in buckets[key]:
buckets[key].append(pn)
links = [LinkDef(name=n, part_names=buckets[n]) for n in A7_LINK_ORDER]
joints = gold_joint_defs()
notes = list(draft.notes) + [
"A7 关节轴/原点已强制替换为 ARM7 金标(原点单位 mm,导出时 ×0.001 → m)。",
"智谱仅用于零件归组;请勿依赖模型猜测的 axis/rpy。",
"轴规律: A1/A3/A5 → [0,0,-1]; A2/A4/A6 → [1,0,0]; A7 → [0,1,0]; A8 fixed。",
]
return RobotDraft(
name=draft.name or "ARM7_urdf",
profile="a7",
links=links,
joints=joints,
notes=notes,
raw_parts=draft.raw_parts,
axis_candidates=draft.axis_candidates,
llm_raw=draft.llm_raw,
)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
def geometry_backend_status() -> dict[str, Any]:
status = {"ocp": False, "cascadio": False, "available": False, "detail": []}
try:
import OCP # noqa: F401
status["ocp"] = True
status["detail"].append("OCP import OK")
except Exception as e: # noqa: BLE001
status["detail"].append(f"OCP unavailable: {e}")
try:
import cascadio # noqa: F401
status["cascadio"] = True
status["detail"].append("cascadio import OK")
except Exception as e: # noqa: BLE001
status["detail"].append(f"cascadio unavailable: {e}")
status["available"] = bool(status["ocp"] or status["cascadio"])
return status
def export_meshes_stub(step_path: Path, meshes_dir: Path, link_names: list[str]) -> dict[str, Any]:
"""MVP mesh export.
Full per-link tessellation needs OCP/pythonocc. Until then we record
intended mesh filenames and leave placeholders.
"""
meshes_dir.mkdir(parents=True, exist_ok=True)
status = geometry_backend_status()
created: list[str] = []
pending: list[str] = []
if status["cascadio"]:
try:
import cascadio
# Whole-assembly export as a starting point (not per-link).
out = meshes_dir / "assembly.glb"
# cascadio API: step_to_glb(in, out) in recent versions
if hasattr(cascadio, "step_to_glb"):
cascadio.step_to_glb(str(step_path), str(out))
created.append(str(out))
else:
pending.append("cascadio installed but step_to_glb API not found")
except Exception as e: # noqa: BLE001
pending.append(f"cascadio export failed: {e}")
for name in link_names:
target = meshes_dir / f"{name}.stl"
if not target.exists():
# Tiny valid-ish ASCII STL placeholder so packages are non-empty.
target.write_text(
"solid placeholder\n"
" facet normal 0 0 1\n"
" outer loop\n"
" vertex 0 0 0\n"
" vertex 1 0 0\n"
" vertex 0 1 0\n"
" endloop\n"
" endfacet\n"
"endsolid placeholder\n",
encoding="utf-8",
)
created.append(str(target))
pending.append(f"{name}.stl is a placeholder triangle (replace after OCCT tessellation)")
return {"backend": status, "created": created, "pending": pending}
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import json
import shutil
import uuid
from pathlib import Path
from typing import Any
from app.config import settings
from app.models import ParseResult, RobotDraft
from app.services.a7_gold import enforce_a7_gold_kinematics
from app.services.profiles import apply_profile
from app.services.step_text import parse_step_products
from app.services.urdf_builder import write_urdf_package
from app.services.zhipu import propose_joints_with_zhipu
from app.services.mesh_export import export_meshes_stub, geometry_backend_status
def _job_dir(job_id: str) -> Path:
return settings.jobs_dir / job_id
def create_job_from_upload(src: Path, filename: str) -> ParseResult:
job_id = uuid.uuid4().hex[:12]
jdir = _job_dir(job_id)
jdir.mkdir(parents=True, exist_ok=True)
dest = jdir / filename
shutil.copy2(src, dest)
parsed = parse_step_products(dest)
result = ParseResult(
job_id=job_id,
filename=filename,
root_name=parsed["root_name"],
parts=parsed["parts"],
product_names=parsed["product_names"],
stats={**parsed["stats"], "geometry": geometry_backend_status()},
)
(jdir / "parse.json").write_text(result.model_dump_json(indent=2), encoding="utf-8")
(jdir / "step_path.txt").write_text(str(dest), encoding="utf-8")
return result
def load_parse(job_id: str) -> ParseResult:
path = _job_dir(job_id) / "parse.json"
if not path.exists():
raise FileNotFoundError(f"job not found: {job_id}")
return ParseResult.model_validate_json(path.read_text(encoding="utf-8"))
def step_path_for_job(job_id: str) -> Path:
txt = _job_dir(job_id) / "step_path.txt"
return Path(txt.read_text(encoding="utf-8").strip())
async def propose_draft(
job_id: str | None,
*,
profile: str,
robot_name: str,
extra_hint: str = "",
part_names: list[str] | None = None,
solids_geom: list[dict[str, Any]] | None = None,
) -> RobotDraft:
"""A7 → ARM7 gold URDF (no LLM). generic → Zhipu full propose."""
stats: dict[str, Any] = {}
names: list[str] = []
if part_names:
names = [n.strip() for n in part_names if n and str(n).strip()]
stats = {"source": "client", "part_count": len(names)}
elif job_id:
parsed = load_parse(job_id)
names = list(parsed.product_names)
stats = dict(parsed.stats)
else:
raise ValueError("Provide part_names (preferred) or job_id")
if not names:
raise ValueError("No part names available for proposal")
seed = apply_profile(profile, names, robot_name=robot_name)
# A7: always return gold kinematics (skip LLM — avoids JSON truncation).
if profile == "a7":
draft = enforce_a7_gold_kinematics(seed)
draft.notes = list(draft.notes) + [
"已使用 ARM7_urdf.urdf 金标关节(未调用智谱)。前端会按模型最长轴摆放。"
]
else:
draft = await propose_joints_with_zhipu(
seed,
extra_hint=extra_hint,
stats=stats,
solids_geom=solids_geom,
)
if job_id:
out = _job_dir(job_id) / "draft.json"
out.write_text(draft.model_dump_json(indent=2), encoding="utf-8")
return draft
def export_package(job_id: str, draft: RobotDraft, include_meshes: bool = True) -> dict[str, Any]:
jdir = _job_dir(job_id)
out_dir = jdir / "export" / draft.name
if out_dir.exists():
shutil.rmtree(out_dir)
paths = write_urdf_package(draft, out_dir)
mesh_info: dict[str, Any] = {}
if include_meshes:
mesh_info = export_meshes_stub(
step_path_for_job(job_id),
Path(paths["meshes_dir"]),
[l.name for l in draft.links],
)
meta = {"files": paths, "meshes": mesh_info, "job_id": job_id}
(out_dir / "export_meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
return meta
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from app.models import LinkDef, RobotDraft
from app.services.a7_gold import A7_LINK_ORDER, enforce_a7_gold_kinematics, gold_joint_defs
def _norm(name: str) -> str:
return name.strip()
def group_links_a7(product_names: list[str], robot_name: str = "ARM7_urdf") -> RobotDraft:
"""ARM7 gold kinematics; part_names filled later (Solid_N clustered on client)."""
names = [_norm(n) for n in product_names if _norm(n)]
# Spread Solid_* across gold links by index so apply has a starting bind.
links = [LinkDef(name=n, part_names=[]) for n in A7_LINK_ORDER]
solids = [n for n in names if n.lower().startswith("solid")]
other = [n for n in names if not n.lower().startswith("solid")]
if solids:
n_link = len(links)
for i, sn in enumerate(solids):
links[min(i * n_link // max(len(solids), 1), n_link - 1)].part_names.append(sn)
if other:
links[0].part_names.extend(other)
draft = RobotDraft(
name=robot_name or "ARM7_urdf",
profile="a7",
links=links,
joints=gold_joint_defs(),
notes=[
"A7 profile: joints from ARM7_urdf.urdf gold standard.",
"Axes: A1/3/5=[0,0,-1], A2/4/6=[1,0,0], A7=[0,1,0], A8=fixed.",
],
raw_parts=names,
)
return enforce_a7_gold_kinematics(draft)
def seed_generic(product_names: list[str], robot_name: str = "robot") -> RobotDraft:
names = [_norm(n) for n in product_names if _norm(n)]
return RobotDraft(
name=robot_name or "robot",
profile="generic",
links=[LinkDef(name="base_link", part_names=[])],
joints=[],
notes=["Generic seed; joints come from LLM."],
raw_parts=names,
)
def apply_profile(profile: str, product_names: list[str], robot_name: str) -> RobotDraft:
if profile == "a7":
return group_links_a7(product_names, robot_name=robot_name)
return seed_generic(product_names, robot_name=robot_name)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import re
from collections import defaultdict
from pathlib import Path
from app.models import PartNode
_X2_RE = re.compile(r"\\X2\\([0-9A-Fa-f]+)\\X0\\")
_PRODUCT_RE = re.compile(
r"#(\d+)\s*=\s*PRODUCT\s*\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'",
re.IGNORECASE,
)
# PRODUCT_DEFINITION_FORMATION(... , #product, ...)
_PDF_RE = re.compile(
r"#(\d+)\s*=\s*PRODUCT_DEFINITION_FORMATION\s*\([^;]*?#(\d+)\s*\)\s*;",
re.IGNORECASE | re.DOTALL,
)
# PRODUCT_DEFINITION(..., #pdf, ...)
_PD_RE = re.compile(
r"#(\d+)\s*=\s*PRODUCT_DEFINITION\s*\([^;]*?#(\d+)\s*\)\s*;",
re.IGNORECASE | re.DOTALL,
)
# NEXT_ASSEMBLY_USAGE_OCCURRENCE('name', ..., #parent_pd, #child_pd, ...)
_NAUO_RE = re.compile(
r"#(\d+)\s*=\s*NEXT_ASSEMBLY_USAGE_OCCURRENCE\s*\(\s*'([^']*)'\s*,"
r"[^;]*?#(\d+)\s*,\s*#(\d+)\s*",
re.IGNORECASE | re.DOTALL,
)
def decode_step_string(s: str) -> str:
def repl(m: re.Match[str]) -> str:
hexpart = m.group(1)
chars: list[str] = []
for i in range(0, len(hexpart), 4):
chars.append(chr(int(hexpart[i : i + 4], 16)))
return "".join(chars)
return _X2_RE.sub(repl, s)
def parse_step_products(step_path: Path) -> dict:
text = step_path.read_text(encoding="utf-8", errors="replace")
products: dict[str, str] = {}
for m in _PRODUCT_RE.finditer(text):
eid, name, *_ = m.groups()
products[eid] = decode_step_string(name)
# pdf_id -> product_id
pdf_to_product: dict[str, str] = {}
for m in _PDF_RE.finditer(text):
pdf_id, product_id = m.groups()
pdf_to_product[pdf_id] = product_id
# pd_id -> pdf_id
pd_to_pdf: dict[str, str] = {}
for m in _PD_RE.finditer(text):
pd_id, pdf_id = m.groups()
pd_to_pdf[pd_id] = pdf_id
def pd_name(pd_id: str) -> str:
pdf = pd_to_pdf.get(pd_id)
if not pdf:
return f"pd_{pd_id}"
prod = pdf_to_product.get(pdf)
if not prod:
return f"pdf_{pdf}"
return products.get(prod, f"product_{prod}")
children: dict[str, list[str]] = defaultdict(list)
parents: dict[str, str] = {}
edge_names: dict[tuple[str, str], str] = {}
for m in _NAUO_RE.finditer(text):
_eid, edge_name, parent_pd, child_pd = m.groups()
children[parent_pd].append(child_pd)
parents[child_pd] = parent_pd
edge_names[(parent_pd, child_pd)] = decode_step_string(edge_name)
# Roots = PDs that appear as parents or products but have no parent
all_pd = set(pd_to_pdf) | set(children) | set(parents)
roots = [pd for pd in all_pd if pd not in parents]
if not roots and products:
# fallback: flat product list
nodes = [
PartNode(id=pid, name=name, parent_id=None)
for pid, name in products.items()
]
return {
"root_name": nodes[0].name if nodes else "assembly",
"parts": nodes,
"product_names": sorted({p.name for p in nodes}),
"stats": {
"products": len(products),
"nauo": 0,
"axis2_placement": text.upper().count("AXIS2_PLACEMENT_3D"),
"circles": len(re.findall(r"\bCIRCLE\b", text, flags=re.I)),
"cylinders": text.upper().count("CYLINDRICAL_SURFACE"),
"mode": "flat_products",
},
}
# Prefer the largest tree root as assembly root
def subtree_size(pd: str, seen: set[str] | None = None) -> int:
seen = seen or set()
if pd in seen:
return 0
seen.add(pd)
return 1 + sum(subtree_size(c, seen) for c in children.get(pd, []))
roots_sorted = sorted(roots, key=subtree_size, reverse=True)
root_pd = roots_sorted[0] if roots_sorted else None
nodes: list[PartNode] = []
for pd in sorted(all_pd, key=lambda x: int(x) if x.isdigit() else 0):
nodes.append(
PartNode(
id=pd,
name=pd_name(pd),
parent_id=parents.get(pd),
children=list(children.get(pd, [])),
)
)
product_names = sorted({decode_step_string(n) for n in products.values()})
root_name = pd_name(root_pd) if root_pd else ""
if not root_name or root_name.startswith(("pd_", "pdf_", "product_")):
preferred = next((n for n in product_names if "装配体" in n or "assembly" in n.lower()), None)
root_name = preferred or (product_names[0] if product_names else "assembly")
return {
"root_name": root_name,
"parts": nodes,
"product_names": product_names,
"stats": {
"products": len(products),
"product_definitions": len(pd_to_pdf),
"nauo": len(edge_names),
"roots": len(roots),
"axis2_placement": text.upper().count("AXIS2_PLACEMENT_3D"),
"circles": len(re.findall(r"\bCIRCLE\b", text, flags=re.I)),
"cylinders": text.upper().count("CYLINDRICAL_SURFACE"),
"mode": "assembly_tree",
"geometry_backend": "text_only",
},
}
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
from xml.dom import minidom
from app.models import RobotDraft
def _fmt(nums: list[float]) -> str:
return " ".join(f"{float(x):.6g}" for x in nums)
def build_urdf_xml(draft: RobotDraft, mesh_dir_uri: str = "package://robot_description/meshes") -> str:
robot = ET.Element("robot", name=draft.name)
for link in draft.links:
link_el = ET.SubElement(robot, "link", name=link.name)
visual = ET.SubElement(link_el, "visual")
ET.SubElement(visual, "origin", xyz="0 0 0", rpy="0 0 0")
geom = ET.SubElement(visual, "geometry")
mesh_name = link.mesh or f"{link.name}.stl"
ET.SubElement(geom, "mesh", filename=f"{mesh_dir_uri}/{mesh_name}", scale="0.001 0.001 0.001")
coll = ET.SubElement(link_el, "collision")
ET.SubElement(coll, "origin", xyz="0 0 0", rpy="0 0 0")
cgeom = ET.SubElement(coll, "geometry")
ET.SubElement(cgeom, "mesh", filename=f"{mesh_dir_uri}/{mesh_name}", scale="0.001 0.001 0.001")
inertial = ET.SubElement(link_el, "inertial")
ET.SubElement(inertial, "origin", xyz="0 0 0", rpy="0 0 0")
ET.SubElement(inertial, "mass", value="1.0")
ET.SubElement(
inertial,
"inertia",
ixx="0.001",
ixy="0",
ixz="0",
iyy="0.001",
iyz="0",
izz="0.001",
)
for joint in draft.joints:
j_el = ET.SubElement(robot, "joint", name=joint.name, type=joint.joint_type)
ET.SubElement(j_el, "parent", link=joint.parent)
ET.SubElement(j_el, "child", link=joint.child)
ET.SubElement(j_el, "origin", xyz=_fmt(joint.origin_xyz), rpy=_fmt(joint.origin_rpy))
if joint.joint_type in {"revolute", "prismatic", "continuous"}:
ET.SubElement(j_el, "axis", xyz=_fmt(joint.axis))
if joint.joint_type in {"revolute", "prismatic"}:
ET.SubElement(
j_el,
"limit",
lower=str(joint.lower),
upper=str(joint.upper),
effort=str(joint.effort),
velocity=str(joint.velocity),
)
rough = ET.tostring(robot, encoding="utf-8")
pretty = minidom.parseString(rough).toprettyxml(indent=" ")
# drop extra XML declaration newline quirks
lines = [ln for ln in pretty.splitlines() if ln.strip()]
return "\n".join(lines) + "\n"
def write_urdf_package(draft: RobotDraft, out_dir: Path) -> dict[str, str]:
out_dir.mkdir(parents=True, exist_ok=True)
meshes = out_dir / "meshes"
meshes.mkdir(exist_ok=True)
urdf_path = out_dir / f"{draft.name}.urdf"
urdf_xml = build_urdf_xml(draft, mesh_dir_uri="meshes")
urdf_path.write_text(urdf_xml, encoding="utf-8")
# Placeholder note for missing meshes
readme = out_dir / "README.txt"
readme.write_text(
"\n".join(
[
f"Robot: {draft.name}",
f"Profile: {draft.profile}",
f"Links: {len(draft.links)}",
f"Joints: {len(draft.joints)}",
"",
"Mesh files are expected under ./meshes as <link>.stl.",
"If meshes were not generated, URDF still validates structurally;",
"install OCP/cadquery or cascadio later for STEP tessellation.",
"",
"Notes:",
*[f"- {n}" for n in draft.notes],
]
)
+ "\n",
encoding="utf-8",
)
draft_json = out_dir / "draft.json"
draft_json.write_text(draft.model_dump_json(indent=2), encoding="utf-8")
return {
"urdf": str(urdf_path),
"readme": str(readme),
"draft_json": str(draft_json),
"meshes_dir": str(meshes),
}
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import json
import re
from typing import Any
import httpx
from app.config import settings
from app.models import JointDef, LinkDef, RobotDraft
SYSTEM_PROMPT = """你是机器人 URDF 运动学专家。根据零件列表与包围盒,直接设计 link/joint 树。
约定:
- 单位:origin_xyz 用毫米;origin_rpy 用弧度。
- 第一个关节相对世界系,origin 靠近对应零件 center,不要写成 [0,0,0]。
- part_names 只能用输入里的名字(如 Solid_0),禁止发明新名字。
- 每个非 base_link 恰好一个 parent joint。
- axis 为单位向量;不要把所有轴都写成 [0,0,1]。
- rationale 最多 20 字。
- 只输出一个合法 JSON 对象,不要 Markdown、不要注释、不要尾逗号。
JSON schema:
{"robot_name":"string","links":[{"name":"string","part_names":["string"]}],"joints":[{"name":"string","joint_type":"revolute|prismatic|fixed","parent":"string","child":"string","origin_xyz":[0,0,0],"origin_rpy":[0,0,0],"axis":[0,0,1],"lower":-3.14,"upper":3.14,"effort":100,"velocity":1,"rationale":"string"}],"notes":["string"]}
"""
def _strip_fences(text: str) -> str:
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
return text.strip()
def _repair_json_text(text: str) -> str:
"""Best-effort cleanup for common LLM JSON issues / truncation."""
text = _strip_fences(text)
# Chinese quotes → ASCII
text = text.replace("“", '"').replace("”", '"').replace("‘", "'").replace("’", "'")
# Remove // comments
text = re.sub(r"(?m)^\s*//.*?$", "", text)
# Trailing commas before } or ]
text = re.sub(r",\s*([}\]])", r"\1", text)
# If truncated, close open braces/brackets
start = text.find("{")
if start < 0:
return text
text = text[start:]
# Drop incomplete trailing string if odd number of unescaped quotes in last line
in_str = False
escape = False
stack: list[str] = []
last_ok = 0
for i, ch in enumerate(text):
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
continue
if ch in "{[":
stack.append("}" if ch == "{" else "]")
last_ok = i
elif ch in "}]":
if stack and stack[-1] == ch:
stack.pop()
last_ok = i
if in_str:
# close string then truncate junk after last complete structure
text = text + '"'
if stack:
# trim to last comma / incomplete key if needed
text = text.rstrip()
if text.endswith(","):
text = text[:-1]
text = text + "".join(reversed(stack))
return text
def _extract_json(text: str) -> dict[str, Any]:
candidates = [_strip_fences(text), _repair_json_text(text)]
m = re.search(r"\{[\s\S]*\}", text)
if m:
candidates.append(m.group(0))
candidates.append(_repair_json_text(m.group(0)))
errors: list[str] = []
for cand in candidates:
try:
data = json.loads(cand)
if isinstance(data, dict):
return data
except json.JSONDecodeError as e:
errors.append(str(e))
raise json.JSONDecodeError(
errors[-1] if errors else "Unable to parse LLM JSON",
text[:200],
0,
)
def _compact_solids(solids_geom: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""Shrink AABB payload so the model reply is less likely to truncate."""
out: list[dict[str, Any]] = []
for s in solids_geom or []:
center = s.get("center")
if not center:
mn, mx = s.get("min"), s.get("max")
if mn and mx and len(mn) == 3 and len(mx) == 3:
center = [(mn[i] + mx[i]) / 2 for i in range(3)]
if not center:
continue
out.append(
{
"name": s.get("name") or s.get("id"),
"center": [round(float(x), 1) for x in center[:3]],
}
)
return out
def _draft_from_llm(data: dict[str, Any], fallback: RobotDraft) -> RobotDraft:
links = [
LinkDef(name=l["name"], part_names=list(l.get("part_names") or []))
for l in data.get("links") or []
]
joints = []
for j in data.get("joints") or []:
jtype = j.get("joint_type") or "revolute"
if jtype not in {"revolute", "prismatic", "fixed", "continuous"}:
jtype = "revolute"
joints.append(
JointDef(
name=j.get("name") or "joint",
joint_type=jtype, # type: ignore[arg-type]
parent=j["parent"],
child=j["child"],
origin_xyz=[float(x) for x in (j.get("origin_xyz") or [0, 0, 0])],
origin_rpy=[float(x) for x in (j.get("origin_rpy") or [0, 0, 0])],
axis=[float(x) for x in (j.get("axis") or [0, 0, 1])],
lower=float(j.get("lower", -3.14)),
upper=float(j.get("upper", 3.14)),
effort=float(j.get("effort", 100)),
velocity=float(j.get("velocity", 1)),
rationale=str(j.get("rationale") or "")[:80],
)
)
if not links:
return fallback.model_copy(
update={
"llm_raw": data,
"notes": fallback.notes + ["LLM returned no links; kept seed."],
}
)
return RobotDraft(
name=str(data.get("robot_name") or fallback.name),
profile=fallback.profile,
links=links,
joints=joints,
notes=list(data.get("notes") or []),
raw_parts=fallback.raw_parts,
axis_candidates=fallback.axis_candidates,
llm_raw=data,
)
async def _chat(messages: list[dict[str, str]], *, temperature: float = 0.2) -> str:
headers = {
"Authorization": f"Bearer {settings.zhipu_api_key}",
"Content-Type": "application/json",
}
body = {
"model": settings.zhipu_model,
"messages": messages,
"temperature": temperature,
"max_tokens": 8192,
}
url = f"{settings.zhipu_base_url.rstrip('/')}/chat/completions"
async with httpx.AsyncClient(timeout=180.0) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
async def propose_joints_with_zhipu(
seed: RobotDraft,
*,
extra_hint: str = "",
stats: dict[str, Any] | None = None,
solids_geom: list[dict[str, Any]] | None = None,
) -> RobotDraft:
if not settings.zhipu_api_key:
seed.notes = list(seed.notes) + [
"ZHIPU_API_KEY 未配置:无法生成关节,请配置后重试。"
]
return seed
compact = _compact_solids(solids_geom)
payload_user = {
"profile": seed.profile,
"robot_name": seed.name,
"part_names": seed.raw_parts,
"solids_center_mm": compact,
"extra_hint": extra_hint,
"instruction": (
"直接生成完整 links+joints JSON。"
"origin_xyz 用毫米;第一个关节靠近零件 center。"
),
}
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": json.dumps(payload_user, ensure_ascii=False)},
]
content = await _chat(messages, temperature=0.2)
try:
parsed = _extract_json(content)
except json.JSONDecodeError:
# One repair pass: ask model to fix truncated / invalid JSON
repair_messages = [
{"role": "system", "content": "只输出修复后的合法 JSON 对象,不要解释。"},
{
"role": "user",
"content": (
"下面内容不是合法 JSON(可能被截断或有尾逗号)。"
"请修复并只输出完整 JSON:\n\n" + content[:12000]
),
},
]
repaired = await _chat(repair_messages, temperature=0.0)
try:
parsed = _extract_json(repaired)
except json.JSONDecodeError as e:
raise ValueError(
f"智谱返回的 JSON 无法解析(常见于输出被截断)。请重试或减少零件数量。原始错误: {e.msg}"
) from e
return _draft_from_llm(parsed, seed)
+114
View File
@@ -0,0 +1,114 @@
const state = {
jobId: null,
draft: null,
};
const $ = (id) => document.getElementById(id);
function pretty(obj) {
return JSON.stringify(obj, null, 2);
}
async function refreshHealth() {
const el = $("health");
try {
const res = await fetch("/api/health");
const data = await res.json();
const z = data.zhipu_configured ? `智谱 OK (${data.zhipu_model})` : "智谱未配置";
const g = data.geometry?.available ? "几何后端可用" : "几何后端未装(占位 mesh)";
el.textContent = `${z} · ${g}`;
el.className = "pill " + (data.zhipu_configured ? "ok" : "warn");
} catch (e) {
el.textContent = "后端未连接";
el.className = "pill warn";
}
}
$("btnParse").addEventListener("click", async () => {
const file = $("file").files?.[0];
if (!file) {
$("parseOut").textContent = "请先选择 STEP 文件";
$("parseOut").classList.add("error");
return;
}
$("parseOut").classList.remove("error");
$("parseOut").textContent = "解析中…";
const fd = new FormData();
fd.append("file", file);
try {
const res = await fetch("/api/parse", { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
state.jobId = data.job_id;
$("btnPropose").disabled = false;
$("parseOut").textContent = pretty({
job_id: data.job_id,
root_name: data.root_name,
product_count: data.product_names?.length,
product_names: data.product_names,
stats: data.stats,
});
} catch (e) {
$("parseOut").textContent = String(e);
$("parseOut").classList.add("error");
}
});
$("btnPropose").addEventListener("click", async () => {
if (!state.jobId) return;
$("proposeOut").classList.remove("error");
$("proposeOut").textContent = "调用智谱中(可能需要几十秒)…";
try {
const res = await fetch("/api/propose", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
job_id: state.jobId,
profile: $("profile").value,
robot_name: $("robotName").value || "A7",
extra_hint: $("hint").value || "",
}),
});
if (!res.ok) throw new Error(await res.text());
const draft = await res.json();
state.draft = draft;
$("draftEditor").value = pretty(draft);
$("btnExport").disabled = false;
$("proposeOut").textContent = `links=${draft.links?.length || 0}, joints=${draft.joints?.length || 0}\n` +
(draft.notes || []).map((n) => `- ${n}`).join("\n");
} catch (e) {
$("proposeOut").textContent = String(e);
$("proposeOut").classList.add("error");
}
});
$("btnExport").addEventListener("click", async () => {
if (!state.jobId) return;
$("exportOut").classList.remove("error");
try {
const draft = JSON.parse($("draftEditor").value);
state.draft = draft;
const res = await fetch("/api/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
job_id: state.jobId,
draft,
include_meshes: true,
}),
});
if (!res.ok) throw new Error(await res.text());
const meta = await res.json();
$("exportOut").textContent = pretty(meta);
const name = draft.name || "robot";
const a = $("dlLink");
a.hidden = false;
a.href = `/api/jobs/${state.jobId}/download/${encodeURIComponent(name)}`;
a.textContent = `下载 ${name}.urdf`;
} catch (e) {
$("exportOut").textContent = String(e);
$("exportOut").classList.add("error");
}
});
refreshHealth();
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Legacy UI — step2urdf-tool</title>
<link rel="stylesheet" href="/legacy/style.css" />
</head>
<body>
<div class="page">
<header class="hero">
<div>
<p class="eyebrow">LEGACY</p>
<h1>旧版静态 UI(已降级)</h1>
<p class="sub">
主界面请用 step2urdf 前端:开发时打开
<a href="http://127.0.0.1:5678">http://127.0.0.1:5678</a>
(Vite + 智谱建议关节 + 导出 URDF)。本页仅保留调试。
</p>
</div>
<div id="health" class="pill">checking…</div>
</header>
<section class="panel">
<h2>1. 上传 STEP</h2>
<div class="row">
<input id="file" type="file" accept=".step,.stp,.STEP,.STP" />
<button id="btnParse" class="primary">解析</button>
</div>
<pre id="parseOut" class="code muted">等待上传…</pre>
</section>
<section class="panel">
<h2>2. 智谱关节提案</h2>
<div class="grid2">
<label>Profile
<select id="profile">
<option value="a7" selected>a7(先试机械臂)</option>
<option value="generic">generic(后续其他 STEP)</option>
</select>
</label>
<label>机器人名
<input id="robotName" value="A7" />
</label>
</div>
<label>额外提示
<textarea id="hint" rows="2" placeholder="可选:关节数量、固定件说明…"></textarea>
</label>
<button id="btnPropose" class="primary">调用智谱</button>
<pre id="draftOut" class="code muted">等待提案…</pre>
</section>
<section class="panel">
<h2>3. 导出 URDF(服务端草稿)</h2>
<button id="btnExport" class="primary">导出</button>
<pre id="exportOut" class="code muted">等待导出…</pre>
</section>
</div>
<script src="/legacy/app.js"></script>
</body>
</html>
+149
View File
@@ -0,0 +1,149 @@
:root {
--bg0: #0f1412;
--bg1: #18211c;
--ink: #e8f0ea;
--muted: #9bb0a2;
--accent: #c4f54b;
--accent2: #5ee0a0;
--line: rgba(232, 240, 234, 0.12);
--danger: #ff7a7a;
--font-display: "Fraunces", "Iowan Old Style", Georgia, serif;
--font-body: "IBM Plex Sans", "Noto Sans SC", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "SF Mono", ui-monospace, monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(94, 224, 160, 0.18), transparent 55%),
radial-gradient(900px 500px at 100% 0%, rgba(196, 245, 75, 0.12), transparent 50%),
linear-gradient(165deg, var(--bg0), #121a16 40%, #0c100e);
color: var(--ink);
font-family: var(--font-body);
}
.page {
width: min(980px, calc(100% - 2rem));
margin: 0 auto;
padding: 2.2rem 0 4rem;
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.eyebrow {
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent2);
font-size: 0.75rem;
margin: 0 0 0.4rem;
}
h1 {
font-family: var(--font-display);
font-weight: 560;
font-size: clamp(2rem, 4vw, 2.8rem);
margin: 0;
letter-spacing: -0.02em;
}
.sub { color: var(--muted); margin: 0.55rem 0 0; max-width: 36rem; }
.pill {
border: 1px solid var(--line);
background: rgba(255,255,255,0.03);
padding: 0.55rem 0.8rem;
border-radius: 999px;
font-size: 0.82rem;
color: var(--muted);
white-space: nowrap;
}
.pill.ok { color: var(--accent2); border-color: rgba(94,224,160,0.35); }
.pill.warn { color: #ffd27a; border-color: rgba(255,210,122,0.35); }
.panel {
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015));
border-radius: 18px;
padding: 1.15rem 1.2rem 1.25rem;
margin-bottom: 1rem;
backdrop-filter: blur(8px);
}
.panel h2 {
margin: 0 0 0.9rem;
font-size: 1.05rem;
font-weight: 600;
}
.row { display: flex; gap: 0.7rem; align-items: center; flex-wrap: wrap; margin: 0.6rem 0; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; }
@media (max-width: 720px) { .grid2 { grid-template-columns: 1fr; } }
label {
display: grid;
gap: 0.35rem;
font-size: 0.86rem;
color: var(--muted);
margin: 0.45rem 0;
}
input[type="text"], input:not([type]), input[type="file"], select, textarea {
width: 100%;
border: 1px solid var(--line);
background: rgba(0,0,0,0.28);
color: var(--ink);
border-radius: 10px;
padding: 0.65rem 0.75rem;
font: inherit;
}
textarea {
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.45;
resize: vertical;
}
button, .ghost {
border: 0;
border-radius: 999px;
padding: 0.62rem 1.05rem;
font: inherit;
cursor: pointer;
text-decoration: none;
}
button.primary {
background: linear-gradient(120deg, var(--accent), var(--accent2));
color: #102016;
font-weight: 650;
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
.ghost {
color: var(--accent2);
border: 1px solid rgba(94,224,160,0.35);
background: transparent;
}
.code {
margin: 0.7rem 0 0;
padding: 0.85rem;
border-radius: 12px;
background: rgba(0,0,0,0.35);
border: 1px solid var(--line);
overflow: auto;
max-height: 260px;
font-family: var(--font-mono);
font-size: 0.78rem;
white-space: pre-wrap;
}
.muted { color: var(--muted); }
.error { color: var(--danger); }