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
+11
View File
@@ -0,0 +1,11 @@
# Zhipu AI (required for joint proposal; never put this key in frontend/)
ZHIPU_API_KEY=
ZHIPU_MODEL=glm-4-plus
# Server (LLM proxy + optional legacy /parse)
HOST=127.0.0.1
PORT=8787
DATA_DIR=data
# Optional: absolute path to a default STEP for quick local demos
# DEFAULT_STEP=/home/lxqs/A7_step/A7_assembly.STEP
+15
View File
@@ -0,0 +1,15 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
data/uploads/
data/jobs/
*.STEP
*.step
*.stp
.DS_Store
node_modules/
dist/
+99
View File
@@ -0,0 +1,99 @@
# step2urdf-tool
Local fork/extension of **[step2urdf](https://github.com/Democratizing-Dexterous/step2urdf)** (UI same product as https://step2urdf.top/), plus a small **FastAPI** backend that proxies **智谱 (Zhipu)** for link/joint suggestions.
Geometry parse + URDF export stay in the **browser** (OpenCASCADE WASM). The backend only helps propose a kinematic draft; you refine axes in the 3D view, then use step2urdfs own **导出 URDF**.
## Architecture
```
Browser (Vite :5678) FastAPI (:8787)
┌─────────────────────────────┐ ┌──────────────────────────┐
│ step2urdf UI (frontend/) │ /api/* │ ZHIPU_API_KEY (server) │
│ OCCT WASM · links/joints │ ──────► │ POST /api/propose │
│ Export URDF (client ZIP) │ │ optional /api/parse │
└─────────────────────────────┘ └──────────────────────────┘
```
Upstream source is under `frontend/` — see `frontend/UPSTREAM.md` for commit SHA.
The old MVP static page is demoted to **http://127.0.0.1:8787/legacy/** only.
## Quick start (dev)
### 1. Backend
```bash
cd ~/Projects/step2urdf-tool
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# edit .env → set ZHIPU_API_KEY (never commit .env)
python -m app.main
```
API: http://127.0.0.1:8787/api/health
### 2. Frontend (primary UI)
Needs Node 20+ and pnpm 10 (see `frontend/package.json` `packageManager`).
```bash
export PATH="$HOME/.local/node/bin:$PATH" # if pnpm lives here
cd ~/Projects/step2urdf-tool/frontend
pnpm install
pnpm dev
```
Open: **http://127.0.0.1:5678**
Vite proxies `/api` → FastAPI `:8787`.
Test STEP: `/home/lxqs/A7_step/A7_assembly.STEP`
### 3. Zhipu button
After importing a STEP:
1. Left panel → **智谱建议关节**
2. Confirm/edit part names + profile (`a7` / `generic`) + optional hint
3. **请求智谱建议** → preview joints
4. **应用到结构树** → writes into step2urdf `useURDFStore` (links, joints, solid binds by name)
5. Tweak joint origins/axes in 3D (edge pick / axis offset) — LLM values are approximate
6. **导出 URDF** (native step2urdf exporter)
## Environment
| Variable | Meaning |
|----------|---------|
| `ZHIPU_API_KEY` | Required for LLM propose |
| `ZHIPU_MODEL` | Default `glm-4-plus` |
| `HOST` / `PORT` | FastAPI default `127.0.0.1:8787` |
## Layout
```
frontend/ # step2urdf Vue/Vite app (+ Zhipu panel)
UPSTREAM.md
app/
main.py # FastAPI: /api/propose, CORS, /legacy
services/zhipu.py # Zhipu chat → RobotDraft JSON
services/profiles.py # a7 / generic seed grouping
static/ # legacy MVP UI (/legacy)
data/jobs/ # optional server-side parse jobs
```
## Production-ish (optional)
```bash
cd frontend && pnpm build
# FastAPI serves frontend/dist at / when present
python -m app.main
```
## Known gaps
- Joint **origin/axis** from Zhipu are estimates — always refine in the 3D viewer before trusting Export.
- Solid binding matches by **part name**; rename mismatches need manual bind.
- Server `/api/export` is legacy; prefer the UI exporter.
- Deep auto-snap from LLM to OCCT edge features is not implemented yet.
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); }
+221
View File
@@ -0,0 +1,221 @@
{
"robot_name": "handtuned_arm",
"source": "manual UI screenshots 2026-08-26",
"unit_linear": "m",
"unit_angular": "rad",
"notes": [
"xyz/rpy 来自属性面板:相对 parent link,线性单位米、角度弧度。",
"旋转关节默认绕关节局部 Zaxis=[0,0,1]),姿态由 origin_rpy 决定。",
"导入时:线性量 ×1000→mm;挂在 base_link 下的关节会烘焙成 STEP 世界坐标(页面 FK 从世界原点起步)。"
],
"base_link": {
"name": "base_link",
"solid_names": ["Solid_56", "Solid_55"],
"origin_xyz": [0.1135, 0.6081, 0.0071],
"origin_rpy": [0.0, 0.0, 0.0],
"up_axis": "Z+"
},
"links": [
{
"name": "base_link",
"solid_names": ["Solid_56", "Solid_55"]
},
{
"name": "Link_1",
"solid_names": [
"Solid_54",
"Solid_53",
"Solid_52",
"Solid_48",
"Solid_47",
"Solid_51",
"Solid_49",
"Solid_50"
]
},
{
"name": "Link_2",
"solid_names": [
"Solid_41",
"Solid_42",
"Solid_45",
"Solid_46",
"Solid_43",
"Solid_40"
]
},
{
"name": "Link_3",
"solid_names": [
"Solid_44",
"Solid_39",
"Solid_33",
"Solid_29",
"Solid_38",
"Solid_37",
"Solid_35",
"Solid_36"
]
},
{
"name": "Link_4",
"solid_names": [
"Solid_26",
"Solid_32",
"Solid_24",
"Solid_23",
"Solid_34",
"Solid_30",
"Solid_25",
"Solid_31",
"Solid_28",
"Solid_27",
"Solid_22"
]
},
{
"name": "Link_5",
"solid_names": [
"Solid_21",
"Solid_15",
"Solid_17",
"Solid_16",
"Solid_20"
]
},
{
"name": "Link_6",
"solid_names": [
"Solid_11",
"Solid_12",
"Solid_8",
"Solid_13",
"Solid_9",
"Solid_14",
"Solid_19",
"Solid_18"
]
},
{
"name": "Link_7",
"solid_names": [
"Solid_6",
"Solid_5",
"Solid_2",
"Solid_10",
"Solid_1",
"Solid_4",
"Solid_3",
"Solid_7",
"Solid_0"
]
}
],
"joints": [
{
"name": "Joint_1",
"joint_type": "revolute",
"parent": "base_link",
"child": "Link_1",
"origin_xyz": [-0.048, 0.001, 0.0],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_2",
"joint_type": "revolute",
"parent": "Link_1",
"child": "Link_2",
"origin_xyz": [0.0, -0.001, -0.062],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_3",
"joint_type": "revolute",
"parent": "Link_2",
"child": "Link_3",
"origin_xyz": [0.0, -0.121, 0.0],
"origin_rpy": [1.57, 0.0, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_4",
"joint_type": "revolute",
"parent": "Link_3",
"child": "Link_4",
"origin_xyz": [0.003, 0.0, 0.159],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_5",
"joint_type": "revolute",
"parent": "Link_4",
"child": "Link_5",
"origin_xyz": [-0.128, 0.0, 0.0],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_6",
"joint_type": "revolute",
"parent": "Link_5",
"child": "Link_6",
"origin_xyz": [0.0, -0.001, -0.089],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_7",
"joint_type": "revolute",
"parent": "Link_6",
"child": "Link_7",
"origin_xyz": [0.065, 0.0, 0.0],
"origin_rpy": [1.57, 0.0, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
}
]
}
+6
View File
@@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
+25
View File
@@ -0,0 +1,25 @@
# 本地环境
VITE_USER_NODE_ENV = development
# 公共基础路径
VITE_PUBLIC_PATH = /
# 路由模式
# Optional: hash | history
VITE_ROUTER_MODE = hash
# 打包时是否删除 console
VITE_DROP_CONSOLE = true
# 是否开启 VitePWA
VITE_PWA = false
# 开发环境接口地址
VITE_API_URL = /api
#intervalTime
VITE_INTERVAL_TIME = 10000
# 开发环境跨域代理,支持配置多个
VITE_PROXY = [["/api","https://mock.mengxuegu.com/mock/65fbf147838cf807b819d738/mgm"]]
+28
View File
@@ -0,0 +1,28 @@
# 线上环境
VITE_USER_NODE_ENV = production
# 公共基础路径
VITE_PUBLIC_PATH = /
# 路由模式
# Optional: hash | history
VITE_ROUTER_MODE = history
# 是否启用 gzip 或 brotli 压缩打包,如果需要多个压缩规则,可以使用 “,” 分隔
# Optional: gzip | brotli | none
VITE_BUILD_COMPRESS = none
# 打包压缩后是否删除源文件
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
# 打包时是否删除 console
VITE_DROP_CONSOLE = true
# 是否开启 VitePWA
VITE_PWA = true
#intervalTime
VITE_INTERVAL_TIME = 1000
# 线上环境接口地址
VITE_API_URL = /api
+28
View File
@@ -0,0 +1,28 @@
# 测试环境
VITE_USER_NODE_ENV = test
# 公共基础路径
VITE_PUBLIC_PATH = /
# 路由模式
# Optional: hash | history
VITE_ROUTER_MODE = hash
# 是否启用 gzip 或 brotli 压缩打包,如果需要多个压缩规则,可以使用 “,” 分隔
# Optional: gzip | brotli | none
VITE_BUILD_COMPRESS = none
# 打包压缩后是否删除源文件
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
# 打包时是否删除 console
VITE_DROP_CONSOLE = true
# 是否开启 VitePWA
VITE_PWA = false
#intervalTime
VITE_INTERVAL_TIME = 1000
# 测试环境接口地址
VITE_API_URL = "https://mock.mengxuegu.com/mock/629d727e6163854a32e8307e"
+15
View File
@@ -0,0 +1,15 @@
*.sh
node_modules
*.md
*.woff
*.ttf
.vscode
.idea
dist
/public
/docs
.husky
.local
/bin
/src/mock/*
stats.html
+61
View File
@@ -0,0 +1,61 @@
// @see: http://eslint.cn
module.exports = {
root: true,
env: {
browser: true,
node: true,
es6: true
},
// 指定如何解析语法
parser: "vue-eslint-parser",
// 优先级低于 parse 的语法解析配置
parserOptions: {
parser: "@typescript-eslint/parser",
ecmaVersion: 2020,
sourceType: "module",
jsxPragma: "React",
ecmaFeatures: {
jsx: true
}
},
// 继承某些已有的规则
extends: ["plugin:vue/vue3-recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
/**
* "off" 或 0 ==> 关闭规则
* "warn" 或 1 ==> 打开的规则作为警告(不影响代码执行)
* "error" 或 2 ==> 规则作为一个错误(代码不能执行,界面报错)
*/
rules: {
// eslint (http://eslint.cn/docs/rules)
"no-var": "error", // 要求使用 let 或 const 而不是 var
"no-multiple-empty-lines": ["error", { max: 1 }], // 不允许多个空行
"prefer-const": "off", // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
"no-use-before-define": "off", // 禁止在 函数/类/变量 定义之前使用它们
// typeScript (https://typescript-eslint.io/rules)
"@typescript-eslint/no-unused-vars": "error", // 禁止定义未使用的变量
"@typescript-eslint/no-empty-function": "error", // 禁止空函数
"@typescript-eslint/prefer-ts-expect-error": "error", // 禁止使用 @ts-ignore
"@typescript-eslint/ban-ts-comment": "error", // 禁止 @ts-<directive> 使用注释或要求在指令后进行描述
"@typescript-eslint/no-inferrable-types": "off", // 可以轻松推断的显式类型可能会增加不必要的冗长
"@typescript-eslint/no-namespace": "off", // 禁止使用自定义 TypeScript 模块和命名空间
"@typescript-eslint/no-explicit-any": "off", // 禁止使用 any 类型
"@typescript-eslint/ban-types": "off", // 禁止使用特定类型
"@typescript-eslint/no-var-requires": "off", // 允许使用 require() 函数导入模块
"@typescript-eslint/no-non-null-assertion": "off", // 不允许使用后缀运算符的非空断言(!)
// vue (https://eslint.vuejs.org/rules)
"vue/script-setup-uses-vars": "error", // 防止<script setup>使用的变量<template>被标记为未使用,此规则仅在启用该 no-unused-vars 规则时有效
"vue/v-slot-style": "error", // 强制执行 v-slot 指令样式
"vue/no-mutating-props": "error", // 不允许改变组件 prop
"vue/custom-event-name-casing": "error", // 为自定义事件名称强制使用特定大小写
"vue/html-closing-bracket-newline": "off", // 在标签的右括号之前要求或禁止换行
"vue/attribute-hyphenation": "error", // 对模板中的自定义组件强制执行属性命名样式:my-prop="prop"
"vue/attributes-order": "off", // vue api使用顺序,强制执行属性顺序
"vue/no-v-html": "off", // 禁止使用 v-html
"vue/require-default-prop": "off", // 此规则要求为每个 prop 为必填时,必须提供默认值
"vue/multi-word-component-names": "off", // 要求组件名称始终为 “-” 链接的单词
"vue/no-setup-props-destructure": "off" // 禁止解构 props 传递给 setup
}
};
+31
View File
@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
pnpm-lock.yaml
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
*.zip
+1
View File
@@ -0,0 +1 @@
ignore-dep-scripts=false
+10
View File
@@ -0,0 +1,10 @@
/dist/*
.local
/node_modules/**
**/*.svg
**/*.sh
/public/*
stats.html
pnpm-lock.yaml
+41
View File
@@ -0,0 +1,41 @@
// @see: https://www.prettier.cn
module.exports = {
// 指定最大换行长度
printWidth: 130,
// 缩进制表符宽度 | 空格数
tabWidth: 2,
// 使用制表符而不是空格缩进行 (true:制表符,false:空格)
useTabs: false,
// 结尾不用分号 (true:有,false:没有)
semi: true,
// 使用单引号 (true:单引号,false:双引号)
singleQuote: false,
// 在对象字面量中决定是否将属性名用引号括起来 可选值 "<as-needed|consistent|preserve>"
quoteProps: "as-needed",
// 在JSX中使用单引号而不是双引号 (true:单引号,false:双引号)
jsxSingleQuote: false,
// 多行时尽可能打印尾随逗号 可选值"<none|es5|all>"
trailingComma: "none",
// 在对象,数组括号与文字之间加空格 "{ foo: bar }" (true:有,false:没有)
bracketSpacing: true,
// 将 > 多行元素放在最后一行的末尾,而不是单独放在下一行 (true:放末尾,false:单独一行)
bracketSameLine: false,
// (x) => {} 箭头函数参数只有一个时是否要有小括号 (avoid:省略括号,always:不省略括号)
arrowParens: "avoid",
// 指定要使用的解析器,不需要写文件开头的 @prettier
requirePragma: false,
// 可以在文件顶部插入一个特殊标记,指定该文件已使用 Prettier 格式化
insertPragma: false,
// 用于控制文本是否应该被换行以及如何进行换行
proseWrap: "preserve",
// 在html中空格是否是敏感的 "css" - 遵守 CSS 显示属性的默认值, "strict" - 空格被认为是敏感的 "ignore" - 空格被认为是不敏感的
htmlWhitespaceSensitivity: "css",
// 控制在 Vue 单文件组件中 <script> 和 <style> 标签内的代码缩进方式
vueIndentScriptAndStyle: false,
// 换行符使用 lf 结尾是 可选值 "<auto|lf|crlf|cr>"
endOfLine: "auto",
// 这两个选项可用于格式化以给定字符偏移量(分别包括和不包括)开始和结束的代码 (rangeStart:开始,rangeEnd:结束)
rangeStart: 0,
rangeEnd: Infinity
};
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Democratizing Dexterity
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+117
View File
@@ -0,0 +1,117 @@
# Step2urdf
The repository name has been officially changed from `URDFlyS2U` to `step2urdf`. Please use the new name in future references.
**The ultimate tool for converting STEP files to URDF.**
The online version is [https://step2urdf.top](https://step2urdf.top). Try it!
## Privacy & Local Processing
STEP files are processed entirely on your local machine using your own computational resources. No files are uploaded to external servers. Press F12 to open developer tools and verify that all processing happens locally.
## Overview
**Step2urdf** provides a new, userfriendly way to export CAD (STEP) designs to URDF, making robot model creation faster and more accurate.
It combines precise geometric feature extraction with intuitive configuration and realtime visualization to ensure an efficient and reliable workflow.
<p align="center">
<img src='assets/main.png' width="80%" />
</p>
## Key Features
- 🌀 **Precise joint setup from geometry**
Automatically detects arcs and line segments in the STEP file to define **revolute** and **prismatic** joints accurately.
<p align="center">
<img src='assets/joint_picking.png' width="60%" />
</p>
- ⚖️ **Automatic inertia and center of mass computation**
Input the total machine mass — the easiest parameter to measure — and URDFlyS2U automatically calculates the inertia and center of mass for each link.
Individual link masses can be modified independently.
<p align="center">
<img src='assets/inertia_computing.png' width="60%" />
</p>
- 🔍 **Interactive joint visualization**
Visualize joint configurations at any time. Configure and test each joint interactively to ensure correctness before exporting to URDF.
<p align="center">
<img src='assets/iterative_slide.png' width="60%" />
</p>
- 🔧 **Revolute and prismatic joint support**
Fully supports both joint types for flexible robot modeling.
- 📊 **Hierarchical model tree viewer**
Browse and manage all imported solids with an intuitive tree structure. Easily select, hide, or organize components for better workflow control.
<p align="center">
<img src='assets/solid_visible.png' width="60%" />
</p>
- 🎯 **Fine-tunable axis offset controls**
Adjust joint axis positions in XYZ directions with precision controls. Fine-tune axis offsets to achieve perfect alignment and accurate joint placement in your robot model.
<p align="center">
<img src='assets/joint_axis_offset.png' width="60%" />
</p>
## Video Tutorial
[bilibili](https://www.bilibili.com/video/BV168PjzrErB?vd_source=b2a1004302917395bdd25677ed784bdb)
## Usage
### pnpm
#### Install dependencies
```sh
pnpm install
```
#### Compile and Hot-Reload for Development
```sh
pnpm dev
```
#### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
#### Lint
```sh
pnpm lint
```
### Using release
Download the release, extract the files, and navigate to the extracted folder. Then run `python -m http.server` and open `127.0.0.1:8000` in your browser.
## Why Step2urdf
Step2urdf streamlines the process of converting detailed mechanical CAD models into robot description formats, offering precision, convenience, and clarity from design to URDF generation.
## Buy Me a Coffee
Ali Pay
<img src='assets/zfb.png' width="30%" />
</p>
WeChat
<img src='assets/wechat.png' width="30%" />
## Support
Contact me `yunlongdong@outlook.com`.
+28
View File
@@ -0,0 +1,28 @@
# Frontend origin (step2urdf)
Primary UI is vendored from the open-source **step2urdf** project (same product as https://step2urdf.top/).
| Field | Value |
|-------|--------|
| Upstream | https://github.com/Democratizing-Dexterous/step2urdf |
| License | MIT |
| Cloned commit | `5c67a6768ce6767edf31aaa9e0737561363c315e` (`5c67a67 Update README.md`) |
| Local path | `frontend/` |
## Local extensions
- Vite `/api` proxy → FastAPI `127.0.0.1:8787` (Zhipu propose; API key never in frontend)
- `ZhipuAssistDialog` + `applyZhipuDraft` wired into `URDFLeftPanel` (“智谱建议关节”)
- `package.json``pnpm.onlyBuiltDependencies` for esbuild postinstall
- `.npmrc``ignore-dep-scripts=false` so WASM/tooling install scripts run
To refresh from upstream (this directory may still contain a nested `.git` from the clone):
```bash
cd frontend
git fetch origin
git log -1 --oneline
# re-apply local patches if needed (Zhipu panel, vite proxy, UPSTREAM.md)
```
If you prefer a monorepo without nested git, remove `frontend/.git` manually and keep this file as the provenance record.
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.jpg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>step2urdf</title>
<meta name="description" content="免费在线STEP转URDF转换工具,支持ROS/ROS2机器人模型快速生成,无需安装,一键导出">
<meta name="keywords" content="STEP转URDF,ROS模型,机器人仿真,URDF生成,STEP文件转换">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
{
"name": "step2urdf-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.15.0",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"@parcel/watcher",
"vue-demi"
]
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@guolao/vue-monaco-editor": "^1.6.0",
"@vueuse/core": "^13.7.0",
"axios": "^1.7.7",
"comlink": "^4.4.2",
"echarts": "^6.0.0",
"echarts-liquidfill": "^3.1.0",
"element-plus": "^2.8.6",
"gl-matrix": "^3.4.4",
"jszip": "^3.10.1",
"nprogress": "^0.2.0",
"opencascade.js": "2.0.0-beta.b5ff984",
"pinia": "^2.2.4",
"qs": "^6.14.0",
"sass": "^1.83.0",
"scss": "^0.2.4",
"three": "^0.182.0",
"three-mesh-bvh": "^0.9.8",
"vite-plugin-svg-icons": "^2.0.1",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.4",
"@types/node": "^20.17.0",
"@types/three": "^0.182.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/eslint-config-prettier": "^10.0.0",
"@vue/eslint-config-typescript": "^14.1.1",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.13.0",
"eslint-plugin-vue": "^9.29.0",
"npm-run-all2": "^7.0.1",
"postcss": "^8.5.6",
"prettier": "^3.3.3",
"sass-embedded": "^1.80.4",
"tailwindcss": "^3.3.4",
"typescript": "~5.6.0",
"vite": "^5.4.10",
"vue-tsc": "^2.1.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+221
View File
@@ -0,0 +1,221 @@
{
"robot_name": "handtuned_arm",
"source": "manual UI screenshots 2026-08-26",
"unit_linear": "m",
"unit_angular": "rad",
"notes": [
"xyz/rpy 来自属性面板:相对 parent link,线性单位米、角度弧度。",
"旋转关节默认绕关节局部 Zaxis=[0,0,1]),姿态由 origin_rpy 决定。",
"导入时:线性量 ×1000→mm;挂在 base_link 下的关节会烘焙成 STEP 世界坐标(页面 FK 从世界原点起步)。"
],
"base_link": {
"name": "base_link",
"solid_names": ["Solid_56", "Solid_55"],
"origin_xyz": [0.1135, 0.6081, 0.0071],
"origin_rpy": [0.0, 0.0, 0.0],
"up_axis": "Z+"
},
"links": [
{
"name": "base_link",
"solid_names": ["Solid_56", "Solid_55"]
},
{
"name": "Link_1",
"solid_names": [
"Solid_54",
"Solid_53",
"Solid_52",
"Solid_48",
"Solid_47",
"Solid_51",
"Solid_49",
"Solid_50"
]
},
{
"name": "Link_2",
"solid_names": [
"Solid_41",
"Solid_42",
"Solid_45",
"Solid_46",
"Solid_43",
"Solid_40"
]
},
{
"name": "Link_3",
"solid_names": [
"Solid_44",
"Solid_39",
"Solid_33",
"Solid_29",
"Solid_38",
"Solid_37",
"Solid_35",
"Solid_36"
]
},
{
"name": "Link_4",
"solid_names": [
"Solid_26",
"Solid_32",
"Solid_24",
"Solid_23",
"Solid_34",
"Solid_30",
"Solid_25",
"Solid_31",
"Solid_28",
"Solid_27",
"Solid_22"
]
},
{
"name": "Link_5",
"solid_names": [
"Solid_21",
"Solid_15",
"Solid_17",
"Solid_16",
"Solid_20"
]
},
{
"name": "Link_6",
"solid_names": [
"Solid_11",
"Solid_12",
"Solid_8",
"Solid_13",
"Solid_9",
"Solid_14",
"Solid_19",
"Solid_18"
]
},
{
"name": "Link_7",
"solid_names": [
"Solid_6",
"Solid_5",
"Solid_2",
"Solid_10",
"Solid_1",
"Solid_4",
"Solid_3",
"Solid_7",
"Solid_0"
]
}
],
"joints": [
{
"name": "Joint_1",
"joint_type": "revolute",
"parent": "base_link",
"child": "Link_1",
"origin_xyz": [-0.048, 0.001, 0.0],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_2",
"joint_type": "revolute",
"parent": "Link_1",
"child": "Link_2",
"origin_xyz": [0.0, -0.001, -0.062],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_3",
"joint_type": "revolute",
"parent": "Link_2",
"child": "Link_3",
"origin_xyz": [0.0, -0.121, 0.0],
"origin_rpy": [1.57, 0.0, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_4",
"joint_type": "revolute",
"parent": "Link_3",
"child": "Link_4",
"origin_xyz": [0.003, 0.0, 0.159],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_5",
"joint_type": "revolute",
"parent": "Link_4",
"child": "Link_5",
"origin_xyz": [-0.128, 0.0, 0.0],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_6",
"joint_type": "revolute",
"parent": "Link_5",
"child": "Link_6",
"origin_xyz": [0.0, -0.001, -0.089],
"origin_rpy": [0.0, 1.57, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
},
{
"name": "Joint_7",
"joint_type": "revolute",
"parent": "Link_6",
"child": "Link_7",
"origin_xyz": [0.065, 0.0, 0.0],
"origin_rpy": [1.57, 0.0, 0.0],
"axis": [0.0, 0.0, 1.0],
"limits": {
"lower": -3.142,
"upper": 3.142,
"effort": 10.0,
"velocity": 1.0
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
</template>
<style lang="scss" scoped></style>
+3
View File
@@ -0,0 +1,3 @@
// 后端微服务模块前缀
export const PORT1 = "/geeker";
export const PORT2 = "/hooks";
+55
View File
@@ -0,0 +1,55 @@
import { CustomAxiosRequestConfig } from "../index";
import qs from "qs";
// 声明一个 Map 用于存储每个请求的标识和取消函数
let pendingMap = new Map<string, AbortController>();
// 序列化参数,确保对象属性顺序一致
const sortedStringify = (obj: any) => {
return qs.stringify(obj, { arrayFormat: "repeat", sort: (a, b) => a.localeCompare(b) });
};
// 获取请求的唯一标识
export const getPendingUrl = (config: CustomAxiosRequestConfig) => {
return [config.method, config.url, sortedStringify(config.data), sortedStringify(config.params)].join("&");
};
export class AxiosCanceler {
/**
* @description: 添加请求
* @param {Object} config
* @return void
*/
addPending(config: CustomAxiosRequestConfig) {
// 在请求开始前,对之前的请求做检查取消操作
this.removePending(config);
const url = getPendingUrl(config);
const controller = new AbortController();
config.signal = controller.signal;
pendingMap.set(url, controller);
}
/**
* @description: 移除请求
* @param {Object} config
*/
removePending(config: CustomAxiosRequestConfig) {
const url = getPendingUrl(config);
// 如果在 pending 中存在当前请求标识,需要取消当前请求并删除条目
const controller = pendingMap.get(url);
if (controller) {
controller.abort();
pendingMap.delete(url);
}
}
/**
* @description: 清空所有pending
*/
removeAllPending() {
pendingMap.forEach(controller => {
controller && controller.abort();
});
pendingMap.clear();
}
}
+43
View File
@@ -0,0 +1,43 @@
import { ElMessage } from "element-plus";
/**
* @description: 校验网络请求状态码
* @param {Number} status
* @return void
*/
export const checkStatus = (status: number) => {
switch (status) {
case 400:
ElMessage.error("请求失败!请您稍后重试");
break;
case 401:
ElMessage.error("登录失效!请您重新登录");
break;
case 403:
ElMessage.error("当前账号无权限访问!");
break;
case 404:
ElMessage.error("你所访问的资源不存在!");
break;
case 405:
ElMessage.error("请求方式错误!请您稍后重试");
break;
case 408:
ElMessage.error("请求超时!请您稍后重试");
break;
case 500:
ElMessage.error("服务异常!");
break;
case 502:
ElMessage.error("网关错误!");
break;
case 503:
ElMessage.error("服务不可用!");
break;
case 504:
ElMessage.error("网关超时!");
break;
default:
ElMessage.error("请求失败!");
}
};
+106
View File
@@ -0,0 +1,106 @@
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig, AxiosResponse } from "axios";
import { showFullScreenLoading, tryHideFullScreenLoading } from "@/components/Loading/fullScreen";
import { ElMessage } from "element-plus";
import { ResultData } from "@/api/interface";
import { ResultEnum } from "@/enums/httpEnum";
import { checkStatus } from "./helper/checkStatus";
import { AxiosCanceler } from "./helper/axiosCancel";
// import router from "@/routers";
export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
loading?: boolean;
cancel?: boolean;
}
const config = {
// 默认地址请求地址,可在 .env.** 文件中修改
baseURL: import.meta.env.VITE_API_URL as string,
// 设置超时时间
timeout: ResultEnum.TIMEOUT as number,
// 跨域时候允许携带凭证
withCredentials: true
};
const axiosCanceler = new AxiosCanceler();
class RequestHttp {
service: AxiosInstance;
public constructor(config: AxiosRequestConfig) {
// instantiation
this.service = axios.create(config);
/**
* @description 请求拦截器
* 客户端发送请求 -> [请求拦截器] -> 服务器
* token校验(JWT) : 接受服务器返回的 token,存储到 vuex/pinia/本地储存当中
*/
this.service.interceptors.request.use(
(config: CustomAxiosRequestConfig) => {
// 重复请求不需要取消,在 api 服务中通过指定的第三个参数: { cancel: false } 来控制
config.cancel ??= true;
config.cancel && axiosCanceler.addPending(config);
// 当前请求不需要显示 loading,在 api 服务中通过指定的第三个参数: { loading: false } 来控制
config.loading ??= true;
config.loading && showFullScreenLoading();
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
/**
* @description 响应拦截器
* 服务器换返回信息 -> [拦截统一处理] -> 客户端JS获取到信息
*/
this.service.interceptors.response.use(
(response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
const { data, config } = response;
axiosCanceler.removePending(config);
config.loading && tryHideFullScreenLoading();
// 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
if (data.code && data.code !== ResultEnum.SUCCESS) {
ElMessage.error(data.msg);
return Promise.reject(data);
}
// 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
return data;
},
async (error: AxiosError) => {
const { response } = error;
tryHideFullScreenLoading();
// 请求超时 && 网络错误单独判断,没有 response
if (error.message.indexOf("timeout") !== -1) ElMessage.error("请求超时!请您稍后重试");
if (error.message.indexOf("Network Error") !== -1) ElMessage.error("网络错误!请您稍后重试");
// 根据服务器响应的错误状态码,做不同的处理
if (response) checkStatus(response.status);
return Promise.reject(error);
}
);
}
/**
* @description 常用请求方法封装
*/
get<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {
return this.service.get(url, { params, ..._object });
}
post<T>(url: string, params?: object | string, _object = {}): Promise<ResultData<T>> {
return this.service.post(url, params, _object);
}
postAudio<T>(url: string, params?: object | string, _object = {}): Promise<T> {
return this.service.post(url, params, { ..._object, responseType: "blob", headers: { Accept: "audio/wav" } });
}
put<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {
return this.service.put(url, params, _object);
}
delete<T>(url: string, params?: any, _object = {}): Promise<ResultData<T>> {
return this.service.delete(url, { params, ..._object });
}
download(url: string, params?: object, _object = {}): Promise<BlobPart> {
return this.service.post(url, params, { ..._object, responseType: "blob" });
}
}
export default new RequestHttp(config);
+96
View File
@@ -0,0 +1,96 @@
// 请求响应参数(不包含data
export interface Result {
code: string;
msg: string;
}
// 请求响应参数(包含data
export interface ResultData<T = any> extends Result {
data: T;
}
// 分页响应参数
export interface ResPage<T> {
list: T[];
pageNum: number;
pageSize: number;
total: number;
}
// 分页请求参数
export interface ReqPage {
pageNum: number;
pageSize: number;
}
// 文件上传模块
export namespace Upload {
export interface ResFileUrl {
fileUrl: string;
}
}
// 登录模块
export namespace Login {
export interface ReqLoginForm {
username: string;
password: string;
}
export interface ResLogin {
access_token: string;
}
export interface ResAuthButtons {
[key: string]: string[];
}
}
// 用户管理模块
export namespace User {
export interface ReqUserParams extends ReqPage {
username: string;
gender: number;
idCard: string;
email: string;
address: string;
createTime: string[];
status: number;
}
export interface ResUserList {
id: string;
username: string;
gender: number;
user: { detail: { age: number } };
idCard: string;
email: string;
address: string;
createTime: string;
status: number;
avatar: string;
photo: any[];
children?: ResUserList[];
}
export interface ResStatus {
userLabel: string;
userValue: number;
}
export interface ResGender {
genderLabel: string;
genderValue: number;
}
export interface ResDepartment {
id: string;
name: string;
children?: ResDepartment[];
}
export interface ResRole {
id: string;
name: string;
children?: ResDepartment[];
}
}
export namespace Audio {
export interface resAudio {
text: string;
language: string;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Audio, Upload } from "@/api/interface/index";
import { PORT1 } from "@/api/config/servicePort";
import http from "@/api";
/**
* @name 文件上传模块
*/
// 图片上传
export const uploadImg = (params: FormData) => {
return http.post<Upload.ResFileUrl>(PORT1 + `/file/upload/img`, params, { cancel: false });
};
// 视频上传
export const uploadVideo = (params: FormData) => {
return http.post<Upload.ResFileUrl>(PORT1 + `/file/upload/video`, params, { cancel: false });
};
export const uploadAudioApi = (params: FormData) => {
return http.post<Audio.resAudio>("/PostVoiceFile", params, { cancel: false });
};
export const postResultApi = (params: { text: string }) => {
return http.post<any>("/PostResultText", params, { cancel: false });
};
export const AudioResultApi = (params: { text: string; language: string }) => {
return http.postAudio<any>("/ResultVoice", params, { loading: false });
};
export const SendMessageApi = () => {
return http.get<any>("/SendMessage", {}, { loading: true });
};
export const getExcelFileAPi = () => {
return http.download("/DownExcel", {});
};
+71
View File
@@ -0,0 +1,71 @@
import { ResPage, User } from "@/api/interface/index";
import { PORT1 } from "@/api/config/servicePort";
import http from "@/api";
/**
* @name 用户管理模块
*/
// 获取用户列表
export const getUserList = (params: User.ReqUserParams) => {
return http.post<ResPage<User.ResUserList>>(PORT1 + `/user/list`, params);
};
// 获取树形用户列表
export const getUserTreeList = (params: User.ReqUserParams) => {
return http.post<ResPage<User.ResUserList>>(PORT1 + `/user/tree/list`, params);
};
// 新增用户
export const addUser = (params: { id: string }) => {
return http.post(PORT1 + `/user/add`, params);
};
// 批量添加用户
export const BatchAddUser = (params: FormData) => {
return http.post(PORT1 + `/user/import`, params);
};
// 编辑用户
export const editUser = (params: { id: string }) => {
return http.post(PORT1 + `/user/edit`, params);
};
// 删除用户
export const deleteUser = (params: { id: string[] }) => {
return http.post(PORT1 + `/user/delete`, params);
};
// 切换用户状态
export const changeUserStatus = (params: { id: string; status: number }) => {
return http.post(PORT1 + `/user/change`, params);
};
// 重置用户密码
export const resetUserPassWord = (params: { id: string }) => {
return http.post(PORT1 + `/user/rest_password`, params);
};
// 导出用户数据
export const exportUserInfo = (params: User.ReqUserParams) => {
return http.download(PORT1 + `/user/export`, params);
};
// 获取用户状态字典
export const getUserStatus = () => {
return http.get<User.ResStatus[]>(PORT1 + `/user/status`);
};
// 获取用户性别字典
export const getUserGender = () => {
return http.get<User.ResGender[]>(PORT1 + `/user/gender`);
};
// 获取用户部门列表
export const getUserDepartment = () => {
return http.get<User.ResDepartment[]>(PORT1 + `/user/department`, {}, { cancel: false });
};
// 获取用户角色字典
export const getUserRole = () => {
return http.get<User.ResRole[]>(PORT1 + `/user/role`);
};
+86
View File
@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

+25
View File
@@ -0,0 +1,25 @@
@import './base.css';
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z" transform="scale(64)" fill="#1B1F23"/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,72 @@
import * as echarts from "echarts/core";
import { BarChart, LineChart, LinesChart, PieChart, ScatterChart, RadarChart, GaugeChart } from "echarts/charts";
import {
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
PolarComponent,
GeoComponent,
ToolboxComponent,
DataZoomComponent
} from "echarts/components";
import { LabelLayout, UniversalTransition } from "echarts/features";
import { CanvasRenderer } from "echarts/renderers";
import type {
BarSeriesOption,
LineSeriesOption,
LinesSeriesOption,
PieSeriesOption,
ScatterSeriesOption,
RadarSeriesOption,
GaugeSeriesOption
} from "echarts/charts";
import type {
TitleComponentOption,
TooltipComponentOption,
GridComponentOption,
DatasetComponentOption
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
import "echarts-liquidfill";
export type ECOption = ComposeOption<
| BarSeriesOption
| LineSeriesOption
| LinesSeriesOption
| PieSeriesOption
| RadarSeriesOption
| GaugeSeriesOption
| TitleComponentOption
| TooltipComponentOption
| GridComponentOption
| DatasetComponentOption
| ScatterSeriesOption
>;
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
PolarComponent,
GeoComponent,
ToolboxComponent,
DataZoomComponent,
BarChart,
LineChart,
LinesChart,
PieChart,
ScatterChart,
RadarChart,
GaugeChart,
LabelLayout,
UniversalTransition,
CanvasRenderer
]);
export default echarts;
+104
View File
@@ -0,0 +1,104 @@
<template>
<div id="echarts" ref="chartRef" :style="echartsStyle" />
</template>
<script setup lang="ts" name="ECharts">
import { ref, onMounted, onBeforeUnmount, watch, computed, markRaw, nextTick, onActivated } from "vue";
import { EChartsType, ECElementEvent } from "echarts/core";
import echarts, { ECOption } from "./config";
import { useDebounceFn } from "@vueuse/core";
// import { useGlobalStore } from "@/stores/modules/global";
import { storeToRefs } from "pinia";
interface Props {
option: ECOption;
renderer?: "canvas" | "svg";
resize?: boolean;
theme?: Object | string;
width?: number | string;
height?: number | string;
onClick?: (event: ECElementEvent) => any;
}
const props = withDefaults(defineProps<Props>(), {
renderer: "canvas",
resize: true
});
const echartsStyle = computed(() => {
return props.width || props.height
? { height: props.height + "px", width: props.width + "px" }
: { height: "100%", width: "100%" };
});
const chartRef = ref<HTMLDivElement | HTMLCanvasElement>();
const chartInstance = ref<EChartsType>();
const draw = () => {
if (chartInstance.value) {
chartInstance.value.setOption(props.option, { notMerge: true });
}
};
watch(props, () => {
draw();
});
const handleClick = (event: ECElementEvent) => props.onClick && props.onClick(event);
const init = () => {
if (!chartRef.value) return;
chartInstance.value = echarts.getInstanceByDom(chartRef.value);
if (!chartInstance.value) {
chartInstance.value = markRaw(
echarts.init(chartRef.value, props.theme, {
renderer: props.renderer
})
);
chartInstance.value.on("click", handleClick);
draw();
}
};
const resize = () => {
if (chartInstance.value && props.resize) {
chartInstance.value.resize({ animation: { duration: 300 } });
}
};
const debouncedResize = useDebounceFn(resize, 300, { maxWait: 800 });
// const globalStore = useGlobalStore();
// const { maximize, isCollapse, tabs, footer } = storeToRefs(globalStore);
// watch(
// () => [maximize, isCollapse, tabs, footer],
// () => {
// debouncedResize();
// },
// { deep: true }
// );
onMounted(() => {
nextTick(() => init());
window.addEventListener("resize", debouncedResize);
});
onActivated(() => {
if (chartInstance.value) {
chartInstance.value.resize();
}
});
onBeforeUnmount(() => {
chartInstance.value?.dispose();
window.removeEventListener("resize", debouncedResize);
});
defineExpose({
getInstance: () => chartInstance.value,
resize,
draw
});
</script>
@@ -0,0 +1,45 @@
import { ElLoading } from "element-plus";
/* 全局请求 loading */
let loadingInstance: ReturnType<typeof ElLoading.service>;
/**
* @description 开启 Loading
* */
const startLoading = () => {
loadingInstance = ElLoading.service({
fullscreen: true,
lock: true,
text: "Loading",
background: "rgba(0, 0, 0, 0.7)"
});
};
/**
* @description 结束 Loading
* */
const endLoading = () => {
loadingInstance.close();
};
/**
* @description 显示全屏加载
* */
let needLoadingRequestCount = 0;
export const showFullScreenLoading = () => {
if (needLoadingRequestCount === 0) {
startLoading();
}
needLoadingRequestCount++;
};
/**
* @description 隐藏全屏加载
* */
export const tryHideFullScreenLoading = () => {
if (needLoadingRequestCount <= 0) return;
needLoadingRequestCount--;
if (needLoadingRequestCount === 0) {
endLoading();
}
};
@@ -0,0 +1,67 @@
.loading-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 98px;
}
}
.dot {
position: relative;
box-sizing: border-box;
display: inline-block;
width: 32px;
height: 32px;
font-size: 32px;
transform: rotate(45deg);
animation: ant-rotate 1.2s infinite linear;
}
.dot i {
position: absolute;
display: block;
width: 14px;
height: 14px;
background-color: var(--el-color-primary);
border-radius: 100%;
opacity: 0.3;
transform: scale(0.75);
transform-origin: 50% 50%;
animation: ant-spin-move 1s infinite linear alternate;
}
.dot i:nth-child(1) {
top: 0;
left: 0;
}
.dot i:nth-child(2) {
top: 0;
right: 0;
animation-delay: 0.4s;
}
.dot i:nth-child(3) {
right: 0;
bottom: 0;
animation-delay: 0.8s;
}
.dot i:nth-child(4) {
bottom: 0;
left: 0;
animation-delay: 1.2s;
}
@keyframes ant-rotate {
to {
transform: rotate(405deg);
}
}
@keyframes ant-spin-move {
to {
opacity: 1;
}
}
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="loading-box">
<div class="loading-wrap">
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
</div>
</div>
</template>
<script setup lang="ts" name="Loading"></script>
<style scoped lang="scss">
@import "./index.scss";
</style>
@@ -0,0 +1,308 @@
<template>
<Transition name="fade">
<div class="loading-overlay" v-if="visible">
<div class="loading-content">
<!-- 3D 旋转立方体动画 -->
<div class="cube-wrapper">
<div class="cube">
<div class="cube-face front"></div>
<div class="cube-face back"></div>
<div class="cube-face right"></div>
<div class="cube-face left"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
</div>
</div>
<!-- 进度信息 -->
<div class="progress-section">
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${progress}%` }">
<div class="progress-glow"></div>
</div>
</div>
<div class="progress-text">{{ progress }}%</div>
</div>
<!-- 状态信息 -->
<div class="status-section">
<span class="status-icon" :class="statusClass">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<span class="status-message">{{ message }}</span>
</div>
<!-- 详细信息 -->
<div class="detail-section" v-if="fileName">
<span class="file-name">{{ fileName }}</span>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
visible: boolean
progress: number
message: string
status: 'idle' | 'uploading' | 'parsing' | 'success' | 'error'
fileName?: string
}>(), {
visible: false,
progress: 0,
message: '',
status: 'idle',
fileName: ''
})
const statusClass = computed(() => {
return {
'status-uploading': props.status === 'uploading',
'status-parsing': props.status === 'parsing',
'status-success': props.status === 'success',
'status-error': props.status === 'error'
}
})
</script>
<style lang="scss" scoped>
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(245, 245, 245, 0.95);
backdrop-filter: blur(4px);
z-index: 100;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 32px;
}
// 3D 立方体动画
.cube-wrapper {
width: 60px;
height: 60px;
perspective: 200px;
}
.cube {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
animation: rotateCube 3s infinite ease-in-out;
}
.cube-face {
position: absolute;
width: 60px;
height: 60px;
border: 2px solid rgba(64, 158, 255, 0.6);
background: rgba(64, 158, 255, 0.1);
box-shadow: inset 0 0 20px rgba(64, 158, 255, 0.2);
}
.front {
transform: translateZ(30px);
}
.back {
transform: rotateY(180deg) translateZ(30px);
}
.right {
transform: rotateY(90deg) translateZ(30px);
}
.left {
transform: rotateY(-90deg) translateZ(30px);
}
.top {
transform: rotateX(90deg) translateZ(30px);
}
.bottom {
transform: rotateX(-90deg) translateZ(30px);
}
@keyframes rotateCube {
0%,
100% {
transform: rotateX(-20deg) rotateY(0deg);
}
25% {
transform: rotateX(-20deg) rotateY(90deg);
}
50% {
transform: rotateX(-20deg) rotateY(180deg);
}
75% {
transform: rotateX(-20deg) rotateY(270deg);
}
}
// 进度条
.progress-section {
width: 240px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.progress-bar-container {
width: 100%;
height: 6px;
background: #e4e7ed;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a);
border-radius: 3px;
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.progress-glow {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: progressGlow 1.5s infinite;
}
@keyframes progressGlow {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.progress-text {
font-size: 14px;
font-weight: 600;
color: #409eff;
}
// 状态信息
.status-section {
display: flex;
align-items: center;
gap: 8px;
}
.status-icon {
display: flex;
gap: 4px;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #909399;
animation: dotPulse 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
&:nth-child(3) {
animation-delay: 0s;
}
}
&.status-uploading .dot {
background: #409eff;
}
&.status-parsing .dot {
background: #e6a23c;
}
&.status-success .dot {
background: #67c23a;
animation: none;
}
&.status-error .dot {
background: #f56c6c;
animation: none;
}
}
@keyframes dotPulse {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.status-message {
font-size: 14px;
color: #606266;
}
// 文件名
.detail-section {
.file-name {
font-size: 12px;
color: #909399;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// 过渡动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,261 @@
<!--
画线测量面板
显示测量列表支持删除单条/清空全部
-->
<template>
<Teleport to="body">
<Transition name="measure-panel">
<div v-show="visible" class="measure-panel" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<!-- 标题栏可拖拽 -->
<div class="panel-header" @mousedown="startDrag">
<span class="panel-title">📏 测量列表</span>
<span class="panel-count" v-if="store.lineMeasurements.length">
{{ store.lineMeasurements.length }}
</span>
<el-button size="small" text @click="$emit('close')"></el-button>
</div>
<!-- 测量列表 -->
<div class="panel-body">
<div v-if="!store.lineMeasurements.length" class="empty-hint">
<p>暂无测量</p>
<p class="hint">在画线模式下点击模型表面添加测量线</p>
</div>
<div v-else class="measure-list">
<div v-for="line in store.lineMeasurements" :key="line.id" class="measure-item">
<div class="item-header">
<span class="item-distance">{{ formatDistance(line.distance) }}</span>
<el-button size="small" text type="danger" @click="handleRemove(line.id)" title="删除">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</el-button>
</div>
<div class="item-coords">
<span class="coord-label">起点</span>
<span class="coord-value">{{ formatCoord(line.start) }}</span>
</div>
<div class="item-coords">
<span class="coord-label">终点</span>
<span class="coord-value">{{ formatCoord(line.end) }}</span>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="panel-footer" v-if="store.lineMeasurements.length">
<el-button size="small" type="danger" plain @click="handleClearAll">
清空全部
</el-button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import * as THREE from 'three'
import { useStepViewerStore } from '../stores/useStepViewerStore'
const store = useStepViewerStore()
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'remove', id: string): void
(e: 'clear-all'): void
}>()
// 面板位置
const pos = reactive({ x: 340, y: 80 })
function formatDistance(mm: number): string {
if (mm >= 1000) return `${(mm / 1000).toFixed(3)} m`
if (mm >= 10) return `${mm.toFixed(2)} mm`
return `${mm.toFixed(3)} mm`
}
function formatCoord(v: THREE.Vector3): string {
return `(${v.x.toFixed(2)}, ${v.y.toFixed(2)}, ${v.z.toFixed(2)})`
}
function handleRemove(id: string): void {
emit('remove', id)
}
function handleClearAll(): void {
emit('clear-all')
}
// ——— 面板拖拽移动 ———
function startDrag(e: MouseEvent): void {
if ((e.target as HTMLElement).closest('button, .el-button')) return
e.preventDefault()
const startX = e.clientX - pos.x
const startY = e.clientY - pos.y
const onMove = (ev: MouseEvent) => {
pos.x = Math.max(0, ev.clientX - startX)
pos.y = Math.max(0, ev.clientY - startY)
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.userSelect = ''
}
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
</script>
<style scoped lang="scss">
.measure-panel {
position: fixed;
display: flex;
flex-direction: column;
width: 280px;
max-height: 60vh;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid #e4e7ed;
background: #fafafa;
flex-shrink: 0;
cursor: move;
user-select: none;
.panel-title {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.panel-count {
font-size: 12px;
color: #909399;
}
}
.panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 12px;
color: #909399;
font-size: 13px;
p {
margin: 2px 0;
}
.hint {
font-size: 12px;
color: #c0c4cc;
text-align: center;
}
}
.measure-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.measure-item {
padding: 8px 10px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fafafa;
transition: border-color 0.15s;
&:hover {
border-color: #409eff;
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.item-distance {
font-size: 14px;
font-weight: 600;
color: #409eff;
}
.item-coords {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #606266;
line-height: 1.6;
}
.coord-label {
flex-shrink: 0;
color: #909399;
width: 28px;
}
.coord-value {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
}
}
.panel-footer {
display: flex;
justify-content: flex-end;
padding: 6px 10px;
border-top: 1px solid #ebeef5;
flex-shrink: 0;
}
/* 入场/退场过渡 */
.measure-panel-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.measure-panel-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.measure-panel-enter-from {
opacity: 0;
transform: translateY(-12px) scale(0.96);
}
.measure-panel-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
</style>
@@ -0,0 +1,423 @@
<!--
模型结构树组件虚拟滚动版
仿 SolidWorks 特征管理器 FeatureManager
支持 Compound Solid Edge 层级
双向联动 + 边缘描边高亮
使用 el-tree-v2 虚拟化支持万级节点
-->
<template>
<div class="model-tree">
<div class="tree-header">
<span class="tree-title">模型结构</span>
<span v-if="store.hasModel" class="tree-count">{{ store.treeNodeCount }} </span>
</div>
<div v-if="!store.hasModel" class="tree-empty">
<p>暂无模型</p>
<p class="hint">请上传 STEP 文件</p>
</div>
<div v-else class="tree-content" ref="treeContainerRef">
<el-tree-v2 ref="treeRef" :data="store.treeNodes" :props="treeProps" :height="treeHeight" :item-size="28"
:indent="24" :default-expanded-keys="store.expandedTreeNodeIds" :highlight-current="true"
:expand-on-click-node="false" :current-node-key="currentNodeKey" @node-click="handleNodeClick"
@node-expand="handleNodeExpand" @node-collapse="handleNodeCollapse">
<template #default="{ data }">
<div class="tree-node" :class="{
'is-selected': store.selectedTreeNodeIdSet.has(data.id),
'is-solid': data.type === 'solid',
'is-edge': data.type === 'edge',
'is-compound': data.type === 'compound' || data.type === 'root'
}" @mouseenter="handleNodeMouseEnter(data)" @mouseleave="handleNodeMouseLeave">
<span class="node-icon">{{ getNodeIcon(data) }}</span>
<span class="node-label" :title="data.name">{{ data.name }}</span>
<span v-if="data.children && data.children.length" class="node-count">
({{ data.children.length }})
</span>
<!-- Solid 节点显示/隐藏切换 -->
<span v-if="data.type === 'solid'" class="node-visibility" :class="{ 'is-hidden': !isSolidVisible(data) }"
@click.stop="handleToggleVisibility(data)" :title="isSolidVisible(data) ? '隐藏' : '显示'">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<template v-if="isSolidVisible(data)">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</template>
</svg>
</span>
</div>
</template>
</el-tree-v2>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import type { TreeNode } from '../types'
import { useStepViewerStore } from '../stores/useStepViewerStore'
const store = useStepViewerStore()
// 事件
const emit = defineEmits<{
(e: 'select', node: TreeNode, multi: boolean): void
(e: 'solidHover', solidId: string | null): void
(e: 'toggleSolidVisibility', solidId: string): void
}>()
const treeRef = ref()
const treeContainerRef = ref<HTMLElement>()
const treeHeight = ref(Math.max(200, Math.floor(window.innerHeight * 0.7 - 80)))
let selectionFromTree = false
const treeProps = {
children: 'children',
label: 'name',
value: 'id'
}
/** 当前高亮的节点 key — 优先 solid_N 格式(树中一定存在),避免 face ID 无法匹配 */
const currentNodeKey = computed(() => {
const ids = store.selectedTreeNodeIds
return ids.find(id => /^solid_\d+$/.test(id))
|| ids.find(id => id.includes('_edge_'))
|| ids[0]
|| ''
})
/**
* 获取节点图标
*/
function getNodeIcon(data: TreeNode): string {
switch (data.type) {
case 'root': return '📦'
case 'compound': return '📁'
case 'solid': return '🧊'
case 'shell': return '🔲'
case 'edge': return getEdgeTypeIcon(data.name)
default: return '📄'
}
}
function getEdgeTypeIcon(name: string): string {
if (name.includes('线段') || name.includes('直线')) return ''
if (name.includes('圆弧') || name.includes('圆')) return '➰'
if (name.includes('椰圆') || name.includes('椭圆')) return '⬭️'
if (name.includes('B样条') || name.includes('B-Spline')) return '〰️'
if (name.includes('Bezier') || name.includes('贝塞尔')) return '〰️'
return '—'
}
/**
* 处理节点点击 — el-tree-v2 签名: (data, node, e)
*/
function handleNodeClick(data: any, _node: any, e: MouseEvent): void {
const node = data as TreeNode
const multi = e?.ctrlKey || e?.shiftKey || false
// 标记本次选择来自树,watcher 中不需要 scrollTo
selectionFromTree = true
emit('select', node, multi)
}
/**
* 处理节点展开/折叠
*/
function handleNodeExpand(data: any): void {
const node = data as TreeNode
if (!store.expandedTreeNodeIds.includes(node.id)) {
store.expandedTreeNodeIds.push(node.id)
}
}
function handleNodeCollapse(data: any): void {
const node = data as TreeNode
const idx = store.expandedTreeNodeIds.indexOf(node.id)
if (idx >= 0) {
store.expandedTreeNodeIds.splice(idx, 1)
}
}
// ========== Hover & Visibility ==========
let hoveredSolidId: string | null = null
let hoverRafId = 0
function handleNodeMouseEnter(data: any): void {
const node = data as TreeNode
if (node.type !== 'solid' || node.solidIndex === undefined) {
// 非 solid 节点,清除 hover
if (hoveredSolidId !== null) {
hoveredSolidId = null
cancelAnimationFrame(hoverRafId)
emit('solidHover', null)
}
return
}
const solidId = `solid_${node.solidIndex}`
if (solidId === hoveredSolidId) return
hoveredSolidId = solidId
cancelAnimationFrame(hoverRafId)
hoverRafId = requestAnimationFrame(() => {
emit('solidHover', hoveredSolidId)
})
}
function handleNodeMouseLeave(): void {
if (hoveredSolidId !== null) {
hoveredSolidId = null
cancelAnimationFrame(hoverRafId)
emit('solidHover', null)
}
}
function handleToggleVisibility(data: any): void {
const node = data as TreeNode
if (node.type !== 'solid' || node.solidIndex === undefined) return
const solidId = `solid_${node.solidIndex}`
emit('toggleSolidVisibility', solidId)
}
function isSolidVisible(data: any): boolean {
const node = data as TreeNode
if (node.solidIndex === undefined) return true
return store.isSolidVisible(`solid_${node.solidIndex}`)
}
/**
* 查找目标节点的所有祖先节点 ID(用于确保展开路径)
*/
function findAncestorIds(targetId: string): string[] {
const ancestors: string[] = []
const find = (nodes: TreeNode[], path: string[]): boolean => {
for (const node of nodes) {
if (node.id === targetId) {
ancestors.push(...path)
return true
}
if (node.children) {
path.push(node.id)
if (find(node.children, path)) return true
path.pop()
}
}
return false
}
find(store.treeNodes, [])
return ancestors
}
function handleWindowResize() {
nextTick(() => {
if (treeContainerRef.value) {
const h = treeContainerRef.value.clientHeight
if (h > 100) treeHeight.value = h
}
})
}
onMounted(() => {
nextTick(() => {
if (treeContainerRef.value) {
const h = treeContainerRef.value.clientHeight
if (h > 100) treeHeight.value = h
}
})
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
})
/**
* 监听 3D 侧选中变化,同步高亮 + 滚动到对应树节点
*/
watch(() => store.selectedTreeNodeIds, async (ids) => {
if (!ids.length || !treeRef.value) {
selectionFromTree = false
return
}
if (selectionFromTree) {
selectionFromTree = false
return
}
// 选择滚动目标:
// 树中只有 solid_N 和 solid_N_edge_M 两种节点,没有 solid_N_face_M
// 3D 点击面时 ids 可能包含 face ID(如 solid_5_face_0),必须跳过
let scrollTarget = ids.find(id => id.includes('_edge_'))
if (!scrollTarget) {
scrollTarget = ids.find(id => /^solid_\d+$/.test(id))
if (!scrollTarget) scrollTarget = ids[0]
}
// 展开目标节点的所有祖先
const ancestors = findAncestorIds(scrollTarget)
for (const id of ancestors) {
if (!store.expandedTreeNodeIds.includes(id)) {
store.expandedTreeNodeIds.push(id)
}
}
// 强制同步 el-tree-v2 内部展开状态
treeRef.value.setExpandedKeys([...store.expandedTreeNodeIds])
// 等待虚拟列表完成重算
await nextTick()
await nextTick()
// 使用官方 scrollToNode API 滚动到目标
treeRef.value.scrollToNode(scrollTarget, 'center')
}, { flush: 'post' })
</script>
<style scoped lang="scss">
.model-tree {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
font-size: 13px;
user-select: none;
}
.tree-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--el-border-color-lighter, #e4e7ed);
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary, #303133);
.tree-title {
flex: 1;
}
.tree-count {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary, #909399);
}
}
.tree-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 16px;
color: var(--el-text-color-secondary, #909399);
p {
margin: 4px 0;
}
.hint {
font-size: 12px;
color: var(--el-text-color-placeholder, #c0c4cc);
}
}
.tree-content {
flex: 1;
overflow: hidden;
}
.tree-node {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 12px 2px 6px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
width: 100%;
min-width: 0;
&.is-selected {
background-color: rgba(64, 158, 255, 0.15);
}
.node-icon {
flex-shrink: 0;
font-size: 14px;
width: 18px;
text-align: center;
}
.node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 1.6;
}
.node-count {
flex-shrink: 0;
font-size: 11px;
color: var(--el-text-color-placeholder, #c0c4cc);
margin-left: 2px;
}
.node-visibility {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #909399;
opacity: 0.5;
margin-left: 4px;
padding: 2px;
border-radius: 3px;
transition: opacity 0.15s, color 0.15s, background-color 0.15s;
&:hover {
background-color: rgba(64, 158, 255, 0.1);
color: #409eff;
opacity: 1;
}
&.is-hidden {
opacity: 0.6;
color: #c0c4cc;
}
}
&:hover .node-visibility {
opacity: 1;
}
}
// 覆盖 el-tree-v2 默认样式
:deep(.el-tree) {
background: transparent;
--el-tree-node-hover-bg-color: transparent;
}
:deep(.el-tree-node__content) {
height: 28px;
}
:deep(.el-tree-node__expand-icon) {
font-size: 14px;
padding: 3px;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background-color: transparent;
}
</style>
@@ -0,0 +1,177 @@
<!--
模型结构树浮动面板
可拖拽移动关闭
-->
<template>
<Teleport to="body">
<Transition name="model-tree-panel">
<div v-show="visible" class="model-tree-panel-overlay" ref="panelRef"
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px', width: panelWidth + 'px' }">
<!-- 拖拽标题栏 -->
<div class="panel-header" @mousedown="startDrag">
<span class="panel-title">模型结构</span>
<el-button size="small" text @click="$emit('close')"></el-button>
</div>
<ModelTree @select="handleTreeSelect" @solid-hover="handleSolidHover"
@toggle-solid-visibility="handleToggleSolidVisibility" />
<!-- 拖拽调整宽度 -->
<div class="resize-handle" @mousedown.prevent="startResize" />
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { TreeNode } from '../types'
import ModelTree from './ModelTree.vue'
defineProps<{
visible: boolean
}>()
//
const emit = defineEmits<{
(e: 'tree-select', node: TreeNode, multi: boolean): void
(e: 'solid-hover', solidId: string | null): void
(e: 'toggle-solid-visibility', solidId: string): void
(e: 'close'): void
}>()
//
const panelRef = ref<HTMLElement>()
const panelWidth = ref(300)
const RIGHT_COLUMN_WIDTH = 160
const PROPERTY_PANEL_WIDTH = 300
const PANEL_TOP_OFFSET = 80
const PANEL_EDGE_GAP = 12
function getDefaultPanelX(width: number): number {
const x = window.innerWidth - RIGHT_COLUMN_WIDTH - PROPERTY_PANEL_WIDTH - width - PANEL_EDGE_GAP
return Math.max(PANEL_EDGE_GAP, x)
}
const panelPos = reactive({ x: getDefaultPanelX(panelWidth.value), y: PANEL_TOP_OFFSET })
function handleTreeSelect(node: TreeNode, multi: boolean): void {
emit('tree-select', node, multi)
}
function handleSolidHover(solidId: string | null): void {
emit('solid-hover', solidId)
}
function handleToggleSolidVisibility(solidId: string): void {
emit('toggle-solid-visibility', solidId)
}
//
function startDrag(e: MouseEvent): void {
if ((e.target as HTMLElement).closest('button, .el-button')) return
e.preventDefault()
const startX = e.clientX - panelPos.x
const startY = e.clientY - panelPos.y
const onMove = (ev: MouseEvent) => {
panelPos.x = Math.max(0, ev.clientX - startX)
panelPos.y = Math.max(0, ev.clientY - startY)
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.userSelect = ''
}
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
//
function startResize(e: MouseEvent): void {
const startX = e.clientX
const startWidth = panelWidth.value
const onMove = (ev: MouseEvent) => {
panelWidth.value = Math.max(220, Math.min(500, startWidth + ev.clientX - startX))
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
</script>
<style scoped lang="scss">
.model-tree-panel-overlay {
position: fixed;
display: flex;
flex-direction: column;
height: 70vh;
max-height: 80vh;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #e4e7ed;
background: #fafafa;
flex-shrink: 0;
cursor: move;
user-select: none;
.panel-title {
font-size: 13px;
font-weight: 600;
color: #303133;
}
}
.resize-handle {
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
cursor: col-resize;
z-index: 10;
&:hover {
background: rgba(64, 158, 255, 0.4);
}
}
/* 入场/退场过渡 */
.model-tree-panel-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.model-tree-panel-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.model-tree-panel-enter-from {
opacity: 0;
transform: translateY(-12px) scale(0.96);
}
.model-tree-panel-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
</style>
@@ -0,0 +1,189 @@
<template>
<div class="stats-panel" v-if="visible">
<div class="stats-row">
<span class="stats-label">FPS</span>
<span class="stats-value" :class="fpsClass">{{ fps }}</span>
</div>
<div class="stats-row">
<span class="stats-label">渲染</span>
<span class="stats-value">{{ renderTime }}ms</span>
</div>
<div class="stats-row" v-if="memoryInfo">
<span class="stats-label">内存</span>
<span class="stats-value">{{ memoryInfo }}</span>
</div>
<div class="stats-row">
<span class="stats-label">三角形</span>
<span class="stats-value">{{ formatNumber(triangles) }}</span>
</div>
<div class="stats-row">
<span class="stats-label">顶点</span>
<span class="stats-value">{{ formatNumber(vertices) }}</span>
</div>
<div class="stats-row">
<span class="stats-label">Draw Calls</span>
<span class="stats-value">{{ drawCalls }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
const props = withDefaults(defineProps<{
visible: boolean
/** 场景总三角形数(父组件传入) */
triangles?: number
/** 场景总顶点数(父组件传入) */
vertices?: number
/** 每帧 Draw Calls(父组件传入) */
drawCalls?: number
}>(), {
triangles: 0,
vertices: 0,
drawCalls: 0
})
// RAF
const fps = ref(0)
const renderTime = ref(0)
// FPS
let frameCount = 0
let lastTime = performance.now()
let lastFrameTime = performance.now()
let animationId: number | null = null
// FPS
const fpsClass = computed(() => {
if (fps.value >= 55) return 'fps-good'
if (fps.value >= 30) return 'fps-ok'
return 'fps-bad'
})
//
const memoryInfo = computed(() => {
if (!('memory' in performance)) return null
const mem = (performance as any).memory
if (!mem) return null
const usedMB = Math.round(mem.usedJSHeapSize / 1024 / 1024)
return `${usedMB} MB`
})
//
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
//
function updateStats() {
frameCount++
const now = performance.now()
//
renderTime.value = Math.round(now - lastFrameTime)
lastFrameTime = now
// FPS
if (now - lastTime >= 1000) {
fps.value = Math.round((frameCount * 1000) / (now - lastTime))
frameCount = 0
lastTime = now
}
animationId = requestAnimationFrame(updateStats)
}
//
function startMonitoring() {
if (animationId !== null) return
lastTime = performance.now()
lastFrameTime = performance.now()
frameCount = 0
animationId = requestAnimationFrame(updateStats)
}
//
function stopMonitoring() {
if (animationId !== null) {
cancelAnimationFrame(animationId)
animationId = null
}
}
// visible
watch(() => props.visible, (visible) => {
if (visible) {
startMonitoring()
} else {
stopMonitoring()
}
}, { immediate: true })
onMounted(() => {
if (props.visible) {
startMonitoring()
}
})
onUnmounted(() => {
stopMonitoring()
})
</script>
<style lang="scss" scoped>
.stats-panel {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
padding: 8px 10px;
border-radius: 4px;
min-width: 100px;
z-index: 100;
pointer-events: none;
user-select: none;
}
.stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0;
&:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
}
.stats-label {
color: rgba(255, 255, 255, 0.7);
margin-right: 12px;
}
.stats-value {
font-weight: bold;
text-align: right;
}
.fps-good {
color: #4caf50;
}
.fps-ok {
color: #ff9800;
}
.fps-bad {
color: #f44336;
}
</style>
@@ -0,0 +1,946 @@
<template>
<div class="step-viewer" ref="viewerRef">
<!-- 工具栏 -->
<Toolbar :file-name="store.currentFileName" :is-loading="store.isLoading" :has-model="store.hasModel"
:has-selection="hasAnySelection" :show-axes="store.showAxes" :show-grid="store.showGrid" :show-stats="showStats"
:occt-ready="occtReady" :occt-load-progress="occtLoadProgress" :is-line-measure-active="store.isLineMeasureActive"
:opacity="opacityPercent" :is-model-tree-open="modelTreeVisible" @upload="handleFileUpload"
@fit-view="handleFitView" @toggle-axes="handleToggleAxes" @toggle-grid="handleToggleGrid"
@opacity-change="handleOpacityChange" @clear-selection="handleClearSelection" @reset-view="handleResetView"
@toggle-stats="handleToggleStats" @toggle-line-measure="handleToggleLineMeasure"
@toggle-model-tree="modelTreeVisible = !modelTreeVisible" />
<!-- 主内容区域 -->
<div class="viewer-content">
<!-- 左侧 URDF 结构树固定面板 -->
<URDFLeftPanel v-if="store.hasModel" ref="urdfLeftPanelRef" @export-urdf="handleExportURDF" />
<!-- 模型结构树浮动面板 -->
<SidePanel :visible="modelTreeVisible" @tree-select="handleTreeSelect" @solid-hover="handleSolidHover"
@toggle-solid-visibility="handleToggleSolidVisibility" @close="modelTreeVisible = false" />
<!-- 测量面板浮动面板 -->
<MeasurementPanel :visible="measurePanelVisible" @remove="handleRemoveMeasurement"
@clear-all="handleClearMeasurements" @close="measurePanelVisible = false" />
<!-- 3D 画布 -->
<div class="canvas-container" ref="canvasContainerRef">
<!-- 性能监控面板 -->
<StatsPanel :visible="showStats" :triangles="modelTriangles" :vertices="modelVertices"
:draw-calls="frameDrawCalls" ref="statsPanelRef" />
<!-- 加载进度动画 -->
<LoadingOverlay :visible="store.isLoading" :progress="store.uploadProgress.progress"
:message="store.uploadProgress.message" :status="store.uploadProgress.status"
:file-name="store.currentFileName" />
<!-- 绑定模式提示 -->
<div class="binding-overlay" v-if="urdfStore.bindingMode.active">
<el-tag type="warning" effect="dark">
点击 3D 场景中的 Solid 绑定到 Link
<el-button size="small" text style="color: #fff" @click="urdfStore.stopBindingMode()">完成</el-button>
</el-tag>
</div>
<!-- 导出进度提示 -->
<div class="binding-overlay" v-if="urdfStore.exporting">
<el-tag type="info" effect="dark">
{{ urdfStore.exportProgress || '正在导出...' }}
</el-tag>
</div>
</div>
<!-- 右侧属性面板 -->
<URDFRightPanel v-if="store.hasModel" @flip-normal="urdfScene.flipNormal"
@toggle-f-k-panel="handleToggleFKPanel" />
</div>
<!-- 浮动关节控制面板 -->
<FloatingJointControl :visible="fkPanelVisible" @close="fkPanelVisible = false" />
<!-- Joint 创建向导 -->
<JointWizard ref="jointWizardRef" @created="urdfScene.handleJointCreated"
@start-edge-pick="urdfScene.startEdgePickMode" @stop-edge-pick="urdfScene.stopEdgePickMode"
@flip-normal="urdfScene.flipNormal" />
<!-- 状态栏 -->
<div class="status-bar">
<template v-if="store.hasModel">
<span class="status-item">实体: <b>{{ store.solids.length }}</b></span>
<span class="status-sep">|</span>
<span class="status-item">URDF: <b>{{ urdfStore.robot.name }}</b></span>
<span class="status-sep">|</span>
<span class="status-item">Links: <b>{{ urdfStore.robot.links.length }}</b></span>
<span class="status-sep">|</span>
<span class="status-item">Joints: <b>{{ urdfStore.robot.joints.length }}</b></span>
<template v-if="store.selectedSolidNames.length">
<span class="status-sep">|</span>
<span class="status-item status-selected">{{ store.selectedSolidNames.join(', ') }}</span>
</template>
</template>
<span v-else class="status-item">{{ occtReady ? '就绪 — 支持 .step / .stp 文件' : '正在加载 OpenCASCADE...' }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import * as THREE from 'three'
import { ElMessage } from 'element-plus'
import Toolbar from './Toolbar.vue'
import SidePanel from './SidePanel.vue'
import MeasurementPanel from './MeasurementPanel.vue'
import StatsPanel from './StatsPanel.vue'
import LoadingOverlay from './LoadingOverlay.vue'
import URDFLeftPanel from './URDFBuilder/URDFLeftPanel.vue'
import URDFRightPanel from './URDFBuilder/URDFRightPanel.vue'
import FloatingJointControl from './URDFBuilder/FloatingJointControl.vue'
import JointWizard from './URDFBuilder/JointWizard.vue'
import { useStepViewerStore } from '../stores/useStepViewerStore'
import { useURDFStore } from '../stores/useURDFStore'
import {
StepLoader,
SceneManager,
SelectionManager,
preloadOcct,
isOcctLoaded
} from '../core'
import { LineMeasurementTool } from '../core/LineMeasurementTool'
import { disposeKinematicsWorker } from '../core/useKinematicsWorker'
import { useURDFScene } from './composables/useURDFScene'
import type { GeometryFeature, TreeNode } from '../types'
import { FeatureType, ViewPreset } from '../types'
// Props
const props = withDefaults(defineProps<{
width?: string | number
height?: string | number
backgroundColor?: number
showStatsPanel?: boolean
}>(), {
width: '100%',
height: '100%',
backgroundColor: 0xf5f5f5,
showStatsPanel: false
})
// Store
const store = useStepViewerStore()
const urdfStore = useURDFStore()
// Refs
const viewerRef = ref<HTMLElement>()
const canvasContainerRef = ref<HTMLElement>()
const statsPanelRef = ref<InstanceType<typeof StatsPanel>>()
//
const showStats = ref(props.showStatsPanel)
//
const modelTriangles = ref(0)
const modelVertices = ref(0)
const frameDrawCalls = ref(0)
// OCCT
const occtReady = ref(isOcctLoaded())
/** 模拟 WASM 加载进度 0100 */
const occtLoadProgress = ref(isOcctLoaded() ? 100 : 0)
//
const fkPanelVisible = ref(false)
function handleToggleFKPanel(): void {
fkPanelVisible.value = !fkPanelVisible.value
}
//
const modelTreeVisible = ref(false)
//
const measurePanelVisible = ref(false)
// 广
const noModelAdVisible = ref(true)
const exportCompleteAdVisible = ref(false)
//
let stepLoader: StepLoader | null = null
let sceneManager: SceneManager | null = null
let selectionManager: SelectionManager | null = null
let lineMeasurementTool: LineMeasurementTool | null = null
// URDF composable
const urdfScene = useURDFScene({
getSceneManager: () => sceneManager,
getSelectionManager: () => selectionManager,
})
const jointWizardRef = ref<InstanceType<typeof JointWizard>>()
const urdfLeftPanelRef = ref<{ setCurrentNodeById: (id: string) => void } | null>(null)
/** 防止 watcher 触发 3D highlight 时反向触发 onSelect 联动,导致循环选中 */
let isHighlightingFromWatcher = false
/** 是否有任何选中(3D 特征 / URDF 树选中 Link 或 Joint */
const hasAnySelection = computed(() =>
store.selectedFeatures.length > 0
|| !!urdfStore.selectedLinkId
|| !!urdfStore.selectedJointId
)
/**
* 统一计算当前应高亮的 Solid ID 列表
* 响应bindingMode / selectedLinkId / selectedJointId / link.solidIds 变化
*/
const effectiveHighlightSolidIds = computed<string[]>(() => {
// Link Solid
if (urdfStore.bindingMode.active && urdfStore.bindingMode.targetLinkId) {
const link = urdfStore.linkMap.get(urdfStore.bindingMode.targetLinkId)
return link?.solidIds.slice() ?? []
}
// Link Link Solid
if (urdfStore.selectedLinkId) {
const link = urdfStore.linkMap.get(urdfStore.selectedLinkId)
return link?.solidIds.slice() ?? []
}
// Joint parent + child Link Solid
if (urdfStore.selectedJointId) {
const joint = urdfStore.jointMap.get(urdfStore.selectedJointId)
if (joint) {
const parentLink = urdfStore.linkMap.get(joint.parentLinkId)
const childLink = urdfStore.linkMap.get(joint.childLinkId)
return [
...(parentLink?.solidIds ?? []),
...(childLink?.solidIds ?? [])
]
}
}
return []
})
//
const opacityPercent = computed(() => {
return Math.round(store.globalOpacity * 100)
})
// ========== ==========
onMounted(async () => {
await nextTick()
// OpenCASCADE WASM
let progressTimer: ReturnType<typeof setInterval> | null = null
if (!occtReady.value) {
occtLoadProgress.value = 5
progressTimer = setInterval(() => {
if (occtLoadProgress.value < 90) {
occtLoadProgress.value += Math.random() * 8 + 2
if (occtLoadProgress.value > 90) occtLoadProgress.value = 90
}
}, 600)
}
preloadOcct()
.then(() => {
if (progressTimer) clearInterval(progressTimer)
occtLoadProgress.value = 100
occtReady.value = true
console.log('OpenCASCADE WASM 预加载完成')
})
.catch(err => {
if (progressTimer) clearInterval(progressTimer)
occtLoadProgress.value = 0
console.error('OpenCASCADE 预加载失败:', err)
})
await initViewer()
// x/y/z f
window.addEventListener('keydown', handleViewShortcut)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleViewShortcut)
disposeViewer()
})
// ========== ==========
async function initViewer(): Promise<void> {
if (!canvasContainerRef.value) return
stepLoader = new StepLoader()
sceneManager = new SceneManager({
container: canvasContainerRef.value,
backgroundColor: props.backgroundColor,
showAxes: store.showAxes,
showGrid: store.showGrid
})
await sceneManager.waitForReady()
selectionManager = new SelectionManager({
camera: sceneManager.camera,
scene: sceneManager.scene,
domElement: sceneManager.getDomElement(),
controls: sceneManager.controls,
onRenderRequest: () => sceneManager?.requestRender()
})
// store线
selectionManager.onSelect((event) => {
// watcher
if (isHighlightingFromWatcher) return
const features = event.selections.map(s => s.feature)
// URDF Solid
if (urdfStore.bindingMode.active && features.length > 0) {
urdfScene.handleBindingClick(features[0])
return
}
// URDF JointWizard Joint
if (urdfScene.isEdgePickMode() && features.length > 0) {
const f = features[0]
const isAccepted = f.edgeCurveType === 'circle' || f.edgeCurveType === 'arc'
|| f.edgeCurveType === 'line' || f.type === FeatureType.CYLINDER
if (!isAccepted) {
if (f.edgeCurveType === 'bspline' || f.edgeCurveType === 'bezier') {
ElMessage.warning('不支持 B 样条/贝塞尔曲线,请选择圆弧边或直线')
} else {
ElMessage.warning('请选择圆弧边或直线作为旋转轴参考')
}
return
}
// Joint origin/axis
if (urdfStore.edgePickEditJointId) {
urdfScene.applyPickedEdgeToExistingJoint(urdfStore.edgePickEditJointId, f)
} else {
// JointWizard
jointWizardRef.value?.applyPickedEdge(f)
}
return
}
// URDF Base Pick Solid Base Origin
if (urdfStore.basePickMode && features.length > 0) {
const f = features[0]
let px = 0, py = 0, pz = 0
if (f.center) {
px = f.center.x; py = f.center.y; pz = f.center.z
} else if (f.solidId) {
const solid = store.solidMap.get(f.solidId)
const pos = solid?.serializedData?.positions
if (pos && pos.length >= 3) {
let sx = 0, sy = 0, sz = 0, n = 0
for (let i = 0; i < pos.length; i += 3) { sx += pos[i]; sy += pos[i + 1]; sz += pos[i + 2]; n++ }
if (n > 0) { px = sx / n; py = sy / n; pz = sz / n }
}
}
const round = (v: number) => Math.round(v * 10000) / 10000
urdfStore.baseLinkOrigin = [round(px), round(py), round(pz)]
urdfStore.basePickMode = false
urdfScene.updateFKAndFrames()
ElMessage.success('Base Origin 已设置')
return
}
store.setSelectedFeatures(features)
// 3D Solid Link
// isHighlightingFromWatcher true
if (!isHighlightingFromWatcher
&& !urdfStore.bindingMode.active
&& features.length > 0 && features[0].solidId) {
const solidId = features[0].solidId
const ownerLink = urdfStore.robot.links.find(l => l.solidIds.includes(solidId))
if (ownerLink) {
urdfStore.selectedLinkId = ownerLink.id
urdfStore.selectedJointId = null
nextTick(() => urdfLeftPanelRef.value?.setCurrentNodeById(ownerLink.id))
}
}
//
if (event.selectedTreeNodeIds) {
// solid
for (const id of event.selectedTreeNodeIds) {
const edgeMatch = id.match(/^(solid_\d+)_edge_\d+$/)
if (edgeMatch) {
const parentSolidId = edgeMatch[1]
if (!store.expandedTreeNodeIds.includes(parentSolidId)) {
store.expandedTreeNodeIds.push(parentSolidId)
}
}
}
store.syncTreeFromSelection(event.selectedTreeNodeIds)
}
urdfScene.updateFKAndFrames() // refresh after tree sync
sceneManager?.markDirty()
})
// Hover Snap Gizmo
selectionManager.onHover((feature) => {
urdfScene.handleHoverSnap(feature)
})
//
sceneManager.addRenderCallback(() => {
if (sceneManager) {
frameDrawCalls.value = sceneManager.frameDrawCalls
}
})
// ViewHelper 使 pointerup click ViewHelper API
const domElement = sceneManager.getDomElement()
domElement.addEventListener('pointerup', handleViewHelperClick)
// 线
lineMeasurementTool = new LineMeasurementTool({
scene: sceneManager.scene,
camera: sceneManager.camera,
domElement: sceneManager.getDomElement(),
container: canvasContainerRef.value,
controls: sceneManager.controls,
onRenderRequest: () => sceneManager?.requestRender(),
onLineAdded: (line) => {
store.addLineMeasurement(line)
sceneManager?.markDirty()
},
onLineRemoved: (id) => {
store.removeLineMeasurement(id)
sceneManager?.markDirty()
}
})
//
const resizeObserver = new ResizeObserver(() => {
if (canvasContainerRef.value && sceneManager) {
const { clientWidth, clientHeight } = canvasContainerRef.value
sceneManager.updateSize(clientWidth, clientHeight)
}
})
resizeObserver.observe(canvasContainerRef.value)
}
// ========== ==========
// x X+ X(Shift+x) X-
// y Y+ Y(Shift+y) Y-
// z Z+ Z(Shift+z) Z-
// f
function handleViewShortcut(e: KeyboardEvent): void {
//
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (!sceneManager) return
switch (e.key) {
case 'x': sceneManager.setViewPreset(ViewPreset.RIGHT); break // +X
case 'X': sceneManager.setViewPreset(ViewPreset.LEFT); break // -X
case 'y': sceneManager.setViewPreset(ViewPreset.TOP); break // +Y
case 'Y': sceneManager.setViewPreset(ViewPreset.BOTTOM); break // -Y
case 'z': sceneManager.setViewPreset(ViewPreset.FRONT); break // +Z
case 'Z': sceneManager.setViewPreset(ViewPreset.BACK); break // -Z
case 'f': sceneManager.setViewPreset(ViewPreset.ISOMETRIC); break //
default: return
}
}
function disposeViewer(): void {
// 线
if (lineMeasurementTool) {
lineMeasurementTool.dispose()
lineMeasurementTool = null
}
// ViewHelper
if (sceneManager) {
const domElement = sceneManager.getDomElement()
domElement.removeEventListener('pointerup', handleViewHelperClick)
}
// URDF
urdfScene.disposeModules()
disposeKinematicsWorker()
selectionManager?.dispose()
sceneManager?.dispose()
stepLoader = null
sceneManager = null
selectionManager = null
}
// ========== ViewHelper ==========
function handleViewHelperClick(event: PointerEvent): void {
if (sceneManager?.handleViewHelperClick(event)) {
// ViewHelper SelectionManager
event.stopPropagation()
}
}
// ========== ==========
async function handleFileUpload(file: File): Promise<void> {
if (!stepLoader) return
// OCCT
if (!occtReady.value) {
ElMessage.warning('OpenCASCADE 引擎正在加载,请稍候...')
return
}
const validation = stepLoader.validateFile(file)
if (!validation.valid) {
ElMessage.error(validation.error || '文件校验失败')
return
}
try {
handleClearAll()
store.setFileName(file.name)
store.updateUploadProgress({
status: 'parsing',
progress: 5,
message: '准备加载...'
})
const { solids, group, treeNodes } = await stepLoader.loadFile(file, (progress) => {
if (progress.status === 'success') {
store.updateUploadProgress({
status: 'parsing',
progress: 90,
message: '正在渲染模型...'
})
} else {
store.updateUploadProgress(progress)
}
})
//
if (sceneManager) {
sceneManager.addModel(group)
sceneManager.fitToModel()
}
// store
store.setSolids(solids)
store.setTreeNodes(treeNodes)
//
if (selectionManager) {
selectionManager.setSolids(solids)
// store 0.3
selectionManager.setOpacity(null, store.globalOpacity)
store.setTransparent(store.globalOpacity < 1)
}
//
modelTriangles.value = sceneManager?.sceneTriangles ?? 0
modelVertices.value = sceneManager?.sceneVertices ?? 0
await nextTick()
// +
if (sceneManager && canvasContainerRef.value) {
const { clientWidth, clientHeight } = canvasContainerRef.value
if (clientWidth > 0 && clientHeight > 0) {
sceneManager.updateSize(clientWidth, clientHeight)
}
sceneManager.fitToModel()
}
store.updateUploadProgress({
status: 'success',
progress: 100,
message: '加载完成'
})
// URDF
initURDFModules()
//
modelTreeVisible.value = true
ElMessage.success('模型加载成功')
} catch (error) {
console.error('加载失败:', error)
store.updateUploadProgress({
status: 'error',
progress: 0,
message: error instanceof Error ? error.message : '加载失败'
})
ElMessage.error(error instanceof Error ? error.message : '模型加载失败')
}
}
// ========== ==========
/**
* 从模型树选择节点 3D 高亮
* 使用 ID 查找而非数组索引避免 InstancedMesh 分组后顺序改变导致的错位
*/
function handleTreeSelect(node: TreeNode, multi: boolean): void {
if (!selectionManager) return
if (node.type === 'solid' && node.solidIndex !== undefined) {
const solidId = `solid_${node.solidIndex}`
const solid = store.solidMap.get(solidId)
if (solid) {
selectionManager.selectBySolidId(solid.id, multi)
}
} else if (node.type === 'edge' && node.solidIndex !== undefined && node.edgeIndex !== undefined) {
const solidId = `solid_${node.solidIndex}`
const solid = store.solidMap.get(solidId)
if (solid) {
selectionManager.selectByEdgeIndex(solid.id, node.edgeIndex, multi)
}
}
sceneManager?.markDirty()
}
// ========== ==========
function handleFitView(): void {
sceneManager?.fitToModel()
}
function handleToggleAxes(): void {
const newValue = !store.showAxes
store.setShowAxes(newValue)
sceneManager?.showAxes(newValue)
}
function handleToggleGrid(): void {
const newValue = !store.showGrid
store.setShowGrid(newValue)
sceneManager?.showGrid(newValue)
}
function handleOpacityChange(percent: number): void {
const opacity = percent / 100
store.setGlobalOpacity(opacity)
store.setTransparent(opacity < 1)
selectionManager?.setOpacity(null, opacity)
sceneManager?.markDirty()
}
function handleToggleStats(): void {
showStats.value = !showStats.value
}
function handleClearSelection(): void {
// /
if (urdfStore.bindingMode.active) {
ElMessage.warning('请先点击「 完成绑定」按钮,完成当前 Solid 绑定后再操作')
return
}
if (urdfStore.edgePickEditJointId) {
ElMessage.warning('请先点击「✕ 停止拾取」结束关节轴线拾取后再操作')
return
}
selectionManager?.clearSelection()
store.clearSelection()
//
urdfStore.selectedLinkId = null
urdfStore.selectedJointId = null
nextTick(() => urdfLeftPanelRef.value?.setCurrentNodeById(''))
}
function handleResetView(): void {
sceneManager?.fitToModel()
}
// ========== 线 ==========
function handleToggleLineMeasure(): void {
if (!lineMeasurementTool) return
const active = !store.isLineMeasureActive
store.setLineMeasureActive(active)
if (active) {
lineMeasurementTool.activate()
measurePanelVisible.value = true
// 线
selectionManager?.setEnabled(false)
} else {
lineMeasurementTool.deactivate()
measurePanelVisible.value = false
selectionManager?.setEnabled(true)
}
sceneManager?.markDirty()
}
function handleRemoveMeasurement(id: string): void {
lineMeasurementTool?.removeLine(id)
sceneManager?.markDirty()
}
function handleClearMeasurements(): void {
lineMeasurementTool?.clearAll()
store.clearLineMeasurements()
sceneManager?.markDirty()
}
function handleClearAll(): void {
handleClearSelection()
// 线
if (lineMeasurementTool) {
lineMeasurementTool.clearAll()
}
store.clearLineMeasurements()
if (store.isLineMeasureActive) {
store.setLineMeasureActive(false)
lineMeasurementTool?.deactivate()
selectionManager?.setEnabled(true)
}
// URDF bug
urdfStore.clearAll()
urdfScene.disposeModules()
sceneManager?.clearModels()
store.clearModel()
modelTriangles.value = 0
modelVertices.value = 0
frameDrawCalls.value = 0
}
function initURDFModules(): void {
urdfScene.initModules()
}
// ========== URDF ==========
async function handleExportURDF(): Promise<void> {
await urdfScene.handleExportURDF(exportCompleteAdVisible)
}
// ========== Watchers ==========
// currentValueoriginaxislimits FK
watch(
() => urdfStore.robot.joints,
() => {
urdfScene.updateFKAndFrames()
},
{ deep: true }
)
//
watch(() => urdfStore.showFrames, (val) => {
urdfScene.setFrameVisible(val)
sceneManager?.markDirty()
})
// link link FK
watch(
() => urdfStore.robot.links.length,
() => {
urdfScene.updateFKAndFrames()
}
)
// edgePickEditJointId StepViewer /退
watch(
() => urdfStore.edgePickEditJointId,
(id, oldId) => {
if (id && !urdfScene.isEdgePickMode()) {
urdfScene.startEdgePickMode()
} else if (!id && urdfScene.isEdgePickMode()) {
urdfScene.stopEdgePickMode()
}
}
)
// frame
watch(
() => urdfStore.axisHelperScale,
(scale) => {
urdfScene.setAxisLength(scale)
}
)
// Base Origin Base Frame
watch(
() => urdfStore.baseLinkOrigin,
() => {
urdfScene.updateFKAndFrames()
},
{ deep: true }
)
// Base Orientation Base Frame
watch(
() => urdfStore.baseLinkRPY,
() => {
urdfScene.updateFKAndFrames()
},
{ deep: true }
)
// Solid
// bindingMode selectedLinkId/selectedJointId link.solidIds
// watcher unbind
watch(
effectiveHighlightSolidIds,
(solidIds) => {
if (!selectionManager) return
isHighlightingFromWatcher = true
try {
selectionManager.clearSelection()
solidIds.forEach(sid => selectionManager!.selectBySolidId(sid, true))
} finally {
isHighlightingFromWatcher = false
}
// store.selectedFeatures Toolbar
store.setSelectedFeatures(selectionManager.getSelectedFeatures())
sceneManager?.markDirty()
}
)
// ========== Solid Hover / Visibility ==========
/** 模型树 hover solid → 3D 临时高亮 */
function handleSolidHover(solidId: string | null): void {
selectionManager?.hoverBySolidId(solidId)
sceneManager?.markDirty()
}
/** 模型树切换 solid 显示/隐藏 */
function handleToggleSolidVisibility(solidId: string): void {
store.toggleSolidVisibility(solidId)
const visible = store.isSolidVisible(solidId)
// SelectionManager 线
selectionManager?.setVisibility(solidId, visible)
sceneManager?.markDirty()
}
// solidVisibilityMap 3D clearModel
watch(
() => store.solidVisibilityMap.size,
() => {
for (const solid of store.solids) {
const visible = store.isSolidVisible(solid.id)
if (solid.mesh) {
solid.mesh.visible = visible
}
}
sceneManager?.markDirty()
}
)
//
defineExpose({
fitView: handleFitView,
clearSelection: handleClearSelection,
loadFile: handleFileUpload
})
</script>
<style lang="scss" scoped>
.step-viewer {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #fff;
overflow: hidden;
}
.viewer-content {
flex: 1;
display: flex;
overflow: hidden;
}
.canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background: #f5f5f5;
:deep(canvas) {
display: block;
}
}
.empty-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(245, 245, 245, 0.95);
z-index: 10;
}
.binding-overlay {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.empty-text {
color: #909399;
font-size: 14px;
margin: 0;
}
.status-bar {
display: flex;
align-items: center;
padding: 3px 12px;
font-size: 12px;
color: #606266;
background: #f5f5f5;
border-top: 1px solid #e4e7ed;
white-space: nowrap;
overflow: hidden;
gap: 0;
.status-item {
flex-shrink: 0;
b {
font-weight: 600;
color: #303133;
}
}
.status-sep {
margin: 0 6px;
color: #c0c4cc;
flex-shrink: 0;
}
.status-selected {
color: #409eff;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
}
</style>
@@ -0,0 +1,649 @@
<template>
<div class="step-viewer-toolbar">
<!-- 左侧侧栏切换 + 文件上传 -->
<div class="toolbar-left">
<div class="toolbar-section">
<el-tooltip :content="occtReady ? '选择并导入 STEP / STP 模型文件' : '正在加载 OpenCASCADE 引擎...'" placement="bottom">
<el-button type="primary" :loading="isLoading || !occtReady" :icon="UploadFilled"
:disabled="isLoading || !occtReady" @click="openUploadDialog">
{{ isLoading ? '加载中...' : (!occtReady ? '引擎加载中...' : '导入模型') }}
</el-button>
</el-tooltip>
<!-- WASM 加载进度条 -->
<div v-if="!occtReady" class="wasm-progress">
<el-progress :percentage="Math.round(occtLoadProgress ?? 0)" :stroke-width="14" :show-text="false" />
<span class="wasm-progress-text">OpenCASCADE WASM 加载中 ({{ Math.round(occtLoadProgress ?? 0) }}%)</span>
</div>
<span v-if="fileName" class="file-name" :title="fileName">{{ fileName }}</span>
</div>
<!-- 导入模型文件弹框 -->
<el-dialog v-model="uploadDialogVisible" width="520px" :close-on-click-modal="false" :append-to-body="true"
class="step-upload-dialog" align-center title="导入模型文件">
<div class="upload-dialog-body">
<!-- el-upload 拖拽区 -->
<el-upload ref="elUploadRef" class="step-uploader" drag :auto-upload="false" :show-file-list="false"
:multiple="false" accept=".step,.stp" :on-change="handleElUploadChange">
<div class="upload-placeholder">
<div class="uph-icon-wrap">
<el-icon class="uph-icon">
<UploadFilled />
</el-icon>
</div>
<p class="uph-title">将文件拖到此处</p>
<p class="uph-sub"> <em class="uph-browse">点击选择本地文件</em></p>
<div class="uph-tags">
<el-tag size="small" type="primary" effect="light" round>.STEP</el-tag>
<el-tag size="small" type="primary" effect="light" round>.STP</el-tag>
<span class="uph-size-note">最大上传限制300MB</span>
</div>
</div>
</el-upload>
<!-- 已选文件预览卡片 -->
<transition name="file-card-slide">
<div v-if="pendingFile" class="selected-file-card">
<div class="sfc-icon-block">
<el-icon class="sfc-doc-icon">
<Document />
</el-icon>
<span class="sfc-ext">{{ fileExtension }}</span>
</div>
<div class="sfc-meta">
<div class="sfc-name" :title="pendingFile.name">{{ pendingFile.name }}</div>
<div class="sfc-detail">
<span class="sfc-size">{{ formatFileSize(pendingFile.size) }}</span>
<el-divider direction="vertical" />
<el-icon class="sfc-check">
<CircleCheckFilled />
</el-icon>
<span class="sfc-ready">准备就绪</span>
</div>
</div>
<el-tooltip content="移除文件" placement="top">
<el-button class="sfc-remove" :icon="Close" circle plain size="small" @click.stop="removePendingFile" />
</el-tooltip>
</div>
</transition>
</div>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :icon="UploadFilled" :disabled="!pendingFile" @click="confirmUpload">
开始导入
</el-button>
</template>
</el-dialog>
</div>
<stats />
<!-- 中间显示控制 + 测量工具 -->
<div class="toolbar-center" v-if="hasModel">
<!-- 显示控制 -->
<el-tooltip content="坐标轴" placement="bottom">
<el-button :type="showAxes ? 'primary' : 'default'" @click="$emit('toggleAxes')" text>
</el-button>
</el-tooltip>
<el-tooltip content="网格" placement="bottom">
<el-button :type="showGrid ? 'primary' : 'default'" @click="$emit('toggleGrid')" text>
网格
</el-button>
</el-tooltip>
<!-- 透明度滑块 -->
<div class="opacity-control">
<span class="opacity-label">透明度</span>
<el-slider v-model="localOpacity" :min="0" :max="100" :step="5" :show-tooltip="true"
:format-tooltip="(val: any) => `${val}%`" @change="handleOpacityInput" style="width: 100px" />
</div>
<el-divider direction="vertical" />
<!-- 画线测量 -->
<el-tooltip content="画线测量(点击模型/空间画直线,自动计算距离)" placement="bottom">
<el-button :type="isLineMeasureActive ? 'warning' : 'default'" @click="$emit('toggleLineMeasure')" text>
画线测量
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
<!-- 模型结构树面板切换 -->
<el-tooltip content="打开/关闭模型结构树面板" placement="bottom">
<el-button :type="isModelTreeOpen ? 'primary' : 'default'" @click="$emit('toggleModelTree')" text>
模型树
</el-button>
</el-tooltip>
</div>
<!-- 右侧清空/重置 + FPS -->
<div class="toolbar-right" v-if="hasModel">
<el-tooltip content="取消选择" placement="bottom">
<el-button @click="$emit('clearSelection')" :disabled="!hasSelection" text>
取消选择
</el-button>
</el-tooltip>
<el-tooltip content="适应窗口" placement="bottom">
<el-button :icon="Aim" @click="$emit('fitView')" />
</el-tooltip>
<el-tooltip content="重置视图" placement="bottom">
<el-button :icon="RefreshRight" @click="$emit('resetView')" />
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip content="性能监控" placement="bottom">
<el-button :type="showStats ? 'warning' : 'default'" @click="$emit('toggleStats')" :icon="DataLine" text>
FPS
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
<!-- GitHub 链接 -->
<el-tooltip content="GitHub 仓库" placement="bottom">
<el-button class="github-btn" text @click="openGitHub">
<svg class="github-icon" viewBox="0 0 1024 1024" width="18" height="18" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z"
transform="scale(64)" fill="currentColor" />
</svg>
</el-button>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadFile, UploadInstance } from 'element-plus'
import {
Upload,
UploadFilled,
Aim,
RefreshRight,
DataLine,
Document,
Close,
CircleCheckFilled,
WarningFilled
} from '@element-plus/icons-vue'
// Types removed: GranularityMode, ViewMode no longer needed
const props = defineProps<{
fileName: string
isLoading: boolean
hasModel: boolean
hasSelection: boolean
showAxes: boolean
showGrid: boolean
showStats: boolean
/** OpenCASCADE WASM 是否已加载完成 */
occtReady: boolean
/** WASM 加载进度 0-100 */
occtLoadProgress?: number
/** 画线测量模式是否激活 */
isLineMeasureActive?: boolean
/** 模型结构树面板是否打开 */
isModelTreeOpen?: boolean
/** 当前透明度(0~100 */
opacity?: number
}>()
const emit = defineEmits<{
(e: 'upload', file: File): void
(e: 'fitView'): void
(e: 'toggleAxes'): void
(e: 'toggleGrid'): void
(e: 'opacityChange', value: number): void
(e: 'clearSelection'): void
(e: 'resetView'): void
(e: 'toggleStats'): void
(e: 'toggleLineMeasure'): void
(e: 'toggleModelTree'): void
}>()
const localOpacity = ref(props.opacity ?? 100)
//
const uploadDialogVisible = ref(false)
const pendingFile = ref<File | null>(null)
const elUploadRef = ref<UploadInstance>()
/** 从文件名提取扩展名大写标签 */
const fileExtension = computed(() => {
if (!pendingFile.value) return ''
return pendingFile.value.name.toLowerCase().endsWith('.step') ? 'STEP' : 'STP'
})
function isValidStepFile(file: File): boolean {
const name = file.name.toLowerCase()
return name.endsWith('.step') || name.endsWith('.stp')
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function openUploadDialog(): void {
pendingFile.value = null
uploadDialogVisible.value = true
// el-upload
setTimeout(() => elUploadRef.value?.clearFiles(), 80)
}
function handleElUploadChange(uploadFile: UploadFile): void {
const raw = uploadFile.raw
if (!raw) return
if (!isValidStepFile(raw)) {
ElMessage.warning('仅支持 .step / .stp 格式的文件')
elUploadRef.value?.clearFiles()
return
}
pendingFile.value = raw
}
function removePendingFile(): void {
pendingFile.value = null
elUploadRef.value?.clearFiles()
}
function confirmUpload(): void {
if (!pendingFile.value) return
emit('upload', pendingFile.value)
uploadDialogVisible.value = false
pendingFile.value = null
}
// opacity prop
watch(() => props.opacity, (val) => {
if (val !== undefined) localOpacity.value = val
})
function handleOpacityInput(val: number | number[]): void {
const v = Array.isArray(val) ? val[0] : val
emit('opacityChange', v)
}
function openGitHub(): void {
window.open('https://github.com/Democratizing-Dexterous/URDFlyS2U', '_blank', 'noopener,noreferrer')
}
</script>
<style lang="scss" scoped>
.step-viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
gap: 8px;
min-height: 42px;
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-left {
flex-shrink: 0;
}
.toolbar-center {
flex: 1;
justify-content: center;
flex-wrap: wrap;
}
.toolbar-right {
flex-shrink: 0;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 4px;
.file-name {
margin-left: 6px;
font-size: 12px;
color: #606266;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sidebar-toggle {
padding: 6px;
}
.el-divider--vertical {
height: 20px;
margin: 0 4px;
}
.opacity-control {
display: flex;
align-items: center;
gap: 6px;
.opacity-label {
font-size: 12px;
color: #606266;
white-space: nowrap;
}
.el-slider {
--el-slider-height: 4px;
--el-slider-button-size: 14px;
}
}
.axis-scale-control {
display: flex;
align-items: center;
gap: 6px;
.axis-scale-label {
font-size: 12px;
color: #606266;
white-space: nowrap;
}
.el-slider {
--el-slider-height: 4px;
--el-slider-button-size: 14px;
}
}
}
.wasm-progress {
display: flex;
align-items: center;
gap: 6px;
margin-left: 8px;
.el-progress {
width: 100px;
}
.wasm-progress-text {
font-size: 11px;
color: #909399;
white-space: nowrap;
}
}
.github-btn {
padding: 6px;
font-size: 0;
.github-icon {
color: #606266;
transition: color 0.2s;
}
&:hover .github-icon {
color: #303133;
}
}
/* ─── 上传弹框:自有插槽内容样式 ─────────────────────────────────── */
/* dialog body 容器 */
.upload-dialog-body {
display: flex;
flex-direction: column;
gap: 0;
}
/* 上传占位内容(el-upload 默认槽) */
.upload-placeholder {
padding: 32px 20px 26px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.uph-icon-wrap {
width: 68px;
height: 68px;
border-radius: 50%;
background: linear-gradient(135deg, #ecf5ff 0%, #e1efff 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
transition: transform 0.25s ease, box-shadow 0.25s ease;
.uph-icon {
font-size: 34px;
color: #409eff;
transition: color 0.2s;
}
}
.uph-title {
font-size: 15px;
font-weight: 600;
color: #1d2129;
margin: 0;
}
.uph-sub {
font-size: 13px;
color: #86909c;
margin: 0;
.uph-browse {
font-style: normal;
color: #409eff;
font-weight: 500;
}
}
.uph-tags {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
.uph-size-note {
font-size: 11px;
color: #c9cdd4;
margin-left: 2px;
}
}
}
/* 已选文件预览卡片 */
.selected-file-card {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
padding: 12px 16px;
background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
border: 1px solid #c6e0ff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
.sfc-icon-block {
position: relative;
flex-shrink: 0;
line-height: 1;
.sfc-doc-icon {
font-size: 38px;
color: #409eff;
}
.sfc-ext {
position: absolute;
bottom: -2px;
right: -8px;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.3px;
background: #409eff;
color: #fff;
padding: 1px 4px;
border-radius: 3px;
line-height: 1.5;
}
}
.sfc-meta {
flex: 1;
min-width: 0;
.sfc-name {
font-size: 13px;
font-weight: 500;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 5px;
}
.sfc-detail {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #86909c;
.sfc-check {
color: #67c23a;
font-size: 13px;
vertical-align: middle;
}
.sfc-ready {
color: #67c23a;
font-weight: 500;
}
.el-divider--vertical {
height: 10px;
margin: 0 2px;
}
}
}
.sfc-remove {
flex-shrink: 0;
border-color: #dcdfe6;
color: #909399;
&:hover {
border-color: #f56c6c;
color: #f56c6c;
background: #fef0f0;
}
}
}
/* 卡片滑入动画 */
.file-card-slide-enter-active {
transition: all 0.28s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.file-card-slide-leave-active {
transition: all 0.18s ease-in;
}
.file-card-slide-enter-from {
opacity: 0;
transform: translateY(-8px) scale(0.97);
}
.file-card-slide-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
/* ─── el-upload dragger 样式覆盖(:deep 穿透子组件) ───────────── */
:deep(.step-uploader) {
width: 100%;
.el-upload {
width: 100%;
display: block;
}
.el-upload-dragger {
width: 100%;
height: auto;
padding: 0;
border: 2px dashed #dde3ed;
border-radius: 12px;
background: #fafcff;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
&:hover {
border-color: #409eff;
background: #f5f9ff;
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.08);
.uph-icon-wrap {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.2);
}
}
&.is-dragover {
border-color: #409eff;
border-style: solid;
background: linear-gradient(135deg, #ecf5ff 0%, #f0f8ff 100%);
box-shadow: 0 0 0 4px rgba(64, 158, 255, 0.12);
.uph-icon-wrap {
transform: translateY(-4px) scale(1.05);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.25);
}
.uph-icon {
color: #337ecc;
}
}
}
}
</style>
/* ─── 全局覆盖 el-dialog 内部间距(dialog 被 teleport 到 body,需非 scoped ─── */
<style>
.step-upload-dialog .el-dialog__header {
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f2f5;
margin-right: 0;
}
.step-upload-dialog .el-dialog__headerbtn {
top: 20px;
right: 20px;
}
.step-upload-dialog .el-dialog__body {
padding: 10px;
}
.step-upload-dialog .el-dialog {
border-radius: 16px;
overflow: hidden;
}
</style>
@@ -0,0 +1,171 @@
<!--
浮动关节控制面板
参考 URDFEditor.vue 的拖拽实现
支持拖拽移动
-->
<template>
<Teleport to="body">
<Transition name="fk-panel">
<div v-show="visible" class="fk-floating-panel" :style="panelStyle" @mousedown.stop>
<!-- 标题栏可拖拽 -->
<div class="fk-title-bar" @mousedown="startDrag">
<span class="fk-title">🎛 关节控制</span>
<div class="fk-title-actions">
<el-button size="small" text @click.stop="urdfStore.resetJoints()">归零</el-button>
<el-button size="small" text @click.stop="urdfStore.randomizeJoints()">随机</el-button>
<el-button size="small" text circle @click="$emit('close')"></el-button>
</div>
</div>
<!-- 关节滑块列表 -->
<div class="fk-body">
<div v-if="urdfStore.activeJoints.length > 0" class="slider-list">
<JointSlider v-for="joint in urdfStore.activeJoints" :key="joint.id" :joint="joint" />
</div>
<div v-else class="empty-hint">暂无可控关节</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useURDFStore } from '../../stores/useURDFStore'
import JointSlider from './JointSlider.vue'
defineProps<{
visible: boolean
}>()
defineEmits<{
(e: 'close'): void
}>()
const urdfStore = useURDFStore()
//
const posX = ref(Math.max(40, Math.min(window.innerWidth - 360, window.innerWidth * 0.6)))
const posY = ref(Math.max(40, window.innerHeight - 460))
const panelStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`,
}))
function startDrag(e: MouseEvent): void {
e.preventDefault()
const startX = e.clientX
const startY = e.clientY
const startPosX = posX.value
const startPosY = posY.value
const onMouseMove = (moveEvent: MouseEvent) => {
posX.value = Math.max(0, Math.min(window.innerWidth - 100, startPosX + moveEvent.clientX - startX))
posY.value = Math.max(0, Math.min(window.innerHeight - 50, startPosY + moveEvent.clientY - startY))
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'move'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>
<style lang="scss" scoped>
.fk-floating-panel {
position: fixed;
z-index: 2000;
width: 320px;
max-height: 420px;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.fk-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
cursor: move;
user-select: none;
flex-shrink: 0;
}
.fk-title {
font-size: 12px;
font-weight: 600;
color: #303133;
}
.fk-title-actions {
display: flex;
align-items: center;
gap: 2px;
}
.fk-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 2px;
}
}
.slider-list {
display: flex;
flex-direction: column;
width: 100%;
}
.empty-hint {
font-size: 12px;
color: #909399;
text-align: center;
padding: 12px 0;
}
.fk-panel-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fk-panel-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fk-panel-enter-from {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
.fk-panel-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
</style>
@@ -0,0 +1,60 @@
<template>
<div class="joint-slider">
<span class="slider-name" :title="joint.name">{{ joint.name }}</span>
<el-slider :model-value="joint.currentValue" :min="joint.limits.lower" :max="joint.limits.upper" :step="sliderStep"
:show-tooltip="false" @update:model-value="handleChange" />
<span class="slider-value">{{ joint.currentValue.toFixed(3) }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useURDFStore } from '../../stores/useURDFStore'
import type { URDFJoint } from '../../types'
const props = defineProps<{
joint: URDFJoint
}>()
const urdfStore = useURDFStore()
const sliderStep = computed(() => {
const range = props.joint.limits.upper - props.joint.limits.lower
return range > 0 ? range / 200 : 0.01
})
function handleChange(val: number | number[]): void {
const v = Array.isArray(val) ? val[0] : val
urdfStore.setJointValue(props.joint.id, v)
}
</script>
<style lang="scss" scoped>
.joint-slider {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
width: 100%;
}
.slider-name {
flex-shrink: 0;
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slider-value {
flex-shrink: 0;
text-align: right;
font-size: 14px;
color: #606266;
font-family: monospace;
letter-spacing: -0.3px;
}
</style>
@@ -0,0 +1,427 @@
<template>
<Teleport to="body">
<div v-show="urdfStore.jointWizardVisible" class="joint-wizard-panel" :style="panelStyle">
<!-- 标题栏可拖拽 -->
<div class="panel-header" @mousedown="startDrag">
<span class="panel-title"> 创建关节</span>
<div class="panel-actions">
<el-tag v-if="pickedEdgeInfo" type="success" size="small">{{ pickedEdgeInfo }}</el-tag>
<el-button size="small" text @click="handleClose" style="color:#ccc"></el-button>
</div>
</div>
<div class="panel-body">
<!-- Links 选择 -->
<div class="field-row">
<span class="field-label">Parent:</span>
<el-select v-model="parentLinkId" placeholder="Parent Link" size="small" style="flex:1">
<el-option v-for="l in urdfStore.robot.links" :key="l.id" :label="l.name" :value="l.id" />
</el-select>
</div>
<div class="field-row">
<span class="field-label">Child:</span>
<el-select v-model="childLinkId" placeholder="Child Link" size="small" style="flex:1">
<el-option v-for="l in availableChildLinks" :key="l.id" :label="l.name" :value="l.id"
:disabled="l.id === parentLinkId" />
</el-select>
</div>
<!-- 提示打开面板即可直接拾取 -->
<div class="pick-hint">
<el-tag size="small" type="warning">🎯 直接点击 3D 圆弧边/直线拾取轴线</el-tag>
<el-button v-if="hasSnap" size="small" type="info" text @click="handleFlipNormal">🔄 反转轴向</el-button>
</div>
<!-- Origin XYZ -->
<div class="field-row">
<span class="field-label">Origin:</span>
<div class="vec3-inputs">
<el-input-number v-model="originXYZ[0]" size="small" :step="0.001" :precision="6"
controls-position="right" />
<el-input-number v-model="originXYZ[1]" size="small" :step="0.001" :precision="6"
controls-position="right" />
<el-input-number v-model="originXYZ[2]" size="small" :step="0.001" :precision="6"
controls-position="right" />
</div>
</div>
<!-- Origin RPY -->
<div class="field-row">
<span class="field-label">RPY:</span>
<div class="vec3-inputs">
<el-input-number v-model="originRPY[0]" size="small" :step="0.01" :precision="6"
controls-position="right" />
<el-input-number v-model="originRPY[1]" size="small" :step="0.01" :precision="6"
controls-position="right" />
<el-input-number v-model="originRPY[2]" size="small" :step="0.01" :precision="6"
controls-position="right" />
</div>
</div>
<!-- Axis -->
<div class="field-row">
<span class="field-label">Axis:</span>
<div class="vec3-inputs">
<el-input-number v-model="axis[0]" size="small" :step="0.01" :precision="6" :min="-1" :max="1"
controls-position="right" />
<el-input-number v-model="axis[1]" size="small" :step="0.01" :precision="6" :min="-1" :max="1"
controls-position="right" />
<el-input-number v-model="axis[2]" size="small" :step="0.01" :precision="6" :min="-1" :max="1"
controls-position="right" />
</div>
</div>
<!-- Type + Name -->
<div class="field-row">
<span class="field-label">Type:</span>
<el-select v-model="jointType" size="small" style="flex:1">
<el-option label="Revolute" value="revolute" />
<el-option label="Prismatic" value="prismatic" />
<el-option label="Fixed" value="fixed" />
<el-option label="Continuous" value="continuous" />
</el-select>
</div>
<div class="field-row">
<span class="field-label">Name:</span>
<el-input v-model="jointName" placeholder="自动生成" size="small" style="flex:1" />
</div>
<!-- Limits -->
<template v-if="jointType !== 'fixed'">
<div class="limits-header">限位</div>
<div class="field-row">
<span class="field-label">Lower:</span>
<el-input-number v-model="limits.lower" size="small" :step="0.1" :precision="4" controls-position="right" />
<el-button size="small" text @click="limits.lower = -Math.PI"></el-button>
</div>
<div class="field-row">
<span class="field-label">Upper:</span>
<el-input-number v-model="limits.upper" size="small" :step="0.1" :precision="4" controls-position="right" />
<el-button size="small" text @click="limits.upper = Math.PI">π</el-button>
</div>
<div class="field-row">
<span class="field-label">Effort:</span>
<el-input-number v-model="limits.effort" size="small" :step="1" :precision="1" controls-position="right" />
</div>
<div class="field-row">
<span class="field-label">Velocity:</span>
<el-input-number v-model="limits.velocity" size="small" :step="0.1" :precision="2"
controls-position="right" />
</div>
</template>
</div>
<!-- 底部按钮 -->
<div class="panel-footer">
<el-button size="small" @click="handleClose">取消</el-button>
<el-button size="default" type="success" :disabled="!canCreate" @click="handleCreate"> 创建关节</el-button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onBeforeUnmount, type CSSProperties } from 'vue'
import * as THREE from 'three'
import { ElMessage } from 'element-plus'
import { useURDFStore } from '../../stores/useURDFStore'
import { computeRelativeTransform } from '../../core/useKinematicsWorker'
import type { JointType, GeometryFeature } from '../../types'
const urdfStore = useURDFStore()
const emit = defineEmits<{
(e: 'created', jointId: string): void
(e: 'startEdgePick'): void
(e: 'stopEdgePick'): void
(e: 'flipNormal'): void
}>()
//
const position = reactive({ x: window.innerWidth - 420, y: 80 })
const panelStyle = computed<CSSProperties>(() => ({
left: `${position.x}px`,
top: `${position.y}px`
}))
//
const parentLinkId = ref('')
const childLinkId = ref('')
const originXYZ = reactive<[number, number, number]>([0, 0, 0])
const originRPY = reactive<[number, number, number]>([0, 0, 0])
const axis = reactive<[number, number, number]>([0, 0, 1])
const jointName = ref('')
const jointType = ref<JointType>('revolute')
const limits = reactive({ lower: -3.14159, upper: 3.14159, effort: 100, velocity: 1 })
const pickedEdgeInfo = ref('')
/** 缓存当前 snap 世界坐标(用于反转轴向时重新计算) */
let cachedSnapPosition: [number, number, number] | null = null
let cachedSnapNormal: [number, number, number] | null = null
const availableChildLinks = computed(() => {
const usedChildIds = new Set(urdfStore.robot.joints.map(j => j.childLinkId))
return urdfStore.robot.links.filter(l => !usedChildIds.has(l.id) && !urdfStore.isBaseLink(l.id))
})
const canCreate = computed(() => {
return parentLinkId.value && childLinkId.value && parentLinkId.value !== childLinkId.value
})
/** 是否已拾取过边(缓存了 snap 数据),用于显示反转按钮 */
const hasSnap = computed(() => cachedSnapNormal !== null)
function handleCreate(): void {
const result = urdfStore.addJoint({
name: jointName.value || undefined,
type: jointType.value,
parentLinkId: parentLinkId.value,
childLinkId: childLinkId.value,
origin: {
xyz: [...originXYZ] as [number, number, number],
rpy: [...originRPY] as [number, number, number]
},
axis: [...axis] as [number, number, number],
limits: { ...limits }
})
if (!result.ok) {
ElMessage.warning(result.reason)
return
}
emit('created', result.joint.id)
handleClose()
}
function handleClose(): void {
emit('stopEdgePick')
urdfStore.jointWizardVisible = false
urdfStore.jointWizardStep = 'select-links'
resetForm()
}
function resetForm(): void {
parentLinkId.value = ''
childLinkId.value = ''
originXYZ[0] = originXYZ[1] = originXYZ[2] = 0
originRPY[0] = originRPY[1] = originRPY[2] = 0
axis[0] = 0; axis[1] = 0; axis[2] = 1
jointName.value = ''
jointType.value = 'revolute'
limits.lower = -3.14159; limits.upper = 3.14159; limits.effort = 100; limits.velocity = 1
pickedEdgeInfo.value = ''
cachedSnapPosition = null
cachedSnapNormal = null
}
/** 由外部调用:3D 场景拾取到边时触发,通过 Worker 计算相对坐标 */
async function applyPickedEdge(feature: GeometryFeature): Promise<void> {
// snap
let snapPos: [number, number, number]
let snapNorm: [number, number, number]
if (feature.edgeCurveType === 'line') {
if (!feature.startPoint || !feature.endPoint) return
const dir = feature.endPoint.clone().sub(feature.startPoint).normalize()
snapPos = [feature.startPoint.x, feature.startPoint.y, feature.startPoint.z]
snapNorm = [dir.x, dir.y, dir.z]
pickedEdgeInfo.value = '直线'
} else {
if (!feature.center || (!feature.axis && !feature.normal)) return
const norm = (feature.axis || feature.normal)!
snapPos = [feature.center.x, feature.center.y, feature.center.z]
snapNorm = [norm.x, norm.y, norm.z]
pickedEdgeInfo.value = feature.edgeCurveType || feature.type
}
// snap 使
cachedSnapPosition = snapPos
cachedSnapNormal = snapNorm
await applySnapToForm(snapPos, snapNorm)
}
/** 反转轴向并重新计算 RPY */
async function handleFlipNormal(): Promise<void> {
if (!cachedSnapNormal || !cachedSnapPosition) return
// 线
cachedSnapNormal = [
-cachedSnapNormal[0],
-cachedSnapNormal[1],
-cachedSnapNormal[2]
]
// 线 Gizmo
emit('flipNormal')
//
await applySnapToForm(cachedSnapPosition, cachedSnapNormal)
}
/** 将 snap 数据通过 Worker 计算并填入表单 */
async function applySnapToForm(
snapPos: [number, number, number],
snapNorm: [number, number, number]
): Promise<void> {
//
const parentWorld = parentLinkId.value
? urdfStore.linkWorldTransforms.get(parentLinkId.value)
: null
const parentElements = parentWorld ? parentWorld.elements : new THREE.Matrix4().elements
// Worker
const result = await computeRelativeTransform(parentElements, snapPos, snapNorm)
//
originXYZ[0] = result.xyz[0]
originXYZ[1] = result.xyz[1]
originXYZ[2] = result.xyz[2]
originRPY[0] = result.rpy[0]
originRPY[1] = result.rpy[1]
originRPY[2] = result.rpy[2]
// RPY Z snapNormal axis Z
axis[0] = 0
axis[1] = 0
axis[2] = 1
}
defineExpose({ applyPickedEdge })
// 退
watch(() => urdfStore.jointWizardVisible, (vis) => {
if (vis) {
resetForm()
//
position.x = window.innerWidth - 420
position.y = 80
emit('startEdgePick')
} else {
emit('stopEdgePick')
}
})
// ========== ==========
let isDragging = false
let dragStartX = 0
let dragStartY = 0
let dragStartPosX = 0
let dragStartPosY = 0
function startDrag(e: MouseEvent): void {
isDragging = true
dragStartX = e.clientX
dragStartY = e.clientY
dragStartPosX = position.x
dragStartPosY = position.y
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
}
function onDragMove(e: MouseEvent): void {
if (!isDragging) return
position.x = dragStartPosX + (e.clientX - dragStartX)
position.y = dragStartPosY + (e.clientY - dragStartY)
}
function onDragEnd(): void {
isDragging = false
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
})
</script>
<style lang="scss" scoped>
.joint-wizard-panel {
position: fixed;
z-index: 1500;
width: 390px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
max-height: 80vh;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #2d2d2d;
cursor: move;
user-select: none;
border-radius: 8px 8px 0 0;
}
.panel-title {
font-size: 13px;
color: #e0e0e0;
font-weight: 600;
}
.panel-actions {
display: flex;
align-items: center;
gap: 6px;
}
.panel-body {
padding: 10px 12px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.pick-hint {
margin: 4px 0 8px;
}
.field-row {
display: flex;
align-items: center;
margin-bottom: 7px;
gap: 6px;
.field-label {
font-size: 12px;
color: #303133;
white-space: nowrap;
width: 52px;
flex-shrink: 0;
}
}
.vec3-inputs {
display: flex;
gap: 3px;
flex: 1;
.el-input-number {
width: 95px;
}
}
.limits-header {
font-size: 11px;
color: #909399;
margin: 4px 0 5px;
padding-top: 5px;
border-top: 1px solid #ebeef5;
}
.panel-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #ebeef5;
}
</style>
@@ -0,0 +1,320 @@
<template>
<div class="joints-module">
<div class="module-header">
<span class="module-title"> Joints</span>
<el-button size="small" type="primary" text @click="handleAddJoint" :disabled="urdfStore.robot.links.length < 2">
+ Add Joint
</el-button>
</div>
<div class="joint-list">
<div v-for="joint in urdfStore.robot.joints" :key="joint.id" class="joint-item"
:class="{ active: urdfStore.selectedJointId === joint.id }" @click="handleSelectJoint(joint.id)"
@mouseenter="hoverJointId = joint.id" @mouseleave="hoverJointId = null">
<div class="joint-main">
<span class="joint-icon">{{ getJointIcon(joint.type) }}</span>
<span class="joint-name" :title="joint.name">{{ joint.name }}</span>
</div>
<div class="joint-meta">
<el-select v-model="joint.type" size="small" style="width: 90px" @click.stop
@change="(val: any) => handleTypeChange(joint.id, val)">
<el-option label="Revolute" value="revolute" />
<el-option label="Prismatic" value="prismatic" />
<el-option label="Fixed" value="fixed" />
<el-option label="Continuous" value="continuous" />
</el-select>
<el-button v-show="hoverJointId === joint.id" size="small" type="danger" text :icon="Delete"
@click.stop="handleDeleteJoint(joint.id)" />
</div>
</div>
</div>
<!-- 选中 Joint 的详细编辑 -->
<div v-if="selectedJoint" class="joint-detail">
<div class="detail-section">
<div class="detail-label">Parent: {{ getParentName(selectedJoint) }}</div>
<div class="detail-label">Child: {{ getChildName(selectedJoint) }}</div>
</div>
<div class="detail-section">
<div class="detail-title">
Origin
<el-button v-if="!urdfStore.edgePickEditJointId" size="small" type="warning" text
@click="handleStartEdgePick">
🎯 拾取边
</el-button>
<el-button v-else size="default" type="success" @click="handleStopEdgePick">
完成拾取
</el-button>
<el-button v-if="urdfStore.edgePickEditJointId === selectedJoint?.id" size="small" type="info" text
@click="handleFlipNormal">
🔄 反转
</el-button>
</div>
<div class="detail-row">
<span class="row-label">xyz:</span>
<el-input-number v-model="selectedJoint.origin.xyz[0]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 90px" />
<el-input-number v-model="selectedJoint.origin.xyz[1]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 90px" />
<el-input-number v-model="selectedJoint.origin.xyz[2]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 90px" />
</div>
<div class="detail-row">
<span class="row-label">rpy:</span>
<el-input-number v-model="selectedJoint.origin.rpy[0]" size="small" :step="0.01" :precision="4"
controls-position="right" style="width: 90px" />
<el-input-number v-model="selectedJoint.origin.rpy[1]" size="small" :step="0.01" :precision="4"
controls-position="right" style="width: 90px" />
<el-input-number v-model="selectedJoint.origin.rpy[2]" size="small" :step="0.01" :precision="4"
controls-position="right" style="width: 90px" />
</div>
</div>
<div class="detail-section">
<div class="detail-title">Axis</div>
<div class="detail-row">
<span class="row-label">xyz:</span>
<el-input-number v-model="selectedJoint.axis[0]" size="small" :step="0.01" :precision="4" :min="-1" :max="1"
controls-position="right" style="width: 90px" />
<el-input-number v-model="selectedJoint.axis[1]" size="small" :step="0.01" :precision="4" :min="-1" :max="1"
controls-position="right" style="width: 90px" />
<el-input-number v-model="selectedJoint.axis[2]" size="small" :step="0.01" :precision="4" :min="-1" :max="1"
controls-position="right" style="width: 90px" />
</div>
</div>
<div class="detail-section" v-if="selectedJoint.type !== 'fixed'">
<div class="detail-title">Limits</div>
<div class="detail-row">
<span class="row-label">lower:</span>
<el-input-number v-model="selectedJoint.limits.lower" size="small" :step="0.1" :precision="4"
controls-position="right" style="width: 120px" />
</div>
<div class="detail-row">
<span class="row-label">upper:</span>
<el-input-number v-model="selectedJoint.limits.upper" size="small" :step="0.1" :precision="4"
controls-position="right" style="width: 120px" />
</div>
<div class="detail-row">
<span class="row-label">effort:</span>
<el-input-number v-model="selectedJoint.limits.effort" size="small" :step="1" :precision="1"
controls-position="right" style="width: 120px" />
</div>
<div class="detail-row">
<span class="row-label">velocity:</span>
<el-input-number v-model="selectedJoint.limits.velocity" size="small" :step="0.1" :precision="2"
controls-position="right" style="width: 120px" />
</div>
</div>
</div>
<div v-if="urdfStore.robot.joints.length === 0" class="empty-hint">
需要至少 2 Link 才能创建 Joint
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useURDFStore } from '../../stores/useURDFStore'
import type { URDFJoint, JointType } from '../../types'
const urdfStore = useURDFStore()
const hoverJointId = ref<string | null>(null)
/** 导航守卫:Solid 绑定进行中时,阻止切换关节 */
function guardActiveMode(): boolean {
if (urdfStore.bindingMode.active) {
ElMessage.warning('请先点击「 完成绑定」按钮,完成当前 Solid 绑定后再操作')
return true
}
return false
}
const emit = defineEmits<{
(e: 'selectJoint', jointId: string): void
(e: 'startEdgePick'): void
(e: 'stopEdgePick'): void
(e: 'flipNormal'): void
}>()
const selectedJoint = computed(() => {
if (!urdfStore.selectedJointId) return null
return urdfStore.jointMap.get(urdfStore.selectedJointId) || null
})
function handleAddJoint(): void {
if (guardActiveMode()) return
urdfStore.jointWizardVisible = true
urdfStore.jointWizardStep = 'select-links'
}
function handleSelectJoint(jointId: string): void {
if (guardActiveMode()) return
urdfStore.selectedJointId = jointId
emit('selectJoint', jointId)
}
function handleStartEdgePick(): void {
if (!urdfStore.selectedJointId) return
urdfStore.edgePickEditJointId = urdfStore.selectedJointId
emit('startEdgePick')
}
function handleStopEdgePick(): void {
urdfStore.edgePickEditJointId = null
emit('stopEdgePick')
}
function handleFlipNormal(): void {
emit('flipNormal')
}
function handleDeleteJoint(jointId: string): void {
urdfStore.removeJoint(jointId)
}
function handleTypeChange(jointId: string, type: JointType): void {
urdfStore.updateJoint(jointId, { type })
}
function getJointIcon(type: string): string {
const icons: Record<string, string> = {
revolute: '🔄',
prismatic: '↔️',
fixed: '🔒',
continuous: '🔁'
}
return icons[type] || '⚙️'
}
function getParentName(joint: URDFJoint): string {
return urdfStore.linkMap.get(joint.parentLinkId)?.name || joint.parentLinkId
}
function getChildName(joint: URDFJoint): string {
return urdfStore.linkMap.get(joint.childLinkId)?.name || joint.childLinkId
}
</script>
<style lang="scss" scoped>
.joints-module {
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.module-title {
font-size: 13px;
font-weight: 600;
color: #303133;
}
}
.joint-list {
max-height: 180px;
overflow-y: auto;
}
.joint-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #f5f7fa;
}
&.active {
background: rgba(64, 158, 255, 0.1);
border-left: 2px solid #409eff;
}
}
.joint-main {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.joint-icon {
font-size: 12px;
flex-shrink: 0;
}
.joint-name {
font-size: 12px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.joint-meta {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.joint-detail {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.detail-section {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.detail-label {
font-size: 11px;
color: #606266;
margin-bottom: 2px;
}
.detail-title {
font-size: 11px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.detail-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
.row-label {
font-size: 11px;
color: #909399;
width: 42px;
flex-shrink: 0;
}
}
.empty-hint {
font-size: 11px;
color: #909399;
padding: 4px 0;
}
</style>
@@ -0,0 +1,278 @@
<template>
<div class="links-module">
<div class="module-header">
<span class="module-title">Links</span>
<el-button size="small" type="primary" text @click="handleAddLink">
+ Add Link
</el-button>
</div>
<div class="link-list">
<div v-for="link in urdfStore.robot.links" :key="link.id" class="link-item"
:class="{ active: urdfStore.selectedLinkId === link.id }" @click="handleSelectLink(link.id)"
@dblclick="startRename(link)" @mouseenter="hoverLinkId = link.id" @mouseleave="hoverLinkId = null">
<div class="link-main">
<!-- 编辑模式 -->
<el-input v-if="editingLinkId === link.id" v-model="editingName" size="small" @blur="finishRename(link)"
@keydown.enter="finishRename(link)" @keydown.escape="cancelRename" ref="renameInputRef" autofocus
style="width: 140px" />
<!-- 显示模式 -->
<span v-else class="link-name" :title="link.name">
{{ link.name }}
<el-tag v-if="urdfStore.isBaseLink(link.id)" size="small" type="info"
style="margin-left:4px;font-size:10px">root</el-tag>
</span>
<span class="link-badge" v-if="link.solidIds.length > 0">
{{ link.solidIds.length }} solid{{ link.solidIds.length > 1 ? 's' : '' }}
</span>
</div>
<!-- 操作按钮 -->
<div class="link-actions" v-show="hoverLinkId === link.id || urdfStore.selectedLinkId === link.id">
<el-tooltip content="绑定 Solid" placement="top">
<el-button size="small" type="primary" text :icon="Link" @click.stop="handleStartBinding(link.id)" />
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button v-if="!urdfStore.isBaseLink(link.id)" size="small" type="danger" text :icon="Delete"
@click.stop="handleDeleteLink(link.id)" />
</el-tooltip>
</div>
</div>
</div>
<!-- 绑定的 Solid 列表 -->
<div v-if="selectedLink && selectedLink.solidIds.length > 0" class="bound-solids">
<div class="bound-header">绑定的 Solid</div>
<div v-for="solidId in selectedLink.solidIds" :key="solidId" class="bound-item">
<span class="solid-name">{{ getSolidName(solidId) }}</span>
<el-button size="small" type="danger" text @click="handleUnbindSolid(selectedLink!.id, solidId)">
× 解除
</el-button>
</div>
</div>
<!-- 绑定模式提示 -->
<div v-if="urdfStore.bindingMode.active" class="binding-hint">
<el-tag type="warning" size="small">
🎯 点击 3D 视图中的 Solid 进行绑定
</el-tag>
<el-button size="default" type="success" @click="urdfStore.stopBindingMode()"> 完成绑定</el-button>
</div>
<div v-if="urdfStore.robot.links.length === 0" class="empty-hint">
点击 "+ Add Link" 创建连杆
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Link } from '@element-plus/icons-vue'
import { useURDFStore } from '../../stores/useURDFStore'
import { useStepViewerStore } from '../../stores/useStepViewerStore'
const urdfStore = useURDFStore()
const stepStore = useStepViewerStore()
const hoverLinkId = ref<string | null>(null)
const editingLinkId = ref<string | null>(null)
const editingName = ref('')
const renameInputRef = ref()
const emit = defineEmits<{
(e: 'selectLink', linkId: string): void
}>()
const selectedLink = computed(() => {
if (!urdfStore.selectedLinkId) return null
return urdfStore.linkMap.get(urdfStore.selectedLinkId) || null
})
/** 导航守卫:Solid 绑定进行中时,阻止切换连杆 */
function guardActiveMode(): boolean {
if (urdfStore.bindingMode.active) {
ElMessage.warning('请先点击「 完成绑定」按钮,完成当前 Solid 绑定后再操作')
return true
}
return false
}
function handleAddLink(): void {
if (guardActiveMode()) return
urdfStore.addLink()
}
function handleSelectLink(linkId: string): void {
if (guardActiveMode()) return
urdfStore.selectedLinkId = linkId
emit('selectLink', linkId)
}
function handleDeleteLink(linkId: string): void {
if (guardActiveMode()) return
const link = urdfStore.linkMap.get(linkId)
if (!link) return
ElMessageBox.confirm(
`确定删除连杆 "${link.name}"?关联的关节将被级联删除。`,
'删除确认',
{ type: 'warning' }
).then(() => {
const result = urdfStore.removeLink(linkId)
if (!result.ok) {
ElMessage.warning(result.reason!)
}
}).catch(() => { /* cancelled */ })
}
function startRename(link: { id: string; name: string }): void {
editingLinkId.value = link.id
editingName.value = link.name
nextTick(() => {
const inputEl = renameInputRef.value?.[0]?.$el?.querySelector('input') ||
renameInputRef.value?.$el?.querySelector('input')
inputEl?.select()
})
}
function finishRename(link: { id: string }): void {
if (editingName.value.trim()) {
urdfStore.renameLink(link.id, editingName.value.trim())
}
editingLinkId.value = null
}
function cancelRename(): void {
editingLinkId.value = null
}
function handleStartBinding(linkId: string): void {
// Link Link
if (urdfStore.bindingMode.active && urdfStore.bindingMode.targetLinkId !== linkId) {
ElMessage.warning('请先点击「 完成绑定」按钮,完成当前 Solid 绑定后再切换')
return
}
urdfStore.startBindingMode(linkId)
}
function handleUnbindSolid(linkId: string, solidId: string): void {
urdfStore.unbindSolid(linkId, solidId)
}
function getSolidName(solidId: string): string {
const solid = stepStore.solidMap.get(solidId)
return solid?.name || solidId
}
</script>
<style lang="scss" scoped>
.links-module {
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.module-title {
font-size: 13px;
font-weight: 600;
color: #303133;
}
}
.link-list {
max-height: 200px;
overflow-y: auto;
}
.link-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #f5f7fa;
}
&.active {
background: rgba(64, 158, 255, 0.1);
border-left: 2px solid #409eff;
}
}
.link-main {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.link-name {
font-size: 12px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-badge {
font-size: 10px;
color: #909399;
background: #f0f2f5;
padding: 0 4px;
border-radius: 2px;
flex-shrink: 0;
}
.link-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.bound-solids {
margin-top: 6px;
padding: 6px 8px;
background: #fafafa;
border-radius: 4px;
}
.bound-header {
font-size: 11px;
color: #909399;
margin-bottom: 4px;
}
.bound-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 0;
.solid-name {
font-size: 11px;
color: #606266;
}
}
.binding-hint {
margin-top: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.empty-hint {
font-size: 11px;
color: #909399;
padding: 4px 0;
}
</style>
@@ -0,0 +1,258 @@
<template>
<Teleport to="body">
<div v-show="urdfStore.urdfEditorVisible" ref="editorWrapper" class="urdf-editor-wrapper" :style="wrapperStyle">
<div ref="dragHandle" class="editor-header" @mousedown="startDrag">
<span class="editor-title">📝 URDF Editor</span>
<div class="header-actions">
<el-button size="small" text @click="handleSave" title="下载 .urdf 文件">💾 保存</el-button>
<el-button size="small" type="primary" text @click="handleApply">应用</el-button>
<el-button size="small" text @click="urdfStore.urdfEditorVisible = false">关闭</el-button>
</div>
</div>
<!-- 快捷插入工具栏 -->
<div class="editor-toolbar">
<span class="toolbar-label">插入:</span>
<el-button size="small" text @click="insertText(String(Math.PI))">π</el-button>
<el-button size="small" text @click="insertText(String(Math.PI / 2))">π/2</el-button>
<el-button size="small" text @click="insertText(String(Math.PI / 4))">π/4</el-button>
<el-button size="small" text @click="insertText(String(-Math.PI))"></el-button>
<el-button size="small" text @click="insertText(String(-Math.PI / 2))">/2</el-button>
</div>
<div class="editor-body">
<vue-monaco-editor ref="monacoRef" v-model:value="urdfXml" language="xml" theme="vs-dark"
:options="editorOptions" style="height: 100%" @mount="handleEditorMount" />
</div>
<!-- 拖拽调整大小 -->
<div class="resize-handle" @mousedown.stop="startResize" />
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onBeforeUnmount, type CSSProperties } from 'vue'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ElMessage } from 'element-plus'
import { useURDFStore } from '../../stores/useURDFStore'
import { serializeURDF, deserializeURDF } from '../../core/URDFSerializer'
const urdfStore = useURDFStore()
const urdfXml = ref('')
const monacoRef = ref()
const position = reactive({ x: 100, y: 100 })
const size = reactive({ width: 600, height: 500 })
let editorInstance: any = null
function handleEditorMount(editor: any): void {
editorInstance = editor
}
const editorOptions = {
fontSize: 12,
minimap: { enabled: false },
wordWrap: 'on' as const,
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2
}
const wrapperStyle = computed<CSSProperties>(() => ({
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`
}))
// store URDF XML
watch(() => urdfStore.urdfEditorVisible, (visible) => {
if (visible) {
urdfXml.value = serializeURDF(urdfStore.robot)
}
})
/** 在光标位置插入文本 */
function insertText(text: string): void {
if (!editorInstance) return
const selection = editorInstance.getSelection()
if (selection) {
editorInstance.executeEdits('insert', [{
range: selection,
text,
forceMoveMarkers: true
}])
}
editorInstance.focus()
}
function handleApply(): void {
try {
const imported = deserializeURDF(urdfXml.value)
urdfStore.importRobot(imported)
ElMessage.success('URDF 已应用')
} catch (err) {
ElMessage.error(`解析错误: ${(err as Error).message}`)
}
}
function handleSave(): void {
const blob = new Blob([urdfXml.value], { type: 'application/xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${urdfStore.robot.name || 'robot'}.urdf`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('URDF 文件已下载')
}
// ==== ====
let isDragging = false
let dragStartX = 0
let dragStartY = 0
let dragStartPosX = 0
let dragStartPosY = 0
function startDrag(e: MouseEvent): void {
isDragging = true
dragStartX = e.clientX
dragStartY = e.clientY
dragStartPosX = position.x
dragStartPosY = position.y
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
}
function onDragMove(e: MouseEvent): void {
if (!isDragging) return
position.x = dragStartPosX + (e.clientX - dragStartX)
position.y = dragStartPosY + (e.clientY - dragStartY)
}
function onDragEnd(): void {
isDragging = false
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
}
// ==== ====
let isResizing = false
let resizeStartX = 0
let resizeStartY = 0
let resizeStartW = 0
let resizeStartH = 0
function startResize(e: MouseEvent): void {
isResizing = true
resizeStartX = e.clientX
resizeStartY = e.clientY
resizeStartW = size.width
resizeStartH = size.height
document.addEventListener('mousemove', onResizeMove)
document.addEventListener('mouseup', onResizeEnd)
}
function onResizeMove(e: MouseEvent): void {
if (!isResizing) return
size.width = Math.max(400, resizeStartW + (e.clientX - resizeStartX))
size.height = Math.max(300, resizeStartH + (e.clientY - resizeStartY))
}
function onResizeEnd(): void {
isResizing = false
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
})
</script>
<style lang="scss" scoped>
.urdf-editor-wrapper {
position: fixed;
z-index: 2000;
display: flex;
flex-direction: column;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
overflow: hidden;
background: #1e1e1e;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #333;
cursor: move;
user-select: none;
flex-shrink: 0;
}
.editor-title {
font-size: 13px;
color: #e0e0e0;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 4px;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 10px;
background: #2d2d2d;
border-top: 1px solid #444;
flex-shrink: 0;
.toolbar-label {
font-size: 11px;
color: #aaa;
margin-right: 4px;
}
.el-button {
color: #ccc;
font-size: 11px;
padding: 2px 10px;
}
}
.editor-body {
flex: 1;
min-height: 0;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
&::before {
content: '';
position: absolute;
right: 3px;
bottom: 3px;
width: 8px;
height: 8px;
border-right: 2px solid #666;
border-bottom: 2px solid #666;
}
}
</style>
@@ -0,0 +1,349 @@
<template>
<div class="joint-properties" v-if="joint">
<!-- 节点路径提示 -->
<div class="joint-path">
<span class="path-link">{{ parentLinkName }}</span>
<el-icon>
<ArrowRight />
</el-icon>
<span class="path-joint">{{ joint.name }}</span>
<el-icon>
<ArrowRight />
</el-icon>
<span class="path-link">{{ childLinkName }}</span>
</div>
<el-collapse v-model="openPanels">
<!-- 基本信息 -->
<el-collapse-item name="basic">
<template #title>
<span class="section-title">基本信息</span>
</template>
<div class="prop-form">
<div class="prop-row">
<span class="prop-label">名称</span>
<el-input v-model="joint.name" size="small" placeholder="joint name" />
</div>
<div class="prop-row">
<span class="prop-label">类型</span>
<el-select v-model="joint.type" size="small" style="width: 130px" @change="handleTypeChange">
<el-option label="Revolute(旋转)" value="revolute" />
<el-option label="Prismatic(移动)" value="prismatic" />
<el-option label="Fixed(固定)" value="fixed" />
</el-select>
</div>
</div>
</el-collapse-item>
<!-- 原点 / 特征拾取 -->
<el-collapse-item name="origin">
<template #title>
<span class="section-title">原点 (Origin)</span>
</template>
<div class="prop-form">
<!-- 特征拾取 -->
<div class="pick-row">
<el-button v-if="!urdfStore.edgePickEditJointId" type="warning" plain @click="handleStartEdgePick">
拾取圆边/直线
</el-button>
<template v-else>
<el-button type="danger" plain @click="handleStopEdgePick">
停止拾取
</el-button>
<!-- <el-button v-if="urdfStore.edgePickEditJointId === joint.id" size="small" type="info" plain
@click="$emit('flipNormal')">
反转 Axis
</el-button> -->
</template>
</div>
<!-- xyz -->
<div class="coord-row">
<span class="coord-label">xyz</span>
<el-input-number v-model="joint.origin.xyz[0]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.origin.xyz[1]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.origin.xyz[2]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 82px" />
</div>
<!-- rpy -->
<div class="coord-row">
<span class="coord-label">rpy</span>
<el-input-number v-model="joint.origin.rpy[0]" size="small" :step="0.01" :precision="4"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.origin.rpy[1]" size="small" :step="0.01" :precision="4"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.origin.rpy[2]" size="small" :step="0.01" :precision="4"
controls-position="right" style="width: 82px" />
</div>
</div>
</el-collapse-item>
<!-- 旋转轴 -->
<el-collapse-item name="axis">
<template #title>
<span class="section-title">旋转轴 (Axis)</span>
</template>
<div class="prop-form">
<div class="coord-row">
<span class="coord-label">xyz</span>
<el-input-number v-model="joint.axis[0]" size="small" :step="0.01" :precision="4" :min="-1" :max="1"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.axis[1]" size="small" :step="0.01" :precision="4" :min="-1" :max="1"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.axis[2]" size="small" :step="0.01" :precision="4" :min="-1" :max="1"
controls-position="right" style="width: 82px" />
</div>
<el-button size="small" text type="primary" @click="flipAxis" style="margin-top: 4px">
反转轴方向
</el-button>
</div>
</el-collapse-item>
<!-- 轴偏移DH 参数校正 -->
<el-collapse-item name="axisOffset">
<template #title>
<span class="section-title">轴偏移 (Axis Offset)</span>
</template>
<div class="prop-form">
<div class="coord-row">
<span class="coord-label">xyz</span>
<el-input-number v-model="joint.axisOffset[0]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.axisOffset[1]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 82px" />
<el-input-number v-model="joint.axisOffset[2]" size="small" :step="0.001" :precision="4"
controls-position="right" style="width: 82px" />
</div>
<el-button size="small" text type="info" @click="resetAxisOffset" style="margin-top: 4px">
重置偏移
</el-button>
</div>
</el-collapse-item>
<!-- 限制 fixed -->
<el-collapse-item v-if="joint.type !== 'fixed'" name="limits">
<template #title>
<span class="section-title">限制 (Limits)</span>
</template>
<div class="prop-form">
<div class="prop-row">
<span class="prop-label">下限</span>
<el-input-number v-model="joint.limits.lower" size="small" :step="joint.type === 'prismatic' ? 1 : 0.1"
:precision="3" controls-position="right" style="width: 120px" />
<span class="prop-unit">{{ joint.type === 'prismatic' ? 'mm' : 'rad' }}</span>
</div>
<div class="prop-row">
<span class="prop-label">上限</span>
<el-input-number v-model="joint.limits.upper" size="small" :step="joint.type === 'prismatic' ? 1 : 0.1"
:precision="3" controls-position="right" style="width: 120px" />
<span class="prop-unit">{{ joint.type === 'prismatic' ? 'mm' : 'rad' }}</span>
</div>
<div class="prop-row">
<span class="prop-label">力度</span>
<el-input-number v-model="joint.limits.effort" size="small" :step="1" :precision="1"
controls-position="right" style="width: 120px" />
<span class="prop-unit">{{ joint.type === 'prismatic' ? 'N' : 'N·m' }}</span>
</div>
<div class="prop-row">
<span class="prop-label">速度</span>
<el-input-number v-model="joint.limits.velocity" size="small" :step="joint.type === 'prismatic' ? 1 : 0.1"
:precision="2" controls-position="right" style="width: 120px" />
<span class="prop-unit">{{ joint.type === 'prismatic' ? 'mm/s' : 'rad/s' }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<div v-else class="empty-hint">未选中任何关节</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArrowRight } from '@element-plus/icons-vue'
import { useURDFStore } from '../../stores/useURDFStore'
import type { JointType } from '../../types'
const emit = defineEmits<{
(e: 'flipNormal'): void
}>()
const urdfStore = useURDFStore()
const openPanels = ref<string[]>(['basic', 'origin', 'axis', 'axisOffset', 'limits'])
const joint = computed(() => {
if (!urdfStore.selectedJointId) return null
return urdfStore.jointMap.get(urdfStore.selectedJointId) ?? null
})
const parentLinkName = computed(() =>
joint.value ? (urdfStore.linkMap.get(joint.value.parentLinkId)?.name ?? joint.value.parentLinkId) : ''
)
const childLinkName = computed(() =>
joint.value ? (urdfStore.linkMap.get(joint.value.childLinkId)?.name ?? joint.value.childLinkId) : ''
)
function handleTypeChange(type: JointType): void {
if (!joint.value) return
//
const defaultLimits = type === 'prismatic'
? { lower: -100, upper: 100, effort: 100, velocity: 100 }
: { lower: -3.14159, upper: 3.14159, effort: 10, velocity: 1 }
urdfStore.updateJoint(joint.value.id, { type, limits: defaultLimits })
}
function handleStartEdgePick(): void {
if (!joint.value) return
urdfStore.edgePickEditJointId = joint.value.id
// StepViewer edgePickEditJointId
}
function handleStopEdgePick(): void {
urdfStore.edgePickEditJointId = null
}
function flipAxis(): void {
if (!joint.value) return
joint.value.axis = [
-joint.value.axis[0],
-joint.value.axis[1],
-joint.value.axis[2]
] as [number, number, number]
}
function resetAxisOffset(): void {
if (!joint.value) return
joint.value.axisOffset = [0, 0, 0]
}
</script>
<style lang="scss" scoped>
.joint-properties {
padding: 4px 0;
}
/* 路径提示 */
.joint-path {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: #f4f6f9;
border-radius: 4px;
margin-bottom: 8px;
font-size: 11px;
flex-wrap: wrap;
.path-link {
color: #409eff;
font-weight: 500;
}
.path-joint {
color: #e6a23c;
font-weight: 500;
}
.el-icon {
color: #c0c4cc;
font-size: 10px;
}
}
/* Collapse */
:deep(.el-collapse) {
border: none;
.el-collapse-item__header {
height: 32px;
line-height: 32px;
padding: 0 8px;
font-size: 12px;
background: #fafbfc;
border-bottom: 1px solid #f0f2f5;
}
.el-collapse-item__wrap {
border-bottom: none;
}
.el-collapse-item__content {
padding: 6px 8px 8px;
}
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #303133;
}
.prop-form {
display: flex;
flex-direction: column;
gap: 6px;
}
.prop-row {
display: flex;
align-items: center;
gap: 6px;
.prop-label {
font-size: 11px;
color: #606266;
width: 36px;
flex-shrink: 0;
}
.prop-unit {
font-size: 11px;
color: #909399;
flex-shrink: 0;
min-width: 36px;
}
.el-input,
.el-select {
flex: 1;
}
}
.pick-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.coord-row {
display: flex;
align-items: center;
gap: 3px;
.coord-label {
font-size: 11px;
color: #909399;
width: 24px;
flex-shrink: 0;
}
}
.offset-hint {
font-size: 11px;
color: #909399;
margin: 0 0 6px;
line-height: 1.4;
}
.empty-hint {
font-size: 12px;
color: #909399;
padding: 16px 0;
text-align: center;
}
</style>
@@ -0,0 +1,566 @@
<template>
<div class="urdf-left-panel" :style="{ width: panelWidth + 'px' }">
<!-- 标题栏 -->
<div class="panel-header">
<span class="panel-title">
<el-icon>
<Cpu />
</el-icon>
Robot Structure
</span>
<div class="panel-header-actions">
<el-button size="small" :icon="Plus" @click="handleAddRootLink">Add Link</el-button>
</div>
</div>
<!-- 树形内容区 -->
<div class="panel-content">
<el-tree ref="treeRef" :data="urdfStore.treeData" node-key="id" :default-expand-all="true" highlight-current
:expand-on-click-node="false" empty-text="暂无结构,点击 Add Link 创建根连杆" @node-click="handleNodeClick">
<template #default="{ data }">
<div class="tree-node-row" :class="[data.nodeType, { 'is-base': data.isBase }]">
<!-- 节点图标 -->
<el-icon class="node-icon">
<Box v-if="data.nodeType === 'link'" />
<Share v-else />
</el-icon>
<!-- 节点名称Link / Joint 支持内联重命名 -->
<el-input v-if="editingId === data.id" v-model="editingName" size="small" @blur="finishRename(data)"
@keydown.enter.stop="finishRename(data)" @keydown.escape.stop="cancelRename" @click.stop autofocus
class="rename-input" />
<span v-else class="node-label" :title="data.label">{{ data.label }}</span>
<!-- 徽标 -->
<el-tag v-if="data.isBase" size="small" type="info" class="node-badge">root</el-tag>
<el-tag v-else-if="data.nodeType === 'joint'" size="small" :type="getJointTagType(data.jointType)"
class="node-badge">{{ data.jointType }}</el-tag>
<span v-if="data.nodeType === 'link' && data.solidCount > 0" class="solid-count">{{ data.solidCount
}}s</span>
<!-- 弹性空白 -->
<span class="node-spacer" />
<!-- 操作按钮始终显示末尾对齐 -->
<template v-if="data.nodeType === 'link'">
<el-tooltip content="添加子 Link" placement="top" :show-after="600">
<el-button class="node-btn" size="small" text :icon="Plus" @click.stop="handleAddChildLink(data)" />
</el-tooltip>
<el-tooltip content="绑定 Solid" placement="top" :show-after="600">
<el-button class="node-btn" size="small" text :icon="Paperclip" @click.stop="handleBindSolid(data)" />
</el-tooltip>
<el-tooltip content="重命名" placement="top" :show-after="600">
<el-button class="node-btn" size="small" text :icon="Edit" @click.stop="startRename(data)" />
</el-tooltip>
<el-tooltip v-if="!data.isBase" content="删除连杆" placement="top" :show-after="600">
<el-button class="node-btn node-btn--danger" size="small" text :icon="Delete"
@click.stop="handleDeleteLink(data)" />
</el-tooltip>
</template>
<template v-else>
<el-tooltip content="重命名" placement="top" :show-after="600">
<el-button class="node-btn" size="small" text :icon="Edit" @click.stop="startRename(data)" />
</el-tooltip>
<el-tooltip content="删除关节" placement="top" :show-after="600">
<el-button class="node-btn node-btn--danger" size="small" text :icon="Delete"
@click.stop="handleDeleteJoint(data)" />
</el-tooltip>
</template>
</div>
</template>
</el-tree>
</div>
<!-- 控件区30% -->
<div class="controls-section">
<ViewControls />
</div>
<!-- 底部操作 -->
<div class="panel-footer">
<el-button type="primary" :icon="Upload" :loading="importingHandtuned" @click="loadBundledHandtuned">
加载手调 JSON
</el-button>
<el-button :icon="FolderOpened" @click="pickHandtunedFile">
选择 JSON
</el-button>
<input
ref="jsonFileInput"
type="file"
accept="application/json,.json"
class="hidden-file-input"
@change="onHandtunedFilePicked"
/>
<el-button type="success" :icon="Download" @click="$emit('exportUrdf')">
导出 URDF
</el-button>
<el-button type="primary" :icon="Share" @click="goToURDFCC">
URDF Studio 预览
</el-button>
</div>
<!-- 拖拽调整宽度 -->
<div class="resize-handle" @mousedown.prevent="startResize" />
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Download, Box, Share, Paperclip, Cpu, Upload, FolderOpened } from '@element-plus/icons-vue'
import { useURDFStore } from '../../stores/useURDFStore'
import type { URDFTreeNode } from '../../stores/useURDFStore'
import type { JointType } from '../../types'
import ViewControls from './ViewControls.vue'
import {
applyHandtunedJson,
type HandtunedRobotJson
} from '../../utils/applyHandtunedJson'
defineEmits<{
(e: 'exportUrdf'): void
}>()
const urdfStore = useURDFStore()
const treeRef = ref<any>()
const panelWidth = ref(330)
const importingHandtuned = ref(false)
const jsonFileInput = ref<HTMLInputElement | null>(null)
async function applyHandtunedAndReport(data: HandtunedRobotJson): Promise<void> {
if (guardActiveMode()) return
const result = applyHandtunedJson(data)
if (!result.ok) {
ElMessage.error(result.warnings.join('; ') || '导入失败')
return
}
const extra: string[] = []
if (result.unboundParts.length) {
extra.push(`未绑定零件 ${result.unboundParts.length}${result.unboundParts.slice(0, 8).join(', ')}${result.unboundParts.length > 8 ? '…' : ''}`)
}
if (result.skippedJoints.length) {
extra.push(`跳过关节:${result.skippedJoints.join('; ')}`)
}
ElMessage.success(
`已导入:${result.linksCreated} links / ${result.jointsCreated} joints / ${result.solidsBound} solids` +
(extra.length ? `${extra.join('')}` : '')
)
nextTick(() => treeRef.value?.setCurrentKey(urdfStore.BASE_LINK_ID))
}
async function loadBundledHandtuned(): Promise<void> {
if (guardActiveMode()) return
importingHandtuned.value = true
try {
const res = await fetch('/handtuned_arm.json')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as HandtunedRobotJson
await applyHandtunedAndReport(data)
} catch (e: any) {
ElMessage.error(`加载 handtuned_arm.json 失败:${e?.message || e}`)
} finally {
importingHandtuned.value = false
}
}
function pickHandtunedFile(): void {
if (guardActiveMode()) return
jsonFileInput.value?.click()
}
async function onHandtunedFilePicked(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text) as HandtunedRobotJson
await applyHandtunedAndReport(data)
} catch (e: any) {
ElMessage.error(`解析 JSON 失败:${e?.message || e}`)
}
}
//
const editingId = ref<string | null>(null)
const editingName = ref('')
// Solid / 线
function guardActiveMode(): boolean {
if (urdfStore.bindingMode.active) {
ElMessage.warning('请先点击「 完成绑定」按钮,完成当前 Solid 绑定后再操作')
return true
}
if (urdfStore.edgePickEditJointId) {
ElMessage.warning('请先点击「✕ 停止拾取」结束关节轴线拾取后再操作')
return true
}
return false
}
//
function handleNodeClick(data: URDFTreeNode): void {
if (editingId.value) return
// /
if (urdfStore.bindingMode.active) {
if (data.id !== urdfStore.bindingMode.targetLinkId) {
ElMessage.warning('请先点击「 完成绑定」按钮,完成当前 Solid 绑定后再切换')
}
return
}
if (urdfStore.edgePickEditJointId) {
if (data.id !== urdfStore.edgePickEditJointId) {
ElMessage.warning('请先点击「✕ 停止拾取」结束关节轴线拾取后再切换')
}
return
}
if (data.nodeType === 'link') {
urdfStore.selectedLinkId = data.id
urdfStore.selectedJointId = null
} else {
urdfStore.selectedJointId = data.id
urdfStore.selectedLinkId = null
}
}
//
function handleAddRootLink(): void {
if (guardActiveMode()) return
const link = urdfStore.addLink()
urdfStore.selectedLinkId = link.id
urdfStore.selectedJointId = null
nextTick(() => treeRef.value?.setCurrentKey(link.id))
}
// fixed Joint
function handleAddChildLink(data: URDFTreeNode): void {
if (guardActiveMode()) return
// base_link Solid
if (data.isBase && data.solidCount > 0 && !urdfStore.baseLinkOrigin) {
ElMessage.warning('请先为 base_link 设置坐标基点(右侧面板 → 自动计算 或 3D 拾取)')
return
}
const childLink = urdfStore.addLink()
const result = urdfStore.addJoint({
type: 'revolute',
parentLinkId: data.id,
childLinkId: childLink.id,
origin: { xyz: [0, 0, 0], rpy: [0, 0, 0] },
axis: [0, 0, 1]
})
if (!result.ok) {
urdfStore.removeLink(childLink.id)
ElMessage.warning(result.reason)
return
}
// /
urdfStore.selectedJointId = result.joint.id
urdfStore.selectedLinkId = null
nextTick(() => treeRef.value?.setCurrentKey(result.joint.id))
}
// Solid
function handleBindSolid(data: URDFTreeNode): void {
// Link Link
if (urdfStore.bindingMode.active && urdfStore.bindingMode.targetLinkId !== data.id) {
ElMessage.warning('请先点击「 完成绑定」按钮,完成当前 Solid 绑定后再切换')
return
}
if (urdfStore.edgePickEditJointId) {
ElMessage.warning('请先点击「✕ 停止拾取」结束关节轴线拾取后再操作')
return
}
urdfStore.selectedLinkId = data.id
urdfStore.selectedJointId = null
nextTick(() => treeRef.value?.setCurrentKey(data.id))
urdfStore.startBindingMode(data.id)
}
//
function startRename(data: URDFTreeNode): void {
editingId.value = data.id
editingName.value = data.label
}
function finishRename(data: URDFTreeNode): void {
const name = editingName.value.trim()
if (name) {
if (data.nodeType === 'link') {
urdfStore.renameLink(data.id, name)
} else {
urdfStore.renameJoint(data.id, name)
}
}
editingId.value = null
}
function cancelRename(): void {
editingId.value = null
}
//
function handleDeleteLink(data: URDFTreeNode): void {
if (guardActiveMode()) return
ElMessageBox.confirm(
`确定删除连杆 "${data.label}"?关联的关节将被级联删除。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
).then(() => {
const result = urdfStore.removeLink(data.id)
if (!result.ok) {
ElMessage.warning(result.reason!)
} else {
nextTick(() => treeRef.value?.setCurrentKey(''))
}
}).catch(() => {/* cancelled */ })
}
//
function handleDeleteJoint(data: URDFTreeNode): void {
if (guardActiveMode()) return
urdfStore.removeJoint(data.id)
nextTick(() => treeRef.value?.setCurrentKey(''))
}
// Joint
function getJointTagType(type?: JointType): 'primary' | 'success' | 'info' | 'warning' | 'danger' {
const map: Record<string, 'primary' | 'success' | 'info' | 'warning' | 'danger'> = {
revolute: 'primary', prismatic: 'success', fixed: 'info'
}
return map[type ?? ''] ?? 'info'
}
//
function startResize(e: MouseEvent): void {
const startX = e.clientX
const startWidth = panelWidth.value
const onMove = (ev: MouseEvent) => {
panelWidth.value = Math.max(200, Math.min(500, startWidth + ev.clientX - startX))
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
// 3DUI
function setCurrentNodeById(id: string): void {
treeRef.value?.setCurrentKey(id)
}
function goToURDFCC(): void {
const url = `https://urdf.d-robotics.cc/`
window.open(url, '_blank')
}
defineExpose({ setCurrentNodeById })
</script>
<style lang="scss" scoped>
.urdf-left-panel {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-width: 200px;
max-width: 500px;
background: #fff;
border-right: 1px solid #e4e7ed;
z-index: 10;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #e4e7ed;
background: #fafafa;
flex-shrink: 0;
.panel-title {
display: flex;
align-items: center;
gap: 5px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
}
.panel-content {
flex: 7;
overflow-y: auto;
padding: 6px 0;
min-height: 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 2px;
}
:deep(.el-tree) {
font-size: 12px;
--el-tree-node-hover-bg-color: #f0f5ff;
}
:deep(.el-tree-node__content) {
height: auto;
min-height: 28px;
padding-right: 4px;
}
}
.controls-section {
flex: 3;
min-height: 0;
overflow-y: auto;
border-top: 1px solid #e4e7ed;
padding: 4px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 2px;
}
}
/* ——— 树节点行 ——— */
.tree-node-row {
display: flex;
align-items: center;
width: 100%;
gap: 3px;
padding: 1px 0;
min-width: 0;
&.link .node-icon {
color: #409eff;
}
&.joint .node-icon {
color: #e6a23c;
}
&.is-base .node-icon {
color: #67c23a;
}
}
.node-icon {
font-size: 13px;
flex-shrink: 0;
}
.node-label {
font-size: 12px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.rename-input {
width: 100px;
flex-shrink: 0;
}
.node-badge {
flex-shrink: 0;
font-size: 10px;
padding: 0 4px;
height: 16px;
line-height: 16px;
}
.solid-count {
flex-shrink: 0;
font-size: 10px;
color: #909399;
background: #f0f2f5;
padding: 0 4px;
border-radius: 3px;
}
.node-spacer {
flex: 1;
}
/* ——— 操作按钮 ——— */
.node-btn {
flex-shrink: 0;
padding: 1px !important;
height: 20px !important;
width: 20px !important;
min-height: unset !important;
:deep(.el-icon) {
font-size: 11px;
}
&:hover {
background: #e6f0ff !important;
color: #409eff !important;
}
}
.node-btn--danger:hover {
background: #fef0f0 !important;
color: #f56c6c !important;
}
/* ——— 底部 ——— */
.panel-footer {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid #e4e7ed;
background: #fafafa;
flex-shrink: 0;
.el-button {
flex: 1 1 40%;
min-width: 110px;
}
}
.hidden-file-input {
display: none;
}
/* ——— 宽度拖拽 ——— */
.resize-handle {
position: absolute;
top: 0;
right: -3px;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 20;
&:hover {
background: rgba(64, 158, 255, 0.3);
}
}
</style>
@@ -0,0 +1,563 @@
<template>
<div class="link-properties" v-if="link">
<!-- 连杆名称 -->
<!-- <div class="link-name-row">
<el-icon class="link-icon">
<Box />
</el-icon>
<el-input v-if="editingName" v-model="nameInput" autofocus @blur="finishRename" @keydown.enter="finishRename"
@keydown.escape="cancelRename" style="flex: 1" />
<span v-else class="link-name" @dblclick="startRename">{{ link.name }}</span>
<el-tag v-if="urdfStore.isBaseLink(link.id)" type="info">root</el-tag>
</div> -->
<!-- Base Link 坐标基点 root link 显示-->
<div v-if="urdfStore.isBaseLink(link.id)" class="base-origin-section">
<!-- 标题行名称 + 状态标签 -->
<div class="base-origin-header">
<span class="base-origin-title">🌐 基坐标系原点</span>
<el-tag :type="urdfStore.baseLinkOrigin ? 'success' : 'warning'" effect="light">
{{ urdfStore.baseLinkOrigin ? '已设置' : '未设置' }}
</el-tag>
</div>
<!-- 提示已绑定 Solid 但未设置基点 -->
<el-alert v-if="link.solidIds.length > 0 && !urdfStore.baseLinkOrigin" title="已绑定 Solid,请设置坐标基点以定义运动树计算起点"
type="warning" :closable="false" show-icon class="base-origin-alert" />
<!-- XYZ 可编辑输入 -->
<div class="origin-rows">
<div class="origin-row" v-for="(ax, idx) in axisConfig" :key="ax.key">
<span class="origin-axis-lbl" :style="{ color: ax.color }">{{ ax.key }}</span>
<el-input-number :model-value="editableOrigin[idx]"
@update:model-value="(v: number | undefined) => onAxisInput(idx, v ?? 0)" :precision="4" :step="0.001"
controls-position="right" style="flex: 1; min-width: 0" />
</div>
</div>
<!-- 操作按钮 -->
<div class="origin-actions">
<!-- 向上轴选择 -->
<div class="up-axis-row">
<span class="up-axis-lbl">向上轴</span>
<el-radio-group v-model="baseUpAxis">
<el-radio-button value="Y+">+Y</el-radio-button>
<el-radio-button value="Z+">+Z</el-radio-button>
<el-radio-button value="X+">+X</el-radio-button>
<el-radio-button value="Y-">-Y</el-radio-button>
<el-radio-button value="Z-">-Z</el-radio-button>
<el-radio-button value="X-">-X</el-radio-button>
</el-radio-group>
</div>
<!-- 功能按钮行 -->
<div class="origin-btn-row">
<el-tooltip content="根据已绑定 Solid 的包围盒底面中心自动计算" placement="top">
<el-button type="primary" plain :disabled="link.solidIds.length === 0" @click="autoCalcOrigin">
自动计算
</el-button>
</el-tooltip>
<el-button v-if="urdfStore.baseLinkOrigin" text type="danger" @click="clearBaseOrigin">
清除
</el-button>
</div>
</div>
<!-- 基坐标系 RPY 姿态仅在已设置原点后显示-->
<div v-if="urdfStore.baseLinkOrigin" class="orientation-section">
<div class="orient-header">
<span class="base-origin-title">基坐标系 RPYrad</span>
<el-button text @click="resetRPY">重置</el-button>
</div>
<div class="origin-rows">
<div class="origin-row" v-for="(ax, idx) in rpyConfig" :key="ax.key">
<span class="origin-axis-lbl" :style="{ color: ax.color }">{{ ax.key }}</span>
<el-input-number :model-value="editableRPY[idx]"
@update:model-value="(v: number | undefined) => onRPYInput(idx, v ?? 0)" :precision="4" :step="0.01"
controls-position="right" style="flex: 1; min-width: 0" />
</div>
</div>
</div>
</div>
<el-collapse v-model="openPanels">
<!-- 绑定 Solid -->
<el-collapse-item name="solids">
<template #title>
<span class="section-title">绑定 Solids{{ link.solidIds.length }}</span>
</template>
<div class="prop-form">
<!-- 已绑定列表 -->
<div v-for="solidId in link.solidIds" :key="solidId" class="solid-item">
<el-icon>
<Files />
</el-icon>
<span class="solid-name" :title="getSolidName(solidId)">{{ getSolidName(solidId) }}</span>
<el-button text type="danger" :icon="Delete" @click="handleUnbind(solidId)" class="unbind-btn" />
</div>
<!-- 绑定新 Solid 按钮 -->
<div class="bind-actions">
<el-button v-if="!urdfStore.bindingMode.active" type="primary" plain :icon="Paperclip"
@click="urdfStore.startBindingMode(link.id)">
绑定 Solid
</el-button>
<template v-else-if="urdfStore.bindingMode.targetLinkId === link.id">
<el-button size="default" type="success" @click="urdfStore.stopBindingMode()"> 完成绑定</el-button>
</template>
</div>
<div v-if="link.solidIds.length === 0" class="empty-hint">尚未绑定任何 Solid</div>
</div>
</el-collapse-item>
<!-- 物理属性默认收起 -->
<el-collapse-item name="physics">
<template #title>
<span class="section-title">物理属性</span>
</template>
<div class="prop-form">
<template v-if="link.inertial">
<div class="prop-row">
<span class="prop-label">质量</span>
<span class="prop-value">{{ link.inertial.mass.toFixed(4) }} kg</span>
</div>
<div class="inertia-grid">
<span class="inertia-title">惯性张量kg·</span>
<div class="inertia-row">
<span class="inertia-cell">Ixx: {{ link.inertial.inertia[0].toExponential(3) }}</span>
<span class="inertia-cell">Ixy: {{ link.inertial.inertia[1].toExponential(3) }}</span>
<span class="inertia-cell">Ixz: {{ link.inertial.inertia[2].toExponential(3) }}</span>
</div>
<div class="inertia-row">
<span class="inertia-cell">Iyy: {{ link.inertial.inertia[3].toExponential(3) }}</span>
<span class="inertia-cell">Iyz: {{ link.inertial.inertia[4].toExponential(3) }}</span>
<span class="inertia-cell">Izz: {{ link.inertial.inertia[5].toExponential(3) }}</span>
</div>
</div>
</template>
<div v-else class="empty-hint">请使用左侧整机惯量计算功能统一计算</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<div v-else class="empty-hint">未选中任何连杆</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Box, Files, Delete, Paperclip } from '@element-plus/icons-vue'
import { useURDFStore } from '../../stores/useURDFStore'
import { useStepViewerStore } from '../../stores/useStepViewerStore'
const urdfStore = useURDFStore()
const stepStore = useStepViewerStore()
const openPanels = ref<string[]>(['solids', 'physics']) //
const link = computed(() => {
if (!urdfStore.selectedLinkId) return null
return urdfStore.linkMap.get(urdfStore.selectedLinkId) ?? null
})
//
const editingName = ref(false)
const nameInput = ref('')
function startRename(): void {
nameInput.value = link.value?.name ?? ''
editingName.value = true
}
function finishRename(): void {
if (link.value && nameInput.value.trim()) {
urdfStore.renameLink(link.value.id, nameInput.value.trim())
}
editingName.value = false
}
function cancelRename(): void {
editingName.value = false
}
// Solid
function handleUnbind(solidId: string): void {
if (link.value) urdfStore.unbindSolid(link.value.id, solidId)
}
function getSolidName(solidId: string): string {
return stepStore.solidMap.get(solidId)?.name ?? solidId
}
// Base Origin
const axisConfig = [
{ key: 'X', color: '#f56c6c' },
{ key: 'Y', color: '#67c23a' },
{ key: 'Z', color: '#409eff' }
]
/** RPY 输入标签:R=roll绕X, P=pitch绕Y, Y=yaw绕Z */
const rpyConfig = [
{ key: 'R', color: '#f56c6c' },
{ key: 'P', color: '#67c23a' },
{ key: 'Y', color: '#409eff' }
]
/** 底面运动方向(即哪个轴指向上),自动计算时利用这个轴的最小导 OR 最大导作为底面 */
const baseUpAxis = ref<'Y+' | 'Y-' | 'Z+' | 'Z-' | 'X+' | 'X-'>('Y+')
const editableOrigin = ref<[number, number, number]>([0, 0, 0])
const editableRPY = ref<[number, number, number]>([0, 0, 0])
watch(
() => urdfStore.baseLinkOrigin,
(v) => { editableOrigin.value = v ? [...v] as [number, number, number] : [0, 0, 0] },
{ immediate: true, deep: true }
)
watch(
() => urdfStore.baseLinkRPY,
(v) => { editableRPY.value = v ? [...v] as [number, number, number] : [0, 0, 0] },
{ immediate: true, deep: true }
)
function onAxisInput(idx: number, val: number): void {
const o: [number, number, number] = [...editableOrigin.value] as [number, number, number]
o[idx] = val
editableOrigin.value = o
urdfStore.baseLinkOrigin = [...o] as [number, number, number]
}
function onRPYInput(idx: number, val: number): void {
const d: [number, number, number] = [...editableRPY.value] as [number, number, number]
d[idx] = val
editableRPY.value = d
urdfStore.baseLinkRPY = [...d] as [number, number, number]
}
function resetRPY(): void {
urdfStore.baseLinkRPY = [0, 0, 0]
}
function clearBaseOrigin(): void {
urdfStore.baseLinkOrigin = null
urdfStore.baseLinkRPY = null
}
/** Solid
* 底面 = baseUpAxis 方向的最小/最大截面中心 */
function autoCalcOrigin(): void {
if (!link.value || link.value.solidIds.length === 0) return
let xMin = Infinity, yMin = Infinity, zMin = Infinity
let xMax = -Infinity, yMax = -Infinity, zMax = -Infinity
let found = false
for (const sid of link.value.solidIds) {
const pos = stepStore.solidMap.get(sid)?.serializedData?.positions
if (!pos) continue
found = true
for (let i = 0; i < pos.length; i += 3) {
if (pos[i] < xMin) xMin = pos[i]; if (pos[i] > xMax) xMax = pos[i]
if (pos[i + 1] < yMin) yMin = pos[i + 1]; if (pos[i + 1] > yMax) yMax = pos[i + 1]
if (pos[i + 2] < zMin) zMin = pos[i + 2]; if (pos[i + 2] > zMax) zMax = pos[i + 2]
}
}
if (!found) { ElMessage.warning('未找到有效几何数据'); return }
const round = (v: number) => Math.round(v * 10000) / 10000
const cx = (xMin + xMax) / 2, cy = (yMin + yMax) / 2, cz = (zMin + zMax) / 2
let ox: number, oy: number, oz: number
switch (baseUpAxis.value) {
case 'Y+': ox = cx; oy = yMin; oz = cz; break
case 'Y-': ox = cx; oy = yMax; oz = cz; break
case 'Z+': ox = cx; oy = cy; oz = zMin; break
case 'Z-': ox = cx; oy = cy; oz = zMax; break
case 'X+': ox = xMin; oy = cy; oz = cz; break
case 'X-': ox = xMax; oy = cy; oz = cz; break
default: ox = cx; oy = yMin; oz = cz; break
}
urdfStore.baseLinkOrigin = [round(ox), round(oy), round(oz)]
// RPY 姿ZYX URDF rpy
// R = Rz(yaw)·Ry(pitch)·Rx(roll)·[0,0,1]
// Z+: identity [0,0,0] Z-: Ry(π) [0,π,0]
// Y+: Rx(-π/2) [-π/2,0,0] Y-: Rx(π/2) [π/2,0,0]
// X+: Ry(π/2) [0,π/2,0] X-: Ry(-π/2) [0,-π/2,0]
const rpyMap: Record<string, [number, number, number]> = {
'Z+': [0, 0, 0],
'Z-': [0, Math.PI, 0],
'Y+': [-Math.PI / 2, 0, 0],
'Y-': [Math.PI / 2, 0, 0],
'X+': [0, Math.PI / 2, 0],
'X-': [0, -Math.PI / 2, 0]
}
urdfStore.baseLinkRPY = rpyMap[baseUpAxis.value] ?? [0, 0, 0]
ElMessage.success(`已自动设置基点(${baseUpAxis.value} 底面中心)`)
}
// ViewControls.vue
</script>
<style lang="scss" scoped>
.link-properties {
padding: 4px 0;
}
.link-name-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: #f4f6f9;
border-radius: 4px;
margin-bottom: 8px;
.link-icon {
color: #409eff;
font-size: 14px;
flex-shrink: 0;
}
.link-name {
font-size: 13px;
font-weight: 600;
color: #303133;
cursor: pointer;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: #409eff;
}
}
}
/* Collapse */
:deep(.el-collapse) {
border: none;
.el-collapse-item__header {
height: 32px;
line-height: 32px;
padding: 0 8px;
font-size: 12px;
background: #fafbfc;
border-bottom: 1px solid #f0f2f5;
}
.el-collapse-item__wrap {
border-bottom: none;
}
.el-collapse-item__content {
padding: 6px 8px 8px;
}
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #303133;
}
.prop-form {
display: flex;
flex-direction: column;
gap: 6px;
}
.prop-row {
display: flex;
align-items: center;
gap: 6px;
.prop-label {
font-size: 11px;
color: #606266;
width: 36px;
flex-shrink: 0;
}
.prop-value {
font-size: 12px;
color: #303133;
font-family: monospace;
}
.prop-unit {
font-size: 10px;
color: #909399;
}
}
.solid-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px;
border-radius: 3px;
background: #f9fafc;
border: 1px solid #ebeef5;
.el-icon {
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.solid-name {
font-size: 11px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.unbind-btn {
padding: 1px !important;
height: 18px !important;
width: 18px !important;
min-height: unset !important;
flex-shrink: 0;
}
}
.bind-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.inertia-grid {
.inertia-title {
font-size: 10px;
color: #909399;
display: block;
margin-bottom: 4px;
}
.inertia-row {
display: flex;
gap: 4px;
margin-bottom: 2px;
flex-wrap: wrap;
}
.inertia-cell {
font-size: 10px;
color: #606266;
font-family: monospace;
background: #f4f4f5;
padding: 1px 4px;
border-radius: 2px;
}
}
.empty-hint {
font-size: 11px;
color: #c0c4cc;
text-align: center;
padding: 8px 0;
}
/* Base Origin 区域 */
.base-origin-section {
margin-bottom: 8px;
padding: 6px 8px 8px;
background: linear-gradient(135deg, #f0f9ff 0%, #e8f4fd 100%);
border: 1px solid #b3d8f5;
border-radius: 4px;
.base-origin-alert {
:deep(.el-alert) {
padding: 4px 8px;
font-size: 11px;
}
margin-bottom: 6px;
}
}
.base-origin-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.base-origin-title {
font-size: 11px;
font-weight: 600;
color: #1a6fb0;
flex: 1;
}
.origin-rows {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 6px;
}
.origin-row {
display: flex;
align-items: center;
gap: 5px;
}
.origin-axis-lbl {
font-size: 11px;
font-weight: 700;
font-family: monospace;
width: 12px;
flex-shrink: 0;
text-align: center;
}
.origin-actions {
display: flex;
flex-direction: column;
gap: 5px;
}
.up-axis-row {
display: flex;
align-items: center;
gap: 6px;
}
.up-axis-lbl {
font-size: 16px;
color: #606266;
flex-shrink: 0;
}
:deep(.up-axis-row .el-radio-button__inner) {
padding: 2px 6px;
font-size: 16px;
}
.origin-btn-row {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.orientation-section {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed #b3d8f5;
}
.orient-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
</style>
@@ -0,0 +1,167 @@
<template>
<div class="urdf-right-panel">
<!-- ===== 上部上下文属性面板Link / Joint ===== -->
<div class="panel-section expanded">
<div class="section-header">
<span class="section-title">{{ contextTitle }}</span>
</div>
<div class="section-body">
<URDFJointProperties v-if="urdfStore.selectedJointId" @flip-normal="$emit('flipNormal')" />
<URDFLinkProperties v-else-if="urdfStore.selectedLinkId" />
<div v-else class="empty-hint context-empty">
<el-icon style="font-size: 24px; color: #dcdfe6">
<Connection />
</el-icon>
<p>点击左侧树节点</p>
<p>查看或编辑属性</p>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="section-divider" />
<!-- ===== 下部关节控制打开按钮 ===== -->
<div class="fk-launch-bar">
<el-button type="primary" plain @click="$emit('toggleFKPanel')">
关节控制面板
</el-button>
<span v-if="urdfStore.activeJoints.length" class="fk-count">{{ urdfStore.activeJoints.length }} 个可控关节</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Connection } from '@element-plus/icons-vue'
import { useURDFStore } from '../../stores/useURDFStore'
import URDFJointProperties from './URDFJointProperties.vue'
import URDFLinkProperties from './URDFLinkProperties.vue'
const emit = defineEmits<{
(e: 'flipNormal'): void
(e: 'toggleFKPanel'): void
}>()
const urdfStore = useURDFStore()
const contextTitle = computed(() => {
if (urdfStore.selectedJointId) {
const j = urdfStore.jointMap.get(urdfStore.selectedJointId)
return `${j?.name ?? 'Joint 属性'}`
}
if (urdfStore.selectedLinkId) {
const l = urdfStore.linkMap.get(urdfStore.selectedLinkId)
return `${l?.name ?? 'Link 属性'}`
}
return '属性面板'
})
</script>
<style lang="scss" scoped>
.urdf-right-panel {
width: 300px;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
border-left: 1px solid #e4e7ed;
overflow: hidden;
flex-shrink: 0;
}
/* ——— 面板区域 ——— */
.panel-section {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 36px;
&.expanded {
flex: 1;
}
}
.section-divider {
flex-shrink: 0;
height: 1px;
background: #e4e7ed;
}
/* ——— 区域标题 ——— */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
height: 36px;
background: #fafafa;
border-bottom: 1px solid #f0f2f5;
user-select: none;
flex-shrink: 0;
.section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
}
/* ——— 区域内容 ——— */
.section-body {
flex: 1;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 2px;
}
}
.empty-hint {
font-size: 12px;
color: #909399;
text-align: center;
padding: 12px 0;
}
.context-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 24px 0;
p {
margin: 0;
font-size: 12px;
color: #c0c4cc;
}
}
.fk-launch-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #fafafa;
border-top: 1px solid #f0f2f5;
.fk-count {
font-size: 11px;
color: #909399;
}
}
</style>
@@ -0,0 +1,314 @@
<template>
<div class="view-controls">
<div class="control-row">
<span class="control-label">显示关节坐标系</span>
<el-switch v-model="urdfStore.showFrames" />
</div>
<div class="control-row axis-row">
<span class="control-label">轴长库尺</span>
<el-slider v-model="urdfStore.axisHelperScale" :min="0.1" :max="5" :step="0.1" :show-tooltip="true"
:format-tooltip="(v: number) => v.toFixed(1) + 'x'" style="flex: 1; min-width: 60px" />
<span class="axis-value">{{ urdfStore.axisHelperScale.toFixed(1) }}x</span>
</div>
<!-- 整机惯量计算入口 -->
<div class="control-row">
<el-button type="primary" plain style="width: 100%" @click="openInertiaDialog">
整机惯量计算
</el-button>
</div>
</div>
<!-- 整机惯量计算对话框 -->
<el-dialog v-model="inertiaDialogVisible" title="整机惯量计算" width="600px" :close-on-click-modal="false" append-to-body>
<div class="inertia-dialog-body">
<el-alert title="按各连杆体积比自动分配质量,并计算惯性张量" type="info" :closable="false" show-icon style="margin-bottom: 12px" />
<!-- 总质量输入 -->
<div class="param-row">
<span class="param-label">整机总质量</span>
<el-input-number v-model="totalMass" :min="0.001" :max="100000" :precision="3" :step="1"
controls-position="right" style="width: 160px" />
<span class="param-unit">kg</span>
</div>
<!-- 计算进度 -->
<div v-if="computing" class="progress-row">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>{{ progressText }}</span>
</div>
<!-- 结果表格 -->
<div v-if="computedResults.length > 0" class="result-section">
<el-divider style="margin: 10px 0" />
<div class="result-header">
<span class="result-title">计算结果 {{ computedResults.length }} 个连杆</span>
<el-button type="success" plain @click="applyResults">应用到所有连杆</el-button>
</div>
<el-table :data="computedResults" :row-key="(row: ResultRow) => row.linkId" style="margin-top: 4px">
<el-table-column prop="name" label="连杆" min-width="80" show-overflow-tooltip align="center" />
<el-table-column label="质量 (kg)" width="148" align="center">
<template #default="{ row }">
<el-input-number v-model="row.mass" :min="0.0001" :max="100000" :precision="4" :step="0.1"
controls-position="right" style="width: 136px" @change="recalcInertia(row)" size="" />
</template>
</el-table-column>
<el-table-column label="质心 (m)" min-width="150" align="center">
<template #default="{ row }">
{{ formatCom(row.com) }}
</template>
</el-table-column>
</el-table>
</div>
</div>
<template #footer>
<el-button @click="inertiaDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="computing" :disabled="totalMass <= 0" @click="runCompute">
开始计算
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { useURDFStore } from '../../stores/useURDFStore'
import { useStepViewerStore } from '../../stores/useStepViewerStore'
import { computeRefInertias } from '../../core/useInertiaWorker'
import type { SerializedSolidData, InertialParams } from '../../types'
const urdfStore = useURDFStore()
const stepStore = useStepViewerStore()
function formatCom(com: [number, number, number]): string {
return com.map(v => v.toFixed(3)).join(', ')
}
//
const inertiaDialogVisible = ref(false)
const totalMass = ref(10)
const computing = ref(false)
const progressText = ref('')
interface ResultRow {
linkId: string
name: string
/** 当前(可编辑)质量 */
mass: number
com: [number, number, number]
/** 当前惯性张量(随 mass 自动缩放) */
inertia: InertialParams['inertia']
/** density=1 时的参考质量(正比于体积),用于按比例反推惯性 */
refMass: number
/** density=1 时的参考惯性张量 */
refInertia: InertialParams['inertia']
}
const computedResults = ref<ResultRow[]>([])
function openInertiaDialog(): void {
computedResults.value = []
inertiaDialogVisible.value = true
}
async function runCompute(): Promise<void> {
if (computing.value) return
// isRerun
// computedResults.value = []
const existingMassMap = new Map(computedResults.value.map(r => [r.linkId, r.mass]))
const isRerun = computedResults.value.length > 0
computing.value = true
progressText.value = '正在收集几何数据…'
computedResults.value = []
try {
// Link
const linkInputs = urdfStore.robot.links
.filter(l => l.solidIds.length > 0)
.map(l => ({
linkId: l.id,
solidDataList: l.solidIds
.map(sid => stepStore.solidMap.get(sid)?.serializedData)
.filter((d): d is SerializedSolidData => !!d),
}))
.filter(l => l.solidDataList.length > 0)
if (linkInputs.length === 0) {
ElMessage.warning('没有绑定几何体的连杆,无法计算')
return
}
progressText.value = `正在计算 ${linkInputs.length} 个连杆的参考惯量…`
// density=1 totalMass
const refMap = await computeRefInertias(linkInputs)
if (refMap.size === 0) {
ElMessage.warning('计算结果为空,请检查各连杆是否绑定了有效的几何体')
return
}
//
const totalRefMass = [...refMap.values()].reduce((s, r) => s + r.mass, 0)
const newResults: ResultRow[] = []
for (const [linkId, refParams] of refMap) {
let targetMass: number
if (isRerun && existingMassMap.has(linkId)) {
//
targetMass = existingMassMap.get(linkId)!
} else {
// totalMass
targetMass = (refParams.mass / totalRefMass) * totalMass.value
}
const k = targetMass / refParams.mass
const inertia = refParams.inertia.map(v => v * k) as InertialParams['inertia']
newResults.push({
linkId,
name: urdfStore.linkMap.get(linkId)?.name ?? linkId,
mass: targetMass,
com: refParams.com,
inertia,
// / recalcInertia 使
refMass: targetMass,
refInertia: inertia,
})
}
computedResults.value = newResults
//
totalMass.value = parseFloat(newResults.reduce((s, r) => s + r.mass, 0).toFixed(6))
const hint = isRerun ? '(已保留手动编辑的质量)' : ''
ElMessage.success(`计算完成,共 ${newResults.length} 个连杆${hint}`)
} catch (e) {
ElMessage.error(`计算失败: ${(e as Error).message}`)
} finally {
computing.value = false
progressText.value = ''
}
}
/**
* 用户手动修改某行质量后按原始参考值的比例重算该行惯性张量
* 惯性张量与质量成线性关系同密度假设下体积固定
* inertia_new = refInertia × (mass_new / refMass)
* 质心几何中心与质量无关无需修改
* 同步更新整机总质量为各行之和保持数据一致
*/
function recalcInertia(row: ResultRow): void {
if (row.refMass <= 0 || !Number.isFinite(row.mass) || row.mass <= 0) return
const k = row.mass / row.refMass
row.inertia = row.refInertia.map(v => v * k) as InertialParams['inertia']
// =
totalMass.value = parseFloat(
computedResults.value.reduce((s, r) => s + r.mass, 0).toFixed(6)
)
}
function applyResults(): void {
let count = 0
for (const row of computedResults.value) {
urdfStore.setLinkInertial(row.linkId, {
mass: row.mass,
com: row.com,
inertia: row.inertia,
})
count++
}
ElMessage.success(`已将惯性参数应用到 ${count} 个连杆`)
inertiaDialogVisible.value = false
}
</script>
<style lang="scss" scoped>
.view-controls {
padding: 4px 8px;
}
.control-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 0;
font-size: 12px;
color: #303133;
}
.control-label {
flex-shrink: 0;
font-size: 14px;
}
.axis-row {
padding-top: 2px;
}
.axis-value {
font-size: 10px;
color: #909399;
flex-shrink: 0;
width: 26px;
text-align: right;
}
.inertia-dialog-body {
padding: 0 4px;
}
.param-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.param-label {
flex-shrink: 0;
font-size: 16px;
color: #303133;
width: 80px;
}
.param-unit {
font-size: 12px;
color: #606266;
}
.progress-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #409eff;
margin-bottom: 8px;
}
.result-section {
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.result-title {
font-size: 16px;
color: #606266;
}
.edit-hint {
margin: 4px 0 0;
font-size: 11px;
color: #909399;
}
}
</style>
@@ -0,0 +1,269 @@
<template>
<el-dialog
v-model="visible"
title="智谱建议 Links / Joints"
width="560px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
class="zhipu-assist-dialog"
>
<p class="hint">
<b>A7</b>直接套用 <code>ARM7_urdf.urdf</code> 金标关节不调智谱并按模型最长轴绑定 Solid
<b>generic</b>仍由智谱自由生成
</p>
<el-form label-position="top" size="default">
<el-form-item label="Profile">
<el-radio-group v-model="profile">
<el-radio-button value="a7">A7</el-radio-button>
<el-radio-button value="generic">generic</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="机器人名">
<el-input v-model="robotName" placeholder="A7" />
</el-form-item>
<el-form-item label="额外提示(可选)">
<el-input
v-model="extraHint"
type="textarea"
:rows="3"
placeholder="generic 模式才需要;A7 金标可留空"
/>
</el-form-item>
<el-form-item :label="`零件名(${partNames.length}`">
<el-input
v-model="partNamesText"
type="textarea"
:rows="5"
placeholder="从模型自动填充;可手工增删,每行一个"
/>
</el-form-item>
</el-form>
<div v-if="lastNotes.length" class="notes-block">
<div class="notes-title">Notes</div>
<ul>
<li v-for="(n, i) in lastNotes" :key="i">{{ n }}</li>
</ul>
</div>
<div v-if="previewJoints.length" class="preview-block">
<div class="notes-title">建议关节预览</div>
<el-table :data="previewJoints" size="small" max-height="220" stripe>
<el-table-column prop="name" label="name" width="90" />
<el-table-column prop="joint_type" label="type" width="80" />
<el-table-column prop="parent" label="parent" width="80" />
<el-table-column prop="child" label="child" width="80" />
<el-table-column label="axis" width="110">
<template #default="{ row }">
{{ (row.axis || []).map((x: number) => Number(x).toFixed(0)).join(',') }}
</template>
</el-table-column>
<el-table-column label="xyz" min-width="120">
<template #default="{ row }">
{{ (row.origin_xyz || []).map((x: number) => Number(x).toFixed(2)).join(', ') }}
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button :loading="loading" @click="runPropose">
{{ profile === 'a7' ? '生成 ARM7 金标' : '请求智谱建议' }}
</el-button>
<el-button
type="primary"
:disabled="!lastDraft"
:loading="applying"
@click="applyDraft"
>
应用到结构树
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useStepViewerStore } from '../../stores/useStepViewerStore'
import { useURDFStore } from '../../stores/useURDFStore'
import {
applyProposeDraft,
collectSolidsGeom,
type ProposeDraft,
type ProposeJoint
} from '../../utils/applyZhipuDraft'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const viewer = useStepViewerStore()
const urdf = useURDFStore()
const profile = ref<'a7' | 'generic'>('a7')
const robotName = ref('ARM7_urdf')
const extraHint = ref('')
const partNamesText = ref('')
const loading = ref(false)
const applying = ref(false)
const lastDraft = ref<ProposeDraft | null>(null)
const lastNotes = ref<string[]>([])
const previewJoints = ref<ProposeJoint[]>([])
const partNames = computed(() =>
partNamesText.value
.split(/\r?\n/)
.map(s => s.trim())
.filter(Boolean)
)
function collectPartNames(): string[] {
const fromSolids = viewer.solids.map(s => s.name).filter(Boolean)
const fromTree: string[] = []
const walk = (nodes: { name?: string; type?: string; children?: any[] }[]) => {
for (const n of nodes) {
if (n.name && (n.type === 'solid' || n.type === 'compound')) {
fromTree.push(n.name)
}
if (n.children) walk(n.children)
}
}
walk(viewer.treeNodes as any[])
const seen = new Set<string>()
const out: string[] = []
for (const n of [...fromSolids, ...fromTree]) {
if (!seen.has(n)) {
seen.add(n)
out.push(n)
}
}
return out
}
watch(
() => props.modelValue,
open => {
if (open) {
partNamesText.value = collectPartNames().join('\n')
if (!robotName.value) robotName.value = urdf.robot.name || 'A7'
lastDraft.value = null
lastNotes.value = []
previewJoints.value = []
}
}
)
async function runPropose(): Promise<void> {
if (!partNames.value.length) {
ElMessage.warning('没有零件名可用,请先导入 STEP')
return
}
loading.value = true
lastDraft.value = null
previewJoints.value = []
lastNotes.value = []
try {
const resp = await fetch('/api/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
part_names: partNames.value,
profile: profile.value,
robot_name: robotName.value || 'robot',
extra_hint: extraHint.value,
solids: collectSolidsGeom(viewer.solids)
})
})
if (!resp.ok) {
const text = await resp.text()
throw new Error(text || `HTTP ${resp.status}`)
}
const data = (await resp.json()) as ProposeDraft & { notes?: string[] }
lastDraft.value = data
lastNotes.value = data.notes || []
previewJoints.value = data.joints || []
ElMessage.success(
profile.value === 'a7'
? `金标就绪:${(data.links || []).length} links / ${(data.joints || []).length} joints`
: `已收到建议:${(data.links || []).length} links / ${(data.joints || []).length} joints`
)
} catch (e: any) {
ElMessage.error(`智谱请求失败:${e?.message || e}`)
} finally {
loading.value = false
}
}
async function applyDraft(): Promise<void> {
if (!lastDraft.value) return
try {
await ElMessageBox.confirm(
profile.value === 'a7'
? '将写入 ARM7 金标关节,并按高度绑定 Solid。是否继续?'
: '将用智谱生成的 Link/Joint 替换当前结构树。是否继续?',
'应用到结构树',
{ type: 'warning', confirmButtonText: '应用', cancelButtonText: '取消' }
)
} catch {
return
}
applying.value = true
try {
const result = applyProposeDraft(lastDraft.value)
if (!result.ok) {
ElMessage.error(result.warnings.join('; ') || '应用失败')
return
}
const extra = [
`Links ${result.linksCreated}`,
`Joints ${result.jointsCreated}`,
`绑定 Solid ${result.solidsBound}`
]
if (result.unboundParts.length) {
extra.push(`未匹配零件 ${result.unboundParts.length}`)
}
if (result.skippedJoints.length) {
extra.push(`跳过关节 ${result.skippedJoints.length}`)
}
ElMessage.success(extra.join(' · '))
for (const w of result.warnings) {
ElMessage.warning(w)
}
visible.value = false
} finally {
applying.value = false
}
}
</script>
<style scoped>
.hint {
margin: 0 0 12px;
font-size: 13px;
color: #606266;
line-height: 1.5;
}
.notes-block,
.preview-block {
margin-top: 12px;
}
.notes-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.notes-block ul {
margin: 0;
padding-left: 18px;
font-size: 12px;
color: #606266;
}
</style>
@@ -0,0 +1,355 @@
/**
* URDF Composable
* ForwardKinematics / FrameVisualizer / JointSnapVisualizer
* URDF
*/
import * as THREE from 'three'
import { ElMessage } from 'element-plus'
import { FrameVisualizer } from '../../core/FrameVisualizer'
import { ForwardKinematics } from '../../core/ForwardKinematics'
import { JointSnapVisualizer } from '../../core/JointSnapVisualizer'
import { computeRelativeTransform } from '../../core/useKinematicsWorker'
import { exportURDFInWorker, disposeExportWorker } from '../../core/useExportWorker'
import { serializeURDF } from '../../core/URDFSerializer'
import { useStepViewerStore } from '../../stores/useStepViewerStore'
import { useURDFStore } from '../../stores/useURDFStore'
import type { SceneManager, SelectionManager } from '../../core'
import type { GeometryFeature, SnapData } from '../../types'
interface UseURDFSceneDeps {
getSceneManager: () => SceneManager | null
getSelectionManager: () => SelectionManager | null
}
export function useURDFScene(deps: UseURDFSceneDeps) {
const store = useStepViewerStore()
const urdfStore = useURDFStore()
let frameVisualizer: FrameVisualizer | null = null
let forwardKinematics: ForwardKinematics | null = null
let snapVisualizer: JointSnapVisualizer | null = null
let baseAxisLength = 0.05
let edgePickMode = false
let currentSnapData: SnapData | null = null
function getFK(): ForwardKinematics | null { return forwardKinematics }
function isEdgePickMode(): boolean { return edgePickMode }
function getSnapData(): SnapData | null { return currentSnapData }
function getBaseAxisLength(): number { return baseAxisLength }
// ========== 生命周期 ==========
function initModules(): void {
const sm = deps.getSceneManager()
if (!sm) return
const box = new THREE.Box3().setFromObject(sm.modelGroup)
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
baseAxisLength = maxDim > 0 ? maxDim * 0.05 : 0.05
const axisLength = baseAxisLength * urdfStore.axisHelperScale
frameVisualizer?.dispose()
frameVisualizer = new FrameVisualizer({ scene: sm.scene, axisLength })
if (!forwardKinematics) {
forwardKinematics = new ForwardKinematics()
}
snapVisualizer?.dispose()
snapVisualizer = new JointSnapVisualizer({ scene: sm.scene, axisLength })
forwardKinematics.setRobot(urdfStore.robot)
frameVisualizer.setVisible(urdfStore.showFrames)
updateFKAndFrames()
}
function updateFKAndFrames(): void {
const sm = deps.getSceneManager()
if (!forwardKinematics || !sm) return
forwardKinematics.setRobot(urdfStore.robot)
const transforms = forwardKinematics.compute()
urdfStore.linkWorldTransforms = transforms
forwardKinematics.applyToScene(transforms, urdfStore.robot.links, store.solidMap)
if (frameVisualizer && urdfStore.showFrames) {
frameVisualizer.showAllFrames(urdfStore.robot.joints)
for (const joint of urdfStore.robot.joints) {
const wm = forwardKinematics.getJointWorldMatrix(joint.id)
if (wm) frameVisualizer.updateFrameTransform(joint.id, wm)
}
frameVisualizer.showBaseFrame(urdfStore.baseLinkOrigin, urdfStore.baseLinkRPY ?? undefined)
}
sm.markDirty()
}
function disposeModules(): void {
frameVisualizer?.dispose()
frameVisualizer = null
snapVisualizer?.dispose()
snapVisualizer = null
forwardKinematics = null
currentSnapData = null
edgePickMode = false
disposeExportWorker()
}
// ========== Frame Visualizer 控制 ==========
function setFrameVisible(visible: boolean): void {
frameVisualizer?.setVisible(visible)
}
function setAxisLength(scale: number): void {
if (frameVisualizer) {
frameVisualizer.setAxisLength(baseAxisLength * scale)
updateFKAndFrames()
}
}
// ========== Snap Visualizer ==========
/** 处理 hover 事件的 snap 更新(从 initViewer 的 hover 回调调用) */
function handleHoverSnap(feature: GeometryFeature | null): void {
const sm = deps.getSceneManager()
if (!edgePickMode || !snapVisualizer) {
snapVisualizer?.hide()
currentSnapData = null
return
}
if (!feature) {
snapVisualizer.hide()
currentSnapData = null
sm?.markDirty()
return
}
if (feature.edgeCurveType === 'circle' || feature.edgeCurveType === 'arc') {
if (feature.center && (feature.axis || feature.normal)) {
const pos = feature.center
const norm = (feature.axis || feature.normal)!
snapVisualizer.updateSnap(pos, norm)
currentSnapData = {
position: [pos.x, pos.y, pos.z],
normal: [norm.x, norm.y, norm.z],
featureType: feature.edgeCurveType as 'circle' | 'arc'
}
sm?.markDirty()
}
} else if (feature.edgeCurveType === 'line') {
if (feature.startPoint && feature.endPoint) {
const pos = feature.startPoint
const dir = feature.endPoint.clone().sub(feature.startPoint).normalize()
snapVisualizer.updateSnap(pos, dir)
currentSnapData = {
position: [pos.x, pos.y, pos.z],
normal: [dir.x, dir.y, dir.z],
featureType: 'line'
}
sm?.markDirty()
}
} else {
snapVisualizer.hide()
currentSnapData = null
}
}
function flipNormal(): void {
if (!snapVisualizer?.isVisible()) return
snapVisualizer.flipNormal()
if (currentSnapData) {
const n = snapVisualizer.getCurrentNormal()
currentSnapData.normal = [n.x, n.y, n.z]
}
deps.getSceneManager()?.markDirty()
}
// ========== 绑定模式 ==========
function handleBindingClick(feature: GeometryFeature): void {
if (!urdfStore.bindingMode.active || !urdfStore.bindingMode.targetLinkId) return
if (!feature.solidId) return
if (urdfStore.boundSolidIds.has(feature.solidId)) {
ElMessage.warning('该 Solid 已被其他 Link 绑定')
return
}
urdfStore.bindSolid(urdfStore.bindingMode.targetLinkId, feature.solidId)
}
// ========== 边拾取模式 ==========
function startEdgePickMode(): void {
edgePickMode = true
deps.getSelectionManager()?.setGranularityMode('edge')
}
function stopEdgePickMode(): void {
edgePickMode = false
urdfStore.edgePickEditJointId = null
snapVisualizer?.hide()
currentSnapData = null
deps.getSelectionManager()?.setGranularityMode('solid')
deps.getSceneManager()?.markDirty()
}
async function applyPickedEdgeToExistingJoint(jointId: string, feature: GeometryFeature): Promise<void> {
const joint = urdfStore.jointMap.get(jointId)
if (!joint) return
let snapPos: [number, number, number]
let snapNorm: [number, number, number]
if (feature.edgeCurveType === 'line') {
if (!feature.startPoint || !feature.endPoint) return
const dir = feature.endPoint.clone().sub(feature.startPoint).normalize()
snapPos = [feature.startPoint.x, feature.startPoint.y, feature.startPoint.z]
snapNorm = [dir.x, dir.y, dir.z]
} else {
if (!feature.center || (!feature.axis && !feature.normal)) return
const norm = (feature.axis || feature.normal)!
snapPos = [feature.center.x, feature.center.y, feature.center.z]
snapNorm = [norm.x, norm.y, norm.z]
}
const parentWorld = urdfStore.linkWorldTransforms.get(joint.parentLinkId)
const parentElements = parentWorld ? parentWorld.elements : new THREE.Matrix4().elements
const result = await computeRelativeTransform(parentElements, snapPos, snapNorm)
joint.origin.xyz = result.xyz
joint.origin.rpy = result.rpy
joint.axis = [0, 0, 1]
ElMessage.success('已更新关节参数')
}
function handleJointCreated(_jointId: string): void {
urdfStore.showFrames = true
snapVisualizer?.hide()
currentSnapData = null
updateFKAndFrames()
}
// ========== URDF 导出 ==========
async function handleExportURDF(exportCompleteAdVisible: { value: boolean }): Promise<void> {
const orphans = urdfStore.findOrphanLinks()
if (orphans.length > 0) {
ElMessage.warning(`以下 Link 未被任何 Joint 连接: ${orphans.join(', ')}`)
}
urdfStore.exporting = true
urdfStore.exportProgress = '正在生成 URDF...'
const savedValues = urdfStore.robot.joints.map(j => j.currentValue)
urdfStore.robot.joints.forEach(j => { j.currentValue = 0 })
try {
const fk = forwardKinematics ?? new ForwardKinematics()
fk.setRobot(urdfStore.robot)
const linkRestInverses = new Map<string, THREE.Matrix4>()
for (const link of urdfStore.robot.links) {
const rest = fk.getLinkRestTransform(link.id)
if (rest) linkRestInverses.set(link.id, rest.clone().invert())
}
let basePoseInverseForExport: THREE.Matrix4 | undefined
const bOrigin = urdfStore.baseLinkOrigin
const bRPY = urdfStore.baseLinkRPY
if (bOrigin || bRPY) {
const o = bOrigin ?? [0, 0, 0]
const r = bRPY ?? [0, 0, 0]
const T = new THREE.Matrix4().makeTranslation(o[0], o[1], o[2])
const R = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(r[0], r[1], r[2], 'ZYX'))
const basePoseMatrix = new THREE.Matrix4().multiplyMatrices(T, R)
basePoseInverseForExport = basePoseMatrix.clone().invert()
linkRestInverses.set(urdfStore.BASE_LINK_ID, basePoseInverseForExport)
}
const urdfXml = serializeURDF(urdfStore.robot, {
linkRestInverses,
unitScale: 0.001,
basePoseInverse: basePoseInverseForExport,
baseLinkId: urdfStore.BASE_LINK_ID
})
const linkSolidMap: Record<string, import('../../types').SerializedSolidData[]> = {}
const linkRestInverseMap: Record<string, number[]> = {}
for (const link of urdfStore.robot.links) {
if (link.solidIds.length === 0) continue
const solidDataList: import('../../types').SerializedSolidData[] = []
for (const solidId of link.solidIds) {
const solid = store.solidMap.get(solidId)
if (solid?.serializedData) solidDataList.push(solid.serializedData)
}
if (solidDataList.length > 0) {
linkSolidMap[link.name] = solidDataList
const inv = linkRestInverses.get(link.id)
if (inv) linkRestInverseMap[link.name] = Array.from(inv.elements)
}
}
const zipBuffer = await exportURDFInWorker(
urdfXml,
linkSolidMap,
linkRestInverseMap,
0.001,
(stage, _percent) => { urdfStore.exportProgress = stage }
)
const blob = new Blob([zipBuffer], { type: 'application/zip' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${urdfStore.robot.name}.zip`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('URDF 导出成功')
exportCompleteAdVisible.value = true
} catch (err) {
ElMessage.error(`导出失败: ${(err as Error).message}`)
} finally {
urdfStore.robot.joints.forEach((j, i) => { j.currentValue = savedValues[i] })
updateFKAndFrames()
urdfStore.exporting = false
urdfStore.exportProgress = ''
}
}
return {
// 生命周期
initModules,
updateFKAndFrames,
disposeModules,
// 访问器
getFK,
isEdgePickMode,
getSnapData,
getBaseAxisLength,
// Frame 控制
setFrameVisible,
setAxisLength,
// Snap / Hover
handleHoverSnap,
flipNormal,
// 绑定 & 边拾取
handleBindingClick,
startEdgePickMode,
stopEdgePickMode,
applyPickedEdgeToExistingJoint,
handleJointCreated,
// 导出
handleExportURDF,
}
}
@@ -0,0 +1,85 @@
/**
* BVH
* 使 three-mesh-bvh 线 hover
*
*
* - 原始射线检测: O(n) -
* - BVH 加速: O(log n) - 使
*
* 158K :
* - : ~158,000
* - BVH: ~17 (log 158000 17)
*/
import * as THREE from 'three'
import {
computeBoundsTree,
disposeBoundsTree,
acceleratedRaycast,
MeshBVH,
type MeshBVHOptions
} from 'three-mesh-bvh'
// 标记 BVH 是否已初始化
let bvhInitialized = false
/**
* BVH
* BVH Three.js
*/
export function initBVH(): void {
if (bvhInitialized) return
// 扩展 BufferGeometry 原型(使用类型断言避免类型冲突)
const BufferGeometryProto = THREE.BufferGeometry.prototype as any
BufferGeometryProto.computeBoundsTree = computeBoundsTree
BufferGeometryProto.disposeBoundsTree = disposeBoundsTree
// 使用加速的射线检测替换默认实现
THREE.Mesh.prototype.raycast = acceleratedRaycast
bvhInitialized = true
console.log('BVH 加速已初始化')
}
/**
* BVH
* @param geometry
* @param options BVH
*/
export function buildBVH(geometry: THREE.BufferGeometry, options?: MeshBVHOptions): void {
if (!bvhInitialized) {
initBVH()
}
// 默认选项:优化查询性能
const defaultOptions: MeshBVHOptions = {
maxLeafSize: 10, // 每个叶节点最多 10 个三角形(替代已弃用的 maxLeafTris
strategy: 0, // SAH 策略,平衡构建时间和查询性能
...options
}
try {
console.time('BVH 构建')
; (geometry as any).computeBoundsTree(defaultOptions)
console.timeEnd('BVH 构建')
} catch (error) {
console.warn('BVH 构建失败,将使用默认射线检测:', error)
}
}
/**
* BVH
*/
export function disposeBVH(geometry: THREE.BufferGeometry): void {
if ((geometry as any).boundsTree) {
; (geometry as any).disposeBoundsTree()
}
}
/**
* BVH
*/
export function hasBVH(geometry: THREE.BufferGeometry): boolean {
return !!(geometry as any).boundsTree
}
@@ -0,0 +1,140 @@
/**
* URDF Web Worker
* Worker 线 STL ZIP线
*/
import * as Comlink from 'comlink'
import JSZip from 'jszip'
import type { SerializedSolidData } from '../types'
/**
* binary STL
* @param restInverseElements 4x4 Link
* @param unitScale 0.001 = mm m
*/
function generateBinarySTL(
solidDataList: SerializedSolidData[],
restInverseElements?: ArrayLike<number>,
unitScale: number = 1
): ArrayBuffer {
let totalTriangles = 0
for (const sd of solidDataList) totalTriangles += sd.indices.length / 3
const bufferSize = 80 + 4 + totalTriangles * 50
const buffer = new ArrayBuffer(bufferSize)
const view = new DataView(buffer)
let offset = 80
view.setUint32(offset, totalTriangles, true)
offset += 4
const hasTransform = !!restInverseElements
const me = restInverseElements ?? new Float64Array(16)
const sc = unitScale
for (const sd of solidDataList) {
const pos = sd.positions, idx = sd.indices
for (let t = 0, n = idx.length / 3; t < n; t++) {
const i0 = idx[t * 3], i1 = idx[t * 3 + 1], i2 = idx[t * 3 + 2]
let p0x = pos[i0 * 3], p0y = pos[i0 * 3 + 1], p0z = pos[i0 * 3 + 2]
let p1x = pos[i1 * 3], p1y = pos[i1 * 3 + 1], p1z = pos[i1 * 3 + 2]
let p2x = pos[i2 * 3], p2y = pos[i2 * 3 + 1], p2z = pos[i2 * 3 + 2]
if (hasTransform) {
const _p0x = me[0] * p0x + me[4] * p0y + me[8] * p0z + me[12]
const _p0y = me[1] * p0x + me[5] * p0y + me[9] * p0z + me[13]
const _p0z = me[2] * p0x + me[6] * p0y + me[10] * p0z + me[14]
p0x = _p0x; p0y = _p0y; p0z = _p0z
const _p1x = me[0] * p1x + me[4] * p1y + me[8] * p1z + me[12]
const _p1y = me[1] * p1x + me[5] * p1y + me[9] * p1z + me[13]
const _p1z = me[2] * p1x + me[6] * p1y + me[10] * p1z + me[14]
p1x = _p1x; p1y = _p1y; p1z = _p1z
const _p2x = me[0] * p2x + me[4] * p2y + me[8] * p2z + me[12]
const _p2y = me[1] * p2x + me[5] * p2y + me[9] * p2z + me[13]
const _p2z = me[2] * p2x + me[6] * p2y + me[10] * p2z + me[14]
p2x = _p2x; p2y = _p2y; p2z = _p2z
}
// 面法线
const ax = p1x - p0x, ay = p1y - p0y, az = p1z - p0z
const bx = p2x - p0x, by = p2y - p0y, bz = p2z - p0z
let nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
nx /= len; ny /= len; nz /= len
view.setFloat32(offset, nx, true); offset += 4
view.setFloat32(offset, ny, true); offset += 4
view.setFloat32(offset, nz, true); offset += 4
view.setFloat32(offset, p0x * sc, true); offset += 4
view.setFloat32(offset, p0y * sc, true); offset += 4
view.setFloat32(offset, p0z * sc, true); offset += 4
view.setFloat32(offset, p1x * sc, true); offset += 4
view.setFloat32(offset, p1y * sc, true); offset += 4
view.setFloat32(offset, p1z * sc, true); offset += 4
view.setFloat32(offset, p2x * sc, true); offset += 4
view.setFloat32(offset, p2y * sc, true); offset += 4
view.setFloat32(offset, p2z * sc, true); offset += 4
view.setUint16(offset, 0, true); offset += 2
}
}
return buffer
}
const workerApi = {
/**
* URDF ZIP
* @param urdfXml URDF XML
* @param linkSolidMap linkName solid
* @param linkRestInverseMap linkName 4x4 elements
* @param unitScale mm m 0.001
* @param onProgress
*/
async exportURDF(
urdfXml: string,
linkSolidMap: Record<string, SerializedSolidData[]>,
linkRestInverseMap: Record<string, number[]>,
unitScale: number,
onProgress?: (stage: string, percent: number) => void
): Promise<ArrayBuffer> {
const zip = new JSZip()
zip.file('robot.urdf', urdfXml)
const linkNames = Object.keys(linkSolidMap)
const total = linkNames.length
for (let i = 0; i < total; i++) {
const linkName = linkNames[i]
const solidDataList = linkSolidMap[linkName]
if (solidDataList.length === 0) continue
onProgress?.(`正在生成 ${linkName}.stl...`, Math.round(((i + 1) / total) * 80))
const restInverse = linkRestInverseMap[linkName]
const stlBuffer = generateBinarySTL(solidDataList, restInverse, unitScale)
zip.file(`meshes/${linkName}.stl`, stlBuffer)
}
onProgress?.('正在打包 ZIP...', 90)
const zipBuffer = await zip.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
onProgress?.('导出完成', 100)
return zipBuffer
}
}
export type ExportWorkerApi = typeof workerApi
Comlink.expose(workerApi)
@@ -0,0 +1,266 @@
/**
* (Forward Kinematics)
* URDF Joint Link
*/
import * as THREE from 'three'
import type { URDFRobot, URDFJoint, URDFLink, SolidObject } from '../types'
export class ForwardKinematics {
private robot: URDFRobot | null = null
/** 缓存:linkId → world Matrix4 */
private linkTransforms = new Map<string, THREE.Matrix4>()
/** 静息变换:linkId → value=0 时的世界矩阵(用于 delta 计算) */
private restTransforms = new Map<string, THREE.Matrix4>()
/** 运动学树:parentLinkId → {joint, childLinkId}[] */
private kinematicTree = new Map<string, { joint: URDFJoint; childLinkId: string }[]>()
/** 根 Link ID 列表 */
private rootLinkIds: string[] = []
/**
*
*/
setRobot(robot: URDFRobot): void {
this.robot = robot
this.buildTree()
}
/**
*
*/
private buildTree(): void {
if (!this.robot) return
this.kinematicTree.clear()
const childIds = new Set<string>()
for (const joint of this.robot.joints) {
const children = this.kinematicTree.get(joint.parentLinkId) || []
children.push({ joint, childLinkId: joint.childLinkId })
this.kinematicTree.set(joint.parentLinkId, children)
childIds.add(joint.childLinkId)
}
this.rootLinkIds = this.robot.links
.filter(l => !childIds.has(l.id))
.map(l => l.id)
// 构建静息变换(所有 joint value=0,无运动分量)
this.restTransforms.clear()
const identity = new THREE.Matrix4()
for (const rootId of this.rootLinkIds) {
this.computeRestRecursive(rootId, identity)
}
}
/**
* origin / joint motion
*/
private computeRestRecursive(linkId: string, parentWorld: THREE.Matrix4): void {
this.restTransforms.set(linkId, parentWorld.clone())
const children = this.kinematicTree.get(linkId)
if (!children) return
for (const { joint, childLinkId } of children) {
const restLocal = this.computeJointRestMatrix(joint)
const childRest = new THREE.Matrix4().multiplyMatrices(parentWorld, restLocal)
this.computeRestRecursive(childLinkId, childRest)
}
}
/**
* Joint = T(origin.xyz + axisOffset) × R(origin.rpy)
*/
private computeJointRestMatrix(joint: URDFJoint): THREE.Matrix4 {
const offset = joint.axisOffset || [0, 0, 0]
const translation = new THREE.Matrix4().makeTranslation(
joint.origin.xyz[0] + offset[0],
joint.origin.xyz[1] + offset[1],
joint.origin.xyz[2] + offset[2]
)
const [roll, pitch, yaw] = joint.origin.rpy
const euler = new THREE.Euler(roll, pitch, yaw, 'ZYX')
const rotation = new THREE.Matrix4().makeRotationFromEuler(euler)
return new THREE.Matrix4().multiplyMatrices(translation, rotation)
}
/**
*
* @returns linkId Map
*/
compute(): Map<string, THREE.Matrix4> {
this.linkTransforms.clear()
if (!this.robot) return this.linkTransforms
// 从每个根节点开始递归计算
const identity = new THREE.Matrix4()
for (const rootId of this.rootLinkIds) {
this.computeRecursive(rootId, identity)
}
return this.linkTransforms
}
/**
*
*/
private computeRecursive(linkId: string, parentWorldMatrix: THREE.Matrix4): void {
this.linkTransforms.set(linkId, parentWorldMatrix.clone())
const children = this.kinematicTree.get(linkId)
if (!children) return
for (const { joint, childLinkId } of children) {
// Joint 局部变换 = origin 平移旋转 × joint 运动变换
const jointLocalMatrix = this.computeJointMatrix(joint)
const childWorldMatrix = new THREE.Matrix4().multiplyMatrices(parentWorldMatrix, jointLocalMatrix)
this.computeRecursive(childLinkId, childWorldMatrix)
}
}
/**
* Joint
* = T(origin.xyz + axisOffset) × R(origin.rpy) × R(axis, value) T(axis, value)
*/
private computeJointMatrix(joint: URDFJoint): THREE.Matrix4 {
const matrix = new THREE.Matrix4()
// 1. Origin 平移(包含 axisOffset
const offset = joint.axisOffset || [0, 0, 0]
const translation = new THREE.Matrix4().makeTranslation(
joint.origin.xyz[0] + offset[0],
joint.origin.xyz[1] + offset[1],
joint.origin.xyz[2] + offset[2]
)
// 2. Origin 旋转 (RPY → ZYX intrinsic = extrinsic XYZ, URDF 标准)
const [roll, pitch, yaw] = joint.origin.rpy
const euler = new THREE.Euler(roll, pitch, yaw, 'ZYX')
const rotation = new THREE.Matrix4().makeRotationFromEuler(euler)
// 3. Joint 运动变换
const jointMotion = new THREE.Matrix4()
const axis = new THREE.Vector3(...joint.axis).normalize()
switch (joint.type) {
case 'revolute':
jointMotion.makeRotationAxis(axis, joint.currentValue)
break
case 'prismatic':
jointMotion.makeTranslation(
axis.x * joint.currentValue,
axis.y * joint.currentValue,
axis.z * joint.currentValue
)
break
case 'fixed':
// 恒等变换
break
}
// 组合: parent × T(xyz) × R(rpy) × motion
matrix.multiplyMatrices(translation, rotation)
matrix.multiply(jointMotion)
return matrix
}
/**
* FK 3D Mesh
*
* STEP mesh identity
* delta = currentWorld × restWorld¹ value=0
* joint
*/
applyToScene(
linkTransforms: Map<string, THREE.Matrix4>,
links: URDFLink[],
solidMap: Map<string, SolidObject>
): void {
for (const link of links) {
const worldMatrix = linkTransforms.get(link.id)
if (!worldMatrix) continue
// delta = current × rest⁻¹
const restMatrix = this.restTransforms.get(link.id)
let applyMatrix: THREE.Matrix4
if (restMatrix) {
const restInverse = restMatrix.clone().invert()
applyMatrix = new THREE.Matrix4().multiplyMatrices(worldMatrix, restInverse)
} else {
applyMatrix = worldMatrix
}
for (const solidId of link.solidIds) {
const solid = solidMap.get(solidId)
if (!solid?.mesh) continue
// InstancedMesh 的情况需要特殊处理
if (solid.instanceId !== undefined) {
const instancedMesh = solid.mesh as unknown as THREE.InstancedMesh
instancedMesh.setMatrixAt(solid.instanceId, applyMatrix)
instancedMesh.instanceMatrix.needsUpdate = true
} else {
solid.mesh.matrixAutoUpdate = false
solid.mesh.matrix.copy(applyMatrix)
solid.mesh.matrixWorldNeedsUpdate = true
}
}
}
}
/**
* Mesh
*/
resetScene(links: URDFLink[], solidMap: Map<string, SolidObject>): void {
const identity = new THREE.Matrix4()
for (const link of links) {
for (const solidId of link.solidIds) {
const solid = solidMap.get(solidId)
if (!solid?.mesh) continue
if (solid.instanceId !== undefined) {
const instancedMesh = solid.mesh as unknown as THREE.InstancedMesh
instancedMesh.setMatrixAt(solid.instanceId, identity)
instancedMesh.instanceMatrix.needsUpdate = true
} else {
solid.mesh.matrixAutoUpdate = true
solid.mesh.matrix.identity()
solid.mesh.matrixWorldNeedsUpdate = true
}
}
}
}
/**
* Link value=0 FK
* STL Link
*/
getLinkRestTransform(linkId: string): THREE.Matrix4 | null {
return this.restTransforms.get(linkId)?.clone() ?? null
}
/**
* Joint
*/
getJointWorldMatrix(jointId: string): THREE.Matrix4 | null {
if (!this.robot) return null
const joint = this.robot.joints.find(j => j.id === jointId)
if (!joint) return null
// Joint 的世界矩阵 = parent link 的世界矩阵 × joint local matrix
const parentWorldMatrix = this.linkTransforms.get(joint.parentLinkId) || new THREE.Matrix4()
const jointLocal = this.computeJointMatrix(joint)
return new THREE.Matrix4().multiplyMatrices(parentWorldMatrix, jointLocal)
}
dispose(): void {
this.linkTransforms.clear()
this.restTransforms.clear()
this.kinematicTree.clear()
this.robot = null
}
}
@@ -0,0 +1,191 @@
/**
*
* Joint origin RGB + 线
*/
import * as THREE from 'three'
import type { URDFJoint } from '../types'
export interface FrameVisualizerConfig {
scene: THREE.Scene
axisLength?: number
}
// ——— 辅助:创建单轴箭头(圆柱轴杆 + 圆锥箭头,随轴长同比例缩放线宽) ———
function makeAxisArrow(color: number, length: number, axis: 'x' | 'y' | 'z'): THREE.Group {
const group = new THREE.Group()
const shaftR = length * 0.025
const headR = length * 0.065
const headLen = length * 0.22
const shaftLen = length - headLen
const mat = new THREE.MeshBasicMaterial({ color })
// CylinderGeometry 默认沿 Y 轴,居中在原点
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(shaftR, shaftR, shaftLen, 8, 1), mat)
shaft.position.y = shaftLen / 2
const head = new THREE.Mesh(new THREE.ConeGeometry(headR, headLen, 8), mat)
head.position.y = shaftLen + headLen / 2
group.add(shaft, head)
// 旋转至目标轴:Y 轴保持默认;X 绕 Z 轴旋转 -90°;Z 绕 X 轴旋转 +90°
if (axis === 'x') group.rotation.z = -Math.PI / 2
else if (axis === 'z') group.rotation.x = Math.PI / 2
return group
}
export class FrameVisualizer {
private scene: THREE.Scene
private axisLength: number
private frameGroup: THREE.Group
/** jointId → Group (3 个 ArrowHelper) */
private frames = new Map<string, THREE.Group>()
constructor(config: FrameVisualizerConfig) {
this.scene = config.scene
this.axisLength = config.axisLength ?? 0.05
this.frameGroup = new THREE.Group()
this.frameGroup.name = 'urdf-frames'
this.scene.add(this.frameGroup)
}
/**
* /
*/
showFrame(joint: URDFJoint): void {
this.hideFrame(joint.id)
const group = new THREE.Group()
group.name = `frame_${joint.id}`
// 初始位置(FK 运行后会被 updateFrameTransform 中的世界矩阵覆盖)
group.position.set(...joint.origin.xyz)
const [roll, pitch, yaw] = joint.origin.rpy
group.setRotationFromEuler(new THREE.Euler(roll, pitch, yaw, 'ZYX'))
const len = this.axisLength
group.add(makeAxisArrow(0xff2020, len, 'x')) // X 轴 — 红
group.add(makeAxisArrow(0x20cc20, len, 'y')) // Y 轴 — 绿
group.add(makeAxisArrow(0x2050ff, len, 'z')) // Z 轴 — 蓝
this.frames.set(joint.id, group)
this.frameGroup.add(group)
}
/**
* FK
*/
updateFrameTransform(jointId: string, worldMatrix: THREE.Matrix4): void {
const group = this.frames.get(jointId)
if (group) {
group.matrixAutoUpdate = false
group.matrix.copy(worldMatrix)
group.matrixWorldNeedsUpdate = true
}
}
/**
*
*/
hideFrame(jointId: string): void {
const group = this.frames.get(jointId)
if (group) {
this.frameGroup.remove(group)
disposeGroup(group)
this.frames.delete(jointId)
}
}
/**
*
*/
showAllFrames(joints: URDFJoint[]): void {
this.clearAll()
for (const joint of joints) {
this.showFrame(joint)
}
}
/**
* Base Link RGB
* origin null
* @param origin
* @param rpy 姿 [roll, pitch, yaw] URDF <origin rpy>
* null/undefined [0,0,0]
*/
showBaseFrame(origin: [number, number, number] | null, rpy?: [number, number, number]): void {
// 移除旧的 base frame
const existing = this.frames.get('__base__')
if (existing) {
this.frameGroup.remove(existing)
disposeGroup(existing)
this.frames.delete('__base__')
}
if (!origin) return // 未设置基点时不渲染
const group = new THREE.Group()
group.name = 'frame___base__'
group.position.set(origin[0], origin[1], origin[2])
// 使用 RPY 欧拉角设置旋转(ZYX 顺序 = URDF rpy 约定),与 showFrame() 逻辑完全一致
if (rpy) {
const [roll, pitch, yaw] = rpy
group.setRotationFromEuler(new THREE.Euler(roll, pitch, yaw, 'ZYX'))
}
const len = this.axisLength * 1.6 // 基坐标系箭头稍大,以示区分
group.add(makeAxisArrow(0xff2020, len, 'x'))
group.add(makeAxisArrow(0x20cc20, len, 'y'))
group.add(makeAxisArrow(0x2050ff, len, 'z'))
this.frames.set('__base__', group)
this.frameGroup.add(group)
}
/**
*
*/
setVisible(visible: boolean): void {
this.frameGroup.visible = visible
}
/**
*
*/
setAxisLength(length: number): void {
this.axisLength = length
}
/**
*
*/
clearAll(): void {
for (const [id, group] of this.frames) {
this.frameGroup.remove(group)
disposeGroup(group)
}
this.frames.clear()
}
dispose(): void {
this.clearAll()
this.scene.remove(this.frameGroup)
}
}
function disposeGroup(group: THREE.Group): void {
group.traverse(obj => {
if (obj instanceof THREE.Mesh || obj instanceof THREE.Line) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose())
} else {
obj.material?.dispose()
}
}
})
}
@@ -0,0 +1,137 @@
/**
* Web Worker
*
* 使Divergence Theorem+
* OpenCASCADE OCCT API
*
*
* 输入坐标 : mmSTEP
* 输入密度 : kg/m³
* 输出质量 : kg
* 输出质心 : mmURDFSerializer ×0.001 m
* 输出惯性 : kg·m²SI URDFSerializer 使
*
*
* (a, b, c)
* λ² dV = 1/60, λλ dV = 1/120
* Ref: Mirtich (1996) "Fast and Accurate Computation of Polyhedral Mass Properties".
*/
import * as Comlink from 'comlink'
import type { SerializedSolidData, InertialParams } from '../types'
const workerApi = {
/** 保留 init() 以保持外部接口兼容,纯数学实现无需 OCCT */
async init(): Promise<void> { /* no-op */ },
/**
* Solid
* @param solidDataList Link Solid使 positions + indices
* @param density (kg/m³)
*/
async computeInertia(
solidDataList: SerializedSolidData[],
density: number
): Promise<InertialParams> {
// ── 一次遍历,累计所有积分项(单位均为 mm⁵,sumW 为 mm³×6)──────
let sumW = 0
let sumWx = 0, sumWy = 0, sumWz = 0
let sumWxx = 0, sumWyy = 0, sumWzz = 0
let sumWxy = 0, sumWxz = 0, sumWyz = 0
for (const { positions, indices } of solidDataList) {
const nTri = Math.floor(indices.length / 3)
for (let t = 0; t < nTri; t++) {
const i0 = indices[t * 3], i1 = indices[t * 3 + 1], i2 = indices[t * 3 + 2]
const ax = positions[i0 * 3], ay = positions[i0 * 3 + 1], az = positions[i0 * 3 + 2]
const bx = positions[i1 * 3], by = positions[i1 * 3 + 1], bz = positions[i1 * 3 + 2]
const cx = positions[i2 * 3], cy = positions[i2 * 3 + 1], cz = positions[i2 * 3 + 2]
// w = a·(b×c);正值 = 三角形法线朝外
const w = ax * (by * cz - bz * cy)
+ ay * (bz * cx - bx * cz)
+ az * (bx * cy - by * cx)
sumW += w
// 一阶矩(质心推导):COM_x = Σ w*(ax+bx+cx) / (4*ΣW)
sumWx += w * (ax + bx + cx)
sumWy += w * (ay + by + cy)
sumWz += w * (az + bz + cz)
// 二阶矩(对角):∫x² dV = w/60*(ax²+bx²+cx²+ax*bx+ax*cx+bx*cx)
sumWxx += w * (ax * ax + bx * bx + cx * cx + ax * bx + ax * cx + bx * cx)
sumWyy += w * (ay * ay + by * by + cy * cy + ay * by + ay * cy + by * cy)
sumWzz += w * (az * az + bz * bz + cz * cz + az * bz + az * cz + bz * cz)
// 二阶矩(互项):∫xy dV = w/120*(2ax*ay+2bx*by+2cx*cy+ax*by+ay*bx+...)
sumWxy += w * (2 * ax * ay + 2 * bx * by + 2 * cx * cy
+ ax * by + ay * bx + ax * cy + ay * cx + bx * cy + by * cx)
sumWxz += w * (2 * ax * az + 2 * bx * bz + 2 * cx * cz
+ ax * bz + az * bx + ax * cz + az * cx + bx * cz + bz * cx)
sumWyz += w * (2 * ay * az + 2 * by * bz + 2 * cy * cz
+ ay * bz + az * by + ay * cz + az * cy + by * cz + bz * cy)
}
}
// ── 退化保护 ────────────────────────────────────────────────────
if (Math.abs(sumW) < 1e-10) {
return { mass: 0, com: [0, 0, 0], inertia: [0, 0, 0, 0, 0, 0] }
}
// ── 法线朝向修正(内向网格 ⇒ sumW<0,取符号翻转后积分符号一致)──
if (sumW < 0) {
sumW = -sumW
sumWx = -sumWx; sumWy = -sumWy; sumWz = -sumWz
sumWxx = -sumWxx; sumWyy = -sumWyy; sumWzz = -sumWzz
sumWxy = -sumWxy; sumWxz = -sumWxz; sumWyz = -sumWyz
}
// ── 体积 & 质量 ─────────────────────────────────────────────────
// V [mm³] = Σw / 6
// mass [kg] = V_mm³ × ρ_kg/m³ × 1e-9 1 m³ = 1e9 mm³)
const V_mm3 = sumW / 6
const mass = V_mm3 * density * 1e-9
// ── 质心 [mm] ───────────────────────────────────────────────────
// COM_x = (Σ w*(ax+bx+cx) / 24) / (Σw/6) = sumWx / (4 * sumW)
const com_x = sumWx / (4 * sumW)
const com_y = sumWy / (4 * sumW)
const com_z = sumWz / (4 * sumW)
// ── 原点处惯性张量 [kg·m²] ───────────────────────────────────────
// ∫(y²+z²) dV [mm⁵] = (sumWyy+sumWzz) / 60
// 单位推导:ρ [kg/m³] × integral [mm⁵] × 1e-15 = kg·m²
// 因为 1 mm⁵ = 1e-15 m⁵,所以 ρ×mm⁵ = kg/m³ × 1e-15 m⁵ = 1e-15 kg·m² ✓
const c = density * 1e-15
const I_O_xx = c * (sumWyy + sumWzz) / 60
const I_O_yy = c * (sumWxx + sumWzz) / 60
const I_O_zz = c * (sumWxx + sumWyy) / 60
// URDF 约定:非对角项 ixy = −ρ∫xy dV(负号)
const I_O_xy = -c * sumWxy / 120
const I_O_xz = -c * sumWxz / 120
const I_O_yz = -c * sumWyz / 120
// ── 平行轴定理(Steiner):原点 → 质心 ────────────────────────────
// I_O 单位: kg·m²;COM 存储在 mm,需换算为 m 才能与 I_O 量纲一致
// M [kg] × d² [m²] = kg·m² ✓
const cx_m = com_x * 1e-3, cy_m = com_y * 1e-3, cz_m = com_z * 1e-3
const ixx = I_O_xx - mass * (cy_m * cy_m + cz_m * cz_m)
const iyy = I_O_yy - mass * (cx_m * cx_m + cz_m * cz_m)
const izz = I_O_zz - mass * (cx_m * cx_m + cy_m * cy_m)
const ixy = I_O_xy + mass * cx_m * cy_m
const ixz = I_O_xz + mass * cx_m * cz_m
const iyz = I_O_yz + mass * cy_m * cz_m
return {
mass,
com: [com_x, com_y, com_z],
inertia: [ixx, ixy, ixz, iyy, iyz, izz]
}
}
}
export type InertiaWorkerApi = typeof workerApi
Comlink.expose(workerApi)
@@ -0,0 +1,156 @@
/**
* (Snap Gizmo)
* RGB
* - Z = SnapNormal
* - X / Y 绿= Z
*
* THREE.Group + ArrowHelper new/dispose
*/
import * as THREE from 'three'
export interface JointSnapVisualizerConfig {
scene: THREE.Scene
axisLength?: number
}
export class JointSnapVisualizer {
private scene: THREE.Scene
private axisLength: number
private group: THREE.Group
private xArrow: THREE.ArrowHelper
private yArrow: THREE.ArrowHelper
private zArrow: THREE.ArrowHelper
private visible = false
/** 当前吸附法线(世界空间)— 用于反转 */
private currentNormal = new THREE.Vector3(0, 0, 1)
/** 当前吸附位置(世界空间) */
private currentPosition = new THREE.Vector3()
constructor(config: JointSnapVisualizerConfig) {
this.scene = config.scene
this.axisLength = config.axisLength ?? 0.05
const len = this.axisLength
// 预创建三个箭头(后续仅更新 matrix,不销毁重建)
this.xArrow = new THREE.ArrowHelper(
new THREE.Vector3(1, 0, 0), new THREE.Vector3(), len, 0xff0000, len * 0.2, len * 0.1
)
this.yArrow = new THREE.ArrowHelper(
new THREE.Vector3(0, 1, 0), new THREE.Vector3(), len, 0x00ff00, len * 0.2, len * 0.1
)
this.zArrow = new THREE.ArrowHelper(
new THREE.Vector3(0, 0, 1), new THREE.Vector3(), len, 0x0000ff, len * 0.2, len * 0.1
)
this.group = new THREE.Group()
this.group.name = 'snap-gizmo'
this.group.add(this.xArrow, this.yArrow, this.zArrow)
this.group.visible = false
this.group.matrixAutoUpdate = false
this.scene.add(this.group)
}
/**
* Gizmo
* @param position
* @param normal 线/线Z
*/
updateSnap(position: THREE.Vector3, normal: THREE.Vector3): void {
this.currentPosition.copy(position)
this.currentNormal.copy(normal).normalize()
this.applyTransform()
this.group.visible = true
this.visible = true
}
/**
* Z 线
* Gizmo
*/
flipNormal(): void {
this.currentNormal.negate()
this.applyTransform()
}
/**
* 线 Worker
*/
getCurrentNormal(): THREE.Vector3 {
return this.currentNormal.clone()
}
/**
*
*/
getCurrentPosition(): THREE.Vector3 {
return this.currentPosition.clone()
}
/**
*
*/
isVisible(): boolean {
return this.visible
}
/**
* Gizmo
*/
hide(): void {
this.group.visible = false
this.visible = false
}
/**
* currentPosition + currentNormal Group
*/
private applyTransform(): void {
const z = this.currentNormal.clone().normalize()
// 选择与 Z 轴最不平行的参考向量
const absX = Math.abs(z.x)
const absY = Math.abs(z.y)
const absZ = Math.abs(z.z)
const ref = absX < absY && absX < absZ
? new THREE.Vector3(1, 0, 0)
: absY < absZ
? new THREE.Vector3(0, 1, 0)
: new THREE.Vector3(0, 0, 1)
// X = normalize(cross(ref, Z))
const x = new THREE.Vector3().crossVectors(ref, z).normalize()
// Y = cross(Z, X)
const y = new THREE.Vector3().crossVectors(z, x)
// 构建 4x4 矩阵(列主序)
const m = this.group.matrix
m.set(
x.x, y.x, z.x, this.currentPosition.x,
x.y, y.y, z.y, this.currentPosition.y,
x.z, y.z, z.z, this.currentPosition.z,
0, 0, 0, 1
)
this.group.matrixWorldNeedsUpdate = true
}
dispose(): void {
this.hide()
this.scene.remove(this.group)
this.group.traverse(obj => {
if (obj instanceof THREE.Mesh || obj instanceof THREE.Line) {
obj.geometry?.dispose()
const mat = obj.material
if (Array.isArray(mat)) {
mat.forEach(m => m.dispose())
} else {
mat?.dispose()
}
}
})
}
}
@@ -0,0 +1,139 @@
/**
* Web Worker
* 使 gl-matrix 线 UI
*
*
* - 线 Float32Array /
* - SnapNormal
* - (XYZ) (RPY, XYZ )
*/
import * as Comlink from 'comlink'
import { mat4, vec3 } from 'gl-matrix'
/**
* XYZ (Roll-Pitch-Yaw)
*
* R = Rz(yaw) × Ry(pitch) × Rx(roll)
* XYZ = ZYX URDF
*
* (gl-matrix ):
* col0 = [m[0], m[1], m[2]]
* col1 = [m[4], m[5], m[6]]
* col2 = [m[8], m[9], m[10]]
*
* :
* pitch = asin(-m[2]) ( -R[0][2])
* cos(pitch) != 0:
* roll = atan2(m[6], m[10]) ( R[1][2], R[2][2])
* yaw = atan2(m[1], m[0]) ( R[0][1], R[0][0])
* ():
* roll = atan2(-m[9], m[5])
* yaw = 0
*/
function mat4ToEulerXYZ(m: mat4): [number, number, number] {
// gl-matrix 列主序: m[col*4 + row]
const m00 = m[0], m01 = m[4], m02 = m[8]
const m10 = m[1], m11 = m[5], m12 = m[9]
const m20 = m[2], m21 = m[6], m22 = m[10]
// pitch = asin(clamp(-m20, -1, 1))
const sy = -m20
const pitch = Math.asin(Math.max(-1, Math.min(1, sy)))
let roll: number
let yaw: number
if (Math.abs(sy) < 0.99999) {
// 非万向锁
roll = Math.atan2(m21, m22)
yaw = Math.atan2(m10, m00)
} else {
// 万向锁:pitch ≈ ±90°
roll = Math.atan2(-m12, m11)
yaw = 0
}
return [roll, pitch, yaw]
}
/**
*
* Z normalX/Y
*/
function buildOrthonormalBasis(normal: vec3): { x: vec3; y: vec3; z: vec3 } {
const z = vec3.normalize(vec3.create(), normal)
// 选择与 Z 轴最不平行的参考轴
const absX = Math.abs(z[0])
const absY = Math.abs(z[1])
const absZ = Math.abs(z[2])
const ref: vec3 = absX < absY && absX < absZ
? vec3.fromValues(1, 0, 0)
: absY < absZ
? vec3.fromValues(0, 1, 0)
: vec3.fromValues(0, 0, 1)
// X = normalize(cross(ref, Z))
const x = vec3.normalize(vec3.create(), vec3.cross(vec3.create(), ref, z))
// Y = cross(Z, X) — 已自动归一化(两个单位向量的叉积)
const y = vec3.cross(vec3.create(), z, x)
return { x, y, z }
}
const workerApi = {
/**
*
*
* @param parentWorldMatrix Float32Array(16)
* @param snapPosition Float32Array(3)
* @param snapNormal 线 Float32Array(3)
* @returns { xyz, rpy }
*/
computeRelativeTransform(
parentWorldMatrix: Float32Array,
snapPosition: Float32Array,
snapNormal: Float32Array
): { xyz: [number, number, number]; rpy: [number, number, number] } {
// 1. 从 snapNormal 构建正交坐标系
const normal = vec3.fromValues(snapNormal[0], snapNormal[1], snapNormal[2])
const { x: axisX, y: axisY, z: axisZ } = buildOrthonormalBasis(normal)
// 2. 构建 T_world_to_joint (4x4 列主序)
// 旋转部分: 列 0=X, 列 1=Y, 列 2=Z
// 平移部分: 列 3=snapPosition
const worldToJoint = mat4.fromValues(
axisX[0], axisX[1], axisX[2], 0, // col 0
axisY[0], axisY[1], axisY[2], 0, // col 1
axisZ[0], axisZ[1], axisZ[2], 0, // col 2
snapPosition[0], snapPosition[1], snapPosition[2], 1 // col 3
)
// 3. 计算父级逆矩阵
const parentMat = mat4.clone(parentWorldMatrix as unknown as mat4)
const parentInverse = mat4.create()
const invertible = mat4.invert(parentInverse, parentMat)
if (!invertible) {
// 矩阵不可逆(退化),返回默认值
return { xyz: [0, 0, 0], rpy: [0, 0, 0] }
}
// 4. T_relative = T_parent_inverse × T_world_to_joint
const relative = mat4.create()
mat4.multiply(relative, parentInverse, worldToJoint)
// 5. 提取位移 (列 3 的前三个分量)
const xyz: [number, number, number] = [relative[12], relative[13], relative[14]]
// 6. 提取 RPY (XYZ 欧拉角顺序)
const rpy = mat4ToEulerXYZ(relative)
return { xyz, rpy }
}
}
export type KinematicsWorkerApi = typeof workerApi
Comlink.expose(workerApi)
@@ -0,0 +1,596 @@
/**
* 线
* 3D 线
* 线
*/
import * as THREE from 'three'
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js'
import type { ArcballControls } from 'three/examples/jsm/controls/ArcballControls.js'
/**
* 线
*/
export interface LineMeasurementData {
id: string
start: THREE.Vector3
end: THREE.Vector3
distance: number
label: string
}
/**
* 线 3D
*/
interface LineMeasurementInternal {
data: LineMeasurementData
line: THREE.Line
startMarker: THREE.Mesh
endMarker: THREE.Mesh
label: CSS2DObject
}
/**
* 线
*/
export interface LineMeasurementToolConfig {
scene: THREE.Scene
camera: THREE.Camera
domElement: HTMLElement
container: HTMLElement
controls: ArcballControls
labelRenderer?: CSS2DRenderer
/** 请求渲染回调 */
onRenderRequest?: () => void
/** 新线完成回调 */
onLineAdded?: (line: LineMeasurementData) => void
/** 线被删除回调 */
onLineRemoved?: (id: string) => void
}
/**
* 线
*/
export class LineMeasurementTool {
private scene: THREE.Scene
private camera: THREE.Camera
private domElement: HTMLElement
private container: HTMLElement
private controls: ArcballControls
private labelRenderer?: CSS2DRenderer
private raycaster: THREE.Raycaster
private mouse: THREE.Vector2
// 回调
private onRenderRequest?: () => void
private onLineAdded?: (line: LineMeasurementData) => void
private onLineRemoved?: (id: string) => void
// 状态
private _isActive = false
private currentStart: THREE.Vector3 | null = null
private idCounter = 0
// 3D 对象组
private measureGroup: THREE.Group
private completedLines: Map<string, LineMeasurementInternal> = new Map()
// 预览线(正在画的线)
private previewLine: THREE.Line | null = null
private previewLabel: CSS2DObject | null = null
private previewStartMarker: THREE.Mesh | null = null
// 样式配置
private readonly LINE_COLOR = 0x00aaff
private readonly PREVIEW_COLOR = 0x00aaff
private readonly MARKER_RADIUS = 0.8
private readonly MARKER_COLOR = 0x00aaff
// DOM 事件句柄(绑定解绑用)
private boundHandleClick: (e: MouseEvent) => void
private boundHandleMouseMove: (e: MouseEvent) => void
private boundHandleKeyDown: (e: KeyboardEvent) => void
private boundHandleContextMenu: (e: MouseEvent) => void
// 拖拽检测
private mouseDownPos = { x: 0, y: 0 }
private isDragging = false
private readonly DRAG_THRESHOLD = 5
private boundHandleMouseDown: (e: MouseEvent) => void
private boundHandleMouseUp: (e: MouseEvent) => void
// 缓存可见 meshes 用于 raycasting
private cachedMeshes: THREE.Mesh[] = []
private cachedRect: DOMRect | null = null
constructor(config: LineMeasurementToolConfig) {
this.scene = config.scene
this.camera = config.camera
this.domElement = config.domElement
this.container = config.container
this.controls = config.controls
this.labelRenderer = config.labelRenderer
this.onRenderRequest = config.onRenderRequest
this.onLineAdded = config.onLineAdded
this.onLineRemoved = config.onLineRemoved
this.raycaster = new THREE.Raycaster()
this.mouse = new THREE.Vector2()
// 创建测量组
this.measureGroup = new THREE.Group()
this.measureGroup.name = 'LineMeasurementGroup'
this.scene.add(this.measureGroup)
// 绑定事件句柄
this.boundHandleClick = this.handleClick.bind(this)
this.boundHandleMouseMove = this.handleMouseMove.bind(this)
this.boundHandleKeyDown = this.handleKeyDown.bind(this)
this.boundHandleContextMenu = this.handleContextMenu.bind(this)
this.boundHandleMouseDown = this.handleMouseDown.bind(this)
this.boundHandleMouseUp = this.handleMouseUp.bind(this)
}
// ========== 公共 API ==========
get isActive(): boolean {
return this._isActive
}
/**
* 线
*/
activate(): void {
if (this._isActive) return
this._isActive = true
this.updateCachedMeshes()
this.cachedRect = this.domElement.getBoundingClientRect()
// 绑定事件
this.domElement.addEventListener('click', this.boundHandleClick)
this.domElement.addEventListener('mousemove', this.boundHandleMouseMove)
this.domElement.addEventListener('mousedown', this.boundHandleMouseDown)
this.domElement.addEventListener('mouseup', this.boundHandleMouseUp)
this.domElement.addEventListener('contextmenu', this.boundHandleContextMenu)
window.addEventListener('keydown', this.boundHandleKeyDown)
// 更改光标
this.domElement.style.cursor = 'crosshair'
}
/**
* 退线
*/
deactivate(): void {
if (!this._isActive) return
this._isActive = false
// 取消当前画线
this.cancelCurrentLine()
// 解绑事件
this.domElement.removeEventListener('click', this.boundHandleClick)
this.domElement.removeEventListener('mousemove', this.boundHandleMouseMove)
this.domElement.removeEventListener('mousedown', this.boundHandleMouseDown)
this.domElement.removeEventListener('mouseup', this.boundHandleMouseUp)
this.domElement.removeEventListener('contextmenu', this.boundHandleContextMenu)
window.removeEventListener('keydown', this.boundHandleKeyDown)
// 恢复光标
this.domElement.style.cursor = ''
}
/**
* 线
*/
removeLine(id: string): void {
const line = this.completedLines.get(id)
if (!line) return
this.measureGroup.remove(line.line)
this.measureGroup.remove(line.startMarker)
this.measureGroup.remove(line.endMarker)
this.measureGroup.remove(line.label)
// 释放资源
line.line.geometry.dispose()
; (line.line.material as THREE.Material).dispose()
line.startMarker.geometry.dispose()
; (line.startMarker.material as THREE.Material).dispose()
line.endMarker.geometry.dispose()
; (line.endMarker.material as THREE.Material).dispose()
this.completedLines.delete(id)
this.onLineRemoved?.(id)
this.onRenderRequest?.()
}
/**
* 线
*/
clearAll(): void {
const ids = Array.from(this.completedLines.keys())
ids.forEach(id => {
const line = this.completedLines.get(id)
if (line) {
this.measureGroup.remove(line.line)
this.measureGroup.remove(line.startMarker)
this.measureGroup.remove(line.endMarker)
this.measureGroup.remove(line.label)
line.line.geometry.dispose()
; (line.line.material as THREE.Material).dispose()
line.startMarker.geometry.dispose()
; (line.startMarker.material as THREE.Material).dispose()
line.endMarker.geometry.dispose()
; (line.endMarker.material as THREE.Material).dispose()
}
})
this.completedLines.clear()
this.cancelCurrentLine()
this.onRenderRequest?.()
}
/**
* 线
*/
getLines(): LineMeasurementData[] {
return Array.from(this.completedLines.values()).map(l => l.data)
}
/**
*
*/
dispose(): void {
this.deactivate()
this.clearAll()
this.scene.remove(this.measureGroup)
}
// ========== 内部方法 ==========
/**
* meshes
*/
private updateCachedMeshes(): void {
const meshes: THREE.Mesh[] = []
this.scene.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.visible && obj !== this.previewStartMarker) {
// 排除测量组内的对象
let isMeasureObj = false
let parent = obj.parent
while (parent) {
if (parent === this.measureGroup) {
isMeasureObj = true
break
}
parent = parent.parent
}
if (!isMeasureObj) meshes.push(obj)
}
})
this.cachedMeshes = meshes
}
/**
* 线
*
*/
private getPoint(event: MouseEvent): THREE.Vector3 | null {
const rect = this.cachedRect || this.domElement.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
// 尝试命中模型
const intersects = this.raycaster.intersectObjects(this.cachedMeshes, false)
if (intersects.length > 0) {
return intersects[0].point.clone()
}
// 投射到参考平面(过 currentStart 或场景中心,面朝相机)
const planeCenter = this.currentStart
? this.currentStart.clone()
: new THREE.Vector3(0, 0, 0)
const cameraDir = new THREE.Vector3()
this.camera.getWorldDirection(cameraDir)
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(
cameraDir.negate(),
planeCenter
)
const target = new THREE.Vector3()
const ray = this.raycaster.ray
const hit = ray.intersectPlane(plane, target)
return hit ? target : null
}
// ========== 事件处理 ==========
private handleMouseDown(event: MouseEvent): void {
this.mouseDownPos.x = event.clientX
this.mouseDownPos.y = event.clientY
this.isDragging = false
}
private handleMouseUp(_event: MouseEvent): void {
this.isDragging = false
}
private handleClick(event: MouseEvent): void {
// 检查是否是拖动
const dx = event.clientX - this.mouseDownPos.x
const dy = event.clientY - this.mouseDownPos.y
if (Math.sqrt(dx * dx + dy * dy) > this.DRAG_THRESHOLD) return
const point = this.getPoint(event)
if (!point) return
if (!this.currentStart) {
// 第一次点击:设定起点
this.currentStart = point
this.createPreviewStartMarker(point)
} else {
// 第二次点击:完成线段
this.completeLine(point)
}
}
private handleMouseMove(event: MouseEvent): void {
if (!this.currentStart) return
// 检测拖拽中(旋转/平移)
if (event.buttons !== 0) {
const dx = event.clientX - this.mouseDownPos.x
const dy = event.clientY - this.mouseDownPos.y
if (Math.sqrt(dx * dx + dy * dy) > this.DRAG_THRESHOLD) {
this.isDragging = true
return
}
}
const point = this.getPoint(event)
if (!point) return
this.updatePreviewLine(point)
}
private handleKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.cancelCurrentLine()
} else if (event.key === 'Delete' || event.key === 'Backspace') {
// 删除最后一条完成的线
const keys = Array.from(this.completedLines.keys())
if (keys.length > 0) {
this.removeLine(keys[keys.length - 1])
}
}
}
private handleContextMenu(event: MouseEvent): void {
event.preventDefault()
// 右键取消当前画线
this.cancelCurrentLine()
}
// ========== 画线逻辑 ==========
/**
*
*/
private createPreviewStartMarker(point: THREE.Vector3): void {
const geo = new THREE.SphereGeometry(this.MARKER_RADIUS, 12, 12)
const mat = new THREE.MeshBasicMaterial({
color: this.MARKER_COLOR,
depthTest: false,
transparent: true,
opacity: 0.8
})
this.previewStartMarker = new THREE.Mesh(geo, mat)
this.previewStartMarker.position.copy(point)
this.previewStartMarker.renderOrder = 999
this.measureGroup.add(this.previewStartMarker)
this.onRenderRequest?.()
}
/**
* 线
*/
private updatePreviewLine(endPoint: THREE.Vector3): void {
if (!this.currentStart) return
// 更新或创建预览线
if (this.previewLine) {
const positions = this.previewLine.geometry.getAttribute('position') as THREE.BufferAttribute
positions.setXYZ(0, this.currentStart.x, this.currentStart.y, this.currentStart.z)
positions.setXYZ(1, endPoint.x, endPoint.y, endPoint.z)
positions.needsUpdate = true
this.previewLine.geometry.computeBoundingSphere()
} else {
const geometry = new THREE.BufferGeometry()
const positions = new Float32Array(6)
positions[0] = this.currentStart.x
positions[1] = this.currentStart.y
positions[2] = this.currentStart.z
positions[3] = endPoint.x
positions[4] = endPoint.y
positions[5] = endPoint.z
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const material = new THREE.LineDashedMaterial({
color: this.PREVIEW_COLOR,
dashSize: 6,
gapSize: 2,
linewidth: 2,
depthTest: false,
transparent: true,
opacity: 0.7
})
this.previewLine = new THREE.Line(geometry, material)
this.previewLine.computeLineDistances()
this.previewLine.renderOrder = 999
this.measureGroup.add(this.previewLine)
}
// 计算距离
const distance = this.currentStart.distanceTo(endPoint)
const midPoint = this.currentStart.clone().add(endPoint).multiplyScalar(0.5)
// 更新或创建距离标签
if (this.previewLabel) {
this.previewLabel.position.copy(midPoint)
const div = this.previewLabel.element as HTMLDivElement
div.textContent = `${distance.toFixed(2)} mm`
} else {
const div = document.createElement('div')
div.textContent = `${distance.toFixed(2)} mm`
div.style.cssText = `
background: rgba(0, 170, 255, 0.9);
color: #fff;
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
`
this.previewLabel = new CSS2DObject(div)
this.previewLabel.position.copy(midPoint)
this.measureGroup.add(this.previewLabel)
}
// 需要重新计算虚线距离
this.previewLine.computeLineDistances()
this.onRenderRequest?.()
}
/**
* 线
*/
private completeLine(endPoint: THREE.Vector3): void {
if (!this.currentStart) return
const distance = this.currentStart.distanceTo(endPoint)
const midPoint = this.currentStart.clone().add(endPoint).multiplyScalar(0.5)
const id = `line_measure_${++this.idCounter}_${Date.now()}`
const data: LineMeasurementData = {
id,
start: this.currentStart.clone(),
end: endPoint.clone(),
distance,
label: `${distance.toFixed(2)} mm`
}
// 创建正式线段
const lineGeo = new THREE.BufferGeometry().setFromPoints([
this.currentStart.clone(),
endPoint.clone()
])
const lineMat = new THREE.LineBasicMaterial({
color: this.LINE_COLOR,
linewidth: 2,
depthTest: false,
transparent: true,
opacity: 0.9
})
const line = new THREE.Line(lineGeo, lineMat)
line.renderOrder = 998
this.measureGroup.add(line)
// 起点标记
const startGeo = new THREE.SphereGeometry(this.MARKER_RADIUS, 12, 12)
const markerMat = new THREE.MeshBasicMaterial({
color: this.MARKER_COLOR,
depthTest: false,
transparent: true,
opacity: 0.85
})
const startMarker = new THREE.Mesh(startGeo, markerMat)
startMarker.position.copy(this.currentStart)
startMarker.renderOrder = 999
this.measureGroup.add(startMarker)
// 终点标记
const endGeo = new THREE.SphereGeometry(this.MARKER_RADIUS, 12, 12)
const endMarkerMat = new THREE.MeshBasicMaterial({
color: this.MARKER_COLOR,
depthTest: false,
transparent: true,
opacity: 0.85
})
const endMarker = new THREE.Mesh(endGeo, endMarkerMat)
endMarker.position.copy(endPoint)
endMarker.renderOrder = 999
this.measureGroup.add(endMarker)
// 标签
const div = document.createElement('div')
div.textContent = data.label
div.style.cssText = `
background: rgba(0, 170, 255, 0.95);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
pointer-events: none;
`
const label = new CSS2DObject(div)
label.position.copy(midPoint)
this.measureGroup.add(label)
// 保存
this.completedLines.set(id, {
data,
line,
startMarker,
endMarker,
label
})
// 清除预览
this.cleanupPreview()
this.currentStart = null
// 通知
this.onLineAdded?.(data)
this.onRenderRequest?.()
}
/**
* 线
*/
private cancelCurrentLine(): void {
this.cleanupPreview()
this.currentStart = null
this.onRenderRequest?.()
}
/**
* 3D
*/
private cleanupPreview(): void {
if (this.previewLine) {
this.measureGroup.remove(this.previewLine)
this.previewLine.geometry.dispose()
; (this.previewLine.material as THREE.Material).dispose()
this.previewLine = null
}
if (this.previewLabel) {
this.measureGroup.remove(this.previewLabel)
this.previewLabel = null
}
if (this.previewStartMarker) {
this.measureGroup.remove(this.previewStartMarker)
this.previewStartMarker.geometry.dispose()
; (this.previewStartMarker.material as THREE.Material).dispose()
this.previewStartMarker = null
}
}
}
export default LineMeasurementTool
@@ -0,0 +1,180 @@
/**
*
* WebGPU WebGL
*
*
* 1. navigator.gpu
* 2. WebGPURenderer
* 3. fallback WebGLRenderer
*/
import * as THREE from 'three'
/** 渲染器类型标识 */
export type RendererType = 'webgpu' | 'webgl'
/** 通用渲染器类型 */
export type UniversalRenderer = THREE.WebGLRenderer | any // WebGPURenderer 类型在运行时动态导入
/** 渲染器创建配置 */
export interface RendererConfig {
antialias?: boolean
alpha?: boolean
preserveDrawingBuffer?: boolean
canvas?: HTMLCanvasElement
}
/** 渲染器创建结果 */
export interface RendererResult {
renderer: UniversalRenderer
type: RendererType
}
/**
* WebGPU
*/
export async function isWebGPUAvailable(): Promise<boolean> {
if (typeof navigator === 'undefined') return false
if (!('gpu' in navigator)) return false
try {
const gpu = (navigator as any).gpu
if (!gpu) return false
const adapter = await gpu.requestAdapter()
if (!adapter) return false
const device = await adapter.requestDevice()
if (!device) return false
// 成功获取设备,释放资源
device.destroy()
return true
} catch {
return false
}
}
/**
* WebGPU
*/
async function createWebGPURenderer(config: RendererConfig): Promise<UniversalRenderer | null> {
try {
// 动态导入 WebGPURenderer(避免在不支持的环境中报错)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - three/webgpu types require bundler moduleResolution
const webgpuModule = await import('three/webgpu')
const WebGPURenderer = webgpuModule.default || webgpuModule.WebGPURenderer
const renderer = new WebGPURenderer({
antialias: config.antialias !== false,
alpha: config.alpha,
canvas: config.canvas
})
// 等待 WebGPU 初始化完成
await renderer.init()
console.log('✓ WebGPU 渲染器初始化成功')
return renderer
} catch (error) {
console.warn('WebGPU 渲染器创建失败,将降级到 WebGL:', error)
return null
}
}
/**
* WebGL fallback
*/
function createWebGLRenderer(config: RendererConfig): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({
antialias: config.antialias !== false,
alpha: config.alpha ?? true,
preserveDrawingBuffer: config.preserveDrawingBuffer ?? true,
canvas: config.canvas
})
console.log('✓ WebGL 渲染器初始化成功')
return renderer
}
/**
* +
*
* @param config
* @param preferWebGPU 使 WebGPU true
* @returns
*/
export async function createRenderer(
config: RendererConfig = {},
preferWebGPU = true
): Promise<RendererResult> {
// 尝试使用 WebGPU
if (preferWebGPU) {
const gpuAvailable = await isWebGPUAvailable()
if (gpuAvailable) {
const webgpuRenderer = await createWebGPURenderer(config)
if (webgpuRenderer) {
return { renderer: webgpuRenderer, type: 'webgpu' }
}
}
}
// Fallback 到 WebGL
const webglRenderer = createWebGLRenderer(config)
return { renderer: webglRenderer, type: 'webgl' }
}
/**
* WebGPU
*/
export function isWebGPURenderer(renderer: UniversalRenderer): boolean {
// WebGPURenderer 没有 extensions 属性,WebGLRenderer 有
return renderer && !('extensions' in renderer)
}
/**
*
*/
export function configureRenderer(
renderer: UniversalRenderer,
type: RendererType,
options: {
width: number
height: number
pixelRatio?: number
shadowMapEnabled?: boolean
toneMapping?: THREE.ToneMapping
toneMappingExposure?: number
outputColorSpace?: THREE.ColorSpace
}
): void {
renderer.setSize(options.width, options.height)
renderer.setPixelRatio(Math.min(options.pixelRatio ?? window.devicePixelRatio, 2))
renderer.outputColorSpace = options.outputColorSpace ?? THREE.SRGBColorSpace
// CAD 视图使用 NoToneMapping 保证颜色准确,避免 ACES 在 WebGPU 下的兼容问题
renderer.toneMapping = options.toneMapping ?? THREE.NoToneMapping
renderer.toneMappingExposure = options.toneMappingExposure ?? 1.0
if (type === 'webgl') {
const glRenderer = renderer as THREE.WebGLRenderer
glRenderer.shadowMap.enabled = options.shadowMapEnabled ?? true
glRenderer.shadowMap.type = THREE.PCFSoftShadowMap
} else {
// WebGPU 渲染器的阴影配置
if (options.shadowMapEnabled !== false && renderer.shadowMap) {
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.VSMShadowMap
}
}
}
/**
* WebGPU/WebGL
*/
export function takeScreenshot(renderer: UniversalRenderer, scene: THREE.Scene, camera: THREE.Camera): string {
// 强制渲染一帧
renderer.render(scene, camera)
return renderer.domElement.toDataURL('image/png')
}
@@ -0,0 +1,735 @@
/**
* Three.js
* 3D
*
*
* - WebGPU WebGL
* - GPU
* - THREE.ViewHelper
*/
import * as THREE from 'three'
import { ArcballControls } from 'three/examples/jsm/controls/ArcballControls.js'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper.js'
import type { RenderConfig, ViewPreset, CameraConfig } from '../types'
import {
createRenderer,
configureRenderer,
takeScreenshot,
type RendererType,
type UniversalRenderer
} from './RendererFactory'
/**
*
*/
export interface SceneManagerConfig {
container: HTMLElement
width?: number
height?: number
backgroundColor?: number
antialias?: boolean
showAxes?: boolean
showGrid?: boolean
/** 是否优先使用 WebGPU(默认 true */
preferWebGPU?: boolean
}
/**
*
*/
export class SceneManager {
public scene: THREE.Scene
public camera: THREE.PerspectiveCamera
public renderer!: UniversalRenderer
public controls: ArcballControls & { target: THREE.Vector3 }
/** ViewHelper 实例(右下角视图方向立方体) */
public viewHelper: ViewHelper | null = null
/** 当前渲染器类型 */
public rendererType: RendererType = 'webgl'
/** 本帧 Draw Callsrender 后捕获) */
public frameDrawCalls = 0
/** 场景总三角形数(模型变化时重新计算) */
public sceneTriangles = 0
/** 场景总顶点数(模型变化时重新计算) */
public sceneVertices = 0
private container: HTMLElement
private animationId: number | null = null
private width: number
private height: number
// 场景元素
private axesHelper: THREE.AxesHelper | null = null
private gridHelper: THREE.GridHelper | null = null
private ambientLight: THREE.AmbientLight
private directionalLight: THREE.DirectionalLight
// 模型组
public modelGroup: THREE.Group
// 渲染回调
private renderCallbacks: Array<() => void> = []
// 按需渲染:脏标记机制
private _needsRender = true
/** 是否正在进行相机动画 */
private isAnimating = false
/** ViewHelper 动画状态跟踪(用于动画结束时恢复 controls) */
private _viewHelperWasAnimating = false
/** 时钟(用于 ViewHelper delta time */
private clock = new THREE.Clock()
/** ViewHelper 视口尺寸(像素) */
private readonly VIEW_HELPER_DIM = 128
/** 初始化 Promise(用于等待 WebGPU 异步初始化) */
private initPromise: Promise<void>
constructor(config: SceneManagerConfig) {
this.container = config.container
this.width = config.width || config.container.clientWidth
this.height = config.height || config.container.clientHeight
// 创建场景
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(config.backgroundColor ?? 0xf5f5f5)
// 创建相机
this.camera = new THREE.PerspectiveCamera(
45,
this.width / this.height,
0.1,
10000
)
this.camera.position.set(100, 100, 100)
// 初始化渲染器(异步,WebGPU 需要 async init
this.initPromise = this.initRenderer(config)
// 创建临时 canvas(渲染器创建完成后会替换)
const tempCanvas = document.createElement('canvas')
this.container.appendChild(tempCanvas)
// 创建轨道控制器(先绑定临时 canvas,稍后更新)
// ArcballControls: 球面旋转、无万向节死锁,支持任意视角翻转
this.controls = new ArcballControls(this.camera, tempCanvas, null) as ArcballControls & { target: THREE.Vector3 }
this.controls.enableAnimations = false
this.controls.setGizmosVisible(false)
this.controls.minDistance = 1
this.controls.maxDistance = 5000
// 监听 change 事件标记需要渲染
this.controls.addEventListener('change', () => {
this.markDirty()
})
// 创建灯光
this.ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
this.scene.add(this.ambientLight)
this.directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
this.directionalLight.position.set(100, 100, 50)
// this.directionalLight.castShadow = false
// this.directionalLight.shadow.mapSize.width = 1024
// this.directionalLight.shadow.mapSize.height = 1024
this.scene.add(this.directionalLight)
// 添加补光
const fillLight = new THREE.DirectionalLight(0xffffff, 0.3)
fillLight.position.set(-100, -50, -100)
this.scene.add(fillLight)
// 创建模型组
this.modelGroup = new THREE.Group()
this.scene.add(this.modelGroup)
// 添加辅助元素
if (config.showAxes) {
this.showAxes(true)
}
if (config.showGrid) {
this.showGrid(true)
}
// 监听窗口变化
window.addEventListener('resize', this.handleResize)
// 开始渲染循环
this.startRenderLoop()
}
/**
* WebGPU
*/
private async initRenderer(config: SceneManagerConfig): Promise<void> {
const { renderer, type } = await createRenderer(
{
antialias: config.antialias !== false,
alpha: true,
preserveDrawingBuffer: true
},
config.preferWebGPU !== false
)
this.renderer = renderer
this.rendererType = type
// 配置渲染器通用属性
configureRenderer(renderer, type, {
width: this.width,
height: this.height,
shadowMapEnabled: true
})
// 替换临时 canvas
const tempCanvas = this.container.querySelector('canvas')
if (tempCanvas) {
this.container.removeChild(tempCanvas)
}
this.container.appendChild(renderer.domElement)
// 重建 ArcballControls 绑定到真正的渲染器 DOM
this.controls.dispose()
this.controls = new ArcballControls(this.camera, renderer.domElement, null) as ArcballControls & { target: THREE.Vector3 }
this.controls.enableAnimations = false
this.controls.setGizmosVisible(false)
this.controls.minDistance = 1
this.controls.maxDistance = 5000
this.controls.addEventListener('change', () => {
this.markDirty()
})
// 创建 ViewHelper(右下角视图方向立方体)
this.viewHelper = new ViewHelper(this.camera, renderer.domElement)
this.viewHelper.center = this.controls.target
// 添加 XYZ 轴标签提升可读性
try {
this.viewHelper.setLabels('X', 'Y', 'Z')
} catch {
// 低版本 Three.js 可能不支持 setLabels
}
this.markDirty()
}
/**
*
*/
async waitForReady(): Promise<void> {
await this.initPromise
}
/**
*
*/
private handleResize = () => {
this.width = this.container.clientWidth
this.height = this.container.clientHeight
this.camera.aspect = this.width / this.height
this.camera.updateProjectionMatrix()
if (this.renderer) {
this.renderer.setSize(this.width, this.height)
}
this.markDirty()
}
/**
*
*
*/
markDirty(): void {
this._needsRender = true
}
/**
*
* hover
*/
requestRender(): void {
this._needsRender = true
}
/**
*
*/
private computeSceneStats(): void {
let totalVertices = 0
let totalTriangles = 0
this.modelGroup.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
const geo = obj.geometry as THREE.BufferGeometry
const posAttr = geo.getAttribute('position')
if (posAttr) totalVertices += posAttr.count
const idx = geo.getIndex()
if (idx) {
totalTriangles += idx.count / 3
} else if (posAttr) {
totalTriangles += posAttr.count / 3
}
}
})
this.sceneTriangles = Math.round(totalTriangles)
this.sceneVertices = totalVertices
}
/**
* +
*/
private startRenderLoop() {
const animate = () => {
this.animationId = requestAnimationFrame(animate)
const delta = this.clock.getDelta()
// ViewHelper 动画检查(必须在 controls.update 前检查,决定是否跳过)
let viewHelperAnimating = false
if (this.viewHelper) {
viewHelperAnimating = this.viewHelper.animating
if (viewHelperAnimating) {
// ViewHelper 动画期间:直接驱动相机,跳过 controls.update() 避免干扰
this.viewHelper.update(delta)
this.markDirty()
}
this._viewHelperWasAnimating = viewHelperAnimating
}
// 更新控制器(仅在非 ViewHelper 动画期间)
if (!viewHelperAnimating) {
this.controls.update()
}
// 判断是否需要渲染
const shouldRender = this._needsRender
|| this.isAnimating
|| viewHelperAnimating
if (shouldRender && this.renderer && this.width > 0 && this.height > 0) {
// ★ 确保主渲染使用完整视口(防御 ViewHelper 上一帧未恢复视口的边界情况)
this.renderer.setViewport(0, 0, this.width, this.height)
this.renderer.render(this.scene, this.camera)
// 渲染 ViewHelper(在主渲染之后,ViewHelper.render 内置 viewport 切换和背景透明处理)
// ★ 关键修复: ViewHelper.render() 内部会调用 renderer.render()
// 而 WebGLRenderer.autoClear 默认为 true,会导致 gl.clear() 擦除整个帧缓冲区
// WebGL 的 clear 不受 viewport 限制),从而清掉已渲染的主场景。
// 解决方案:渲染 ViewHelper 前临时关闭 autoClear,渲染完毕后恢复。
// WebGPURenderer 的 render pass 机制不受此影响,但 save/restore 模式对其同样安全。
if (this.viewHelper) {
const savedAutoClear = this.renderer.autoClear
this.renderer.autoClear = false
try {
this.viewHelper.render(this.renderer as any)
} catch {
// ViewHelper 渲染失败不影响主渲染
} finally {
this.renderer.autoClear = savedAutoClear
}
}
// 捕获本帧渲染统计(在 render 之后、下次 auto-reset 之前读取)
this.frameDrawCalls = this.renderer.info?.render?.calls ?? 0
// 后置渲染回调(CSS2D 标签、统计更新等需在主渲染之后执行)
this.renderCallbacks.forEach(callback => callback())
// 重置脏标记
this._needsRender = false
}
}
animate()
}
/**
*
*/
addRenderCallback(callback: () => void): void {
this.renderCallbacks.push(callback)
}
/**
*
*/
removeRenderCallback(callback: () => void): void {
const index = this.renderCallbacks.indexOf(callback)
if (index > -1) {
this.renderCallbacks.splice(index, 1)
}
}
/**
* /
*/
showAxes(show: boolean, size: number = 100): void {
if (show) {
if (!this.axesHelper) {
this.axesHelper = new THREE.AxesHelper(size)
this.scene.add(this.axesHelper)
}
} else {
if (this.axesHelper) {
this.scene.remove(this.axesHelper)
this.axesHelper.dispose()
this.axesHelper = null
}
}
this.markDirty()
}
/**
* /
*/
showGrid(show: boolean, size: number = 500, divisions: number = 50): void {
if (show) {
if (!this.gridHelper) {
this.gridHelper = new THREE.GridHelper(size, divisions, 0x888888, 0xcccccc)
this.gridHelper.position.y = -0.01 // 稍微下移避免z-fighting
this.scene.add(this.gridHelper)
}
} else {
if (this.gridHelper) {
this.scene.remove(this.gridHelper)
this.gridHelper.dispose()
this.gridHelper = null
}
}
this.markDirty()
}
/**
*
*/
addModel(object: THREE.Object3D): void {
this.modelGroup.add(object)
this.computeSceneStats()
this.markDirty()
}
/**
*
*/
removeModel(object: THREE.Object3D): void {
this.modelGroup.remove(object)
this.markDirty()
}
/**
*
*/
clearModels(): void {
while (this.modelGroup.children.length > 0) {
const child = this.modelGroup.children[0]
this.modelGroup.remove(child)
this.disposeObject(child)
}
this.computeSceneStats()
this.markDirty()
}
/**
* MeshLineLineSegments
*/
private disposeObject(object: THREE.Object3D): void {
if (object instanceof THREE.Mesh || object instanceof THREE.Line) {
if (object.geometry) {
object.geometry.dispose()
}
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose())
} else {
object.material.dispose()
}
}
}
object.children.forEach(child => this.disposeObject(child))
}
/**
*
*/
fitToModel(padding: number = 1.5): void {
const box = new THREE.Box3().setFromObject(this.modelGroup)
if (box.isEmpty()) {
return
}
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
// 计算相机距离
const fov = this.camera.fov * (Math.PI / 180)
let cameraDistance = maxDim / (2 * Math.tan(fov / 2))
cameraDistance *= padding
// 设置相机位置(等轴测视角:标准导轨方向)
const direction = new THREE.Vector3(1, 1, 1).normalize()
this.camera.position.copy(center).add(direction.multiplyScalar(cameraDistance))
// 重置相机上方向,防止 TrackballControls 自由旋转导致载入时画面歪斜
this.camera.up.set(0, 1, 0)
// 更新控制器目标
this.controls.target.copy(center)
this.controls.update()
// 更新近远裁剪面
this.camera.near = cameraDistance / 100
this.camera.far = cameraDistance * 100
this.camera.updateProjectionMatrix()
// 更新灯光位置
this.directionalLight.position.copy(this.camera.position)
// 同步 ViewHelper 中心
if (this.viewHelper) {
this.viewHelper.center.copy(center)
}
this.markDirty()
}
/**
* ViewHelper
* ViewHelper
*/
handleViewHelperClick(event: PointerEvent | MouseEvent): boolean {
if (!this.viewHelper || !this.renderer) return false
// 检查点击是否位于右下角 ViewHelper 区域
const rect = this.renderer.domElement.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const dim = this.VIEW_HELPER_DIM
// ViewHelper 渲染在右下角,判断点击是否在该区域内
if (x < rect.width - dim || y < rect.height - dim) {
return false
}
const hit = this.viewHelper.handleClick(event as PointerEvent)
if (hit) {
// 仅提示帧需要渲染,不再操作 controls.enabled
// OrbitControls 的 pointerup 处理器将正常清理 state=NONE
this.markDirty()
}
return hit
}
/**
*
*/
setViewPreset(preset: ViewPreset, animate: boolean = true): void {
const box = new THREE.Box3().setFromObject(this.modelGroup)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z) * 2
let position: THREE.Vector3
let up = new THREE.Vector3(0, 1, 0)
switch (preset) {
case 'front':
position = new THREE.Vector3(0, 0, maxDim)
break
case 'back':
position = new THREE.Vector3(0, 0, -maxDim)
break
case 'top':
position = new THREE.Vector3(0, maxDim, 0)
up = new THREE.Vector3(0, 0, -1)
break
case 'bottom':
position = new THREE.Vector3(0, -maxDim, 0)
up = new THREE.Vector3(0, 0, 1)
break
case 'left':
position = new THREE.Vector3(-maxDim, 0, 0)
break
case 'right':
position = new THREE.Vector3(maxDim, 0, 0)
break
case 'isometric':
default:
position = new THREE.Vector3(maxDim, maxDim * 0.8, maxDim)
break
}
position.add(center)
if (animate) {
this.animateCameraTo(position, center, up)
} else {
this.camera.position.copy(position)
this.camera.up.copy(up)
this.controls.target.copy(center)
this.controls.update()
}
}
/**
*
*/
private animateCameraTo(
position: THREE.Vector3,
target: THREE.Vector3,
up: THREE.Vector3,
duration: number = 500
): void {
const startPosition = this.camera.position.clone()
const startTarget = this.controls.target.clone()
const startUp = this.camera.up.clone()
const startTime = Date.now()
this.isAnimating = true
const animate = () => {
const elapsed = Date.now() - startTime
const t = Math.min(elapsed / duration, 1)
const easeT = 1 - Math.pow(1 - t, 3) // easeOutCubic
this.camera.position.lerpVectors(startPosition, position, easeT)
this.controls.target.lerpVectors(startTarget, target, easeT)
this.camera.up.lerpVectors(startUp, up, easeT)
this.controls.update()
// 同步 ViewHelper 中心
if (this.viewHelper) {
this.viewHelper.center.copy(this.controls.target)
}
this.markDirty()
if (t < 1) {
requestAnimationFrame(animate)
} else {
this.isAnimating = false
}
}
animate()
}
/**
*
*/
getCameraConfig(): CameraConfig {
return {
position: this.camera.position.clone(),
target: this.controls.target.clone(),
up: this.camera.up.clone(),
fov: this.camera.fov,
near: this.camera.near,
far: this.camera.far
}
}
/**
*
*/
setCameraConfig(config: Partial<CameraConfig>, animate: boolean = false): void {
if (animate && config.position && config.target) {
this.animateCameraTo(
config.position,
config.target,
config.up || new THREE.Vector3(0, 1, 0)
)
} else {
if (config.position) this.camera.position.copy(config.position)
if (config.target) this.controls.target.copy(config.target)
if (config.up) this.camera.up.copy(config.up)
if (config.fov) this.camera.fov = config.fov
if (config.near) this.camera.near = config.near
if (config.far) this.camera.far = config.far
this.camera.updateProjectionMatrix()
this.controls.update()
this.markDirty()
}
}
/**
*
*/
setBackgroundColor(color: number): void {
this.scene.background = new THREE.Color(color)
this.markDirty()
}
/**
*
*/
screenshot(): string {
return takeScreenshot(this.renderer, this.scene, this.camera)
}
/**
* DOM
*/
getDomElement(): HTMLCanvasElement {
if (!this.renderer) {
throw new Error('Renderer 尚未初始化,请先调用 await waitForReady()')
}
return this.renderer.domElement
}
/**
*
*/
updateSize(width: number, height: number): void {
if (width <= 0 || height <= 0) return
this.width = width
this.height = height
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
if (this.renderer) {
this.renderer.setSize(width, height)
}
this.markDirty()
}
/**
*
*/
dispose(): void {
// 停止渲染循环
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
}
// 移除事件监听
window.removeEventListener('resize', this.handleResize)
// 清理控制器
this.controls.dispose()
// 清理 ViewHelper
if (this.viewHelper) {
this.viewHelper.dispose()
this.viewHelper = null
}
// 清理模型
this.clearModels()
// 清理辅助元素
this.showAxes(false)
this.showGrid(false)
// 清理渲染器
if (this.renderer) {
this.renderer.dispose()
if (this.renderer.domElement?.parentNode === this.container) {
this.container.removeChild(this.renderer.domElement)
}
}
}
}
export default SceneManager
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,992 @@
/**
* STEP
* 使 Web Worker + opencascade.js STEP Three.js
*
*
* - opencascade.js Worker 线线
* - 使 Comlink Worker
* - 线 Three.js BVH 线
*/
import * as THREE from 'three'
import { EdgesGeometry, LineSegments, LineBasicMaterial } from 'three'
import * as Comlink from 'comlink'
import type {
FileValidationResult,
UploadProgress,
SerializedSolidData,
SerializedTreeNode,
TreeNode,
SolidObject,
GeometryFeature,
FaceGroupInfo,
FaceGeometryData,
EdgeGroupInfo,
EdgeGeometryData,
WorkerResponse
} from '../types'
import { FeatureType } from '../types'
import { initBVH, buildBVH } from './BVHAccelerator'
import type { StepParseWorkerApi } from './StepParseWorker'
// Worker 状态管理
let worker: Worker | null = null
let workerProxy: Comlink.Remote<StepParseWorkerApi> | null = null
let workerReady = false
let workerInitPromise: Promise<void> | null = null
/**
* Worker Comlink
*/
function getWorkerProxy(): Comlink.Remote<StepParseWorkerApi> {
if (!workerProxy) {
worker = new Worker(
new URL('./StepParseWorker.ts', import.meta.url),
{ type: 'module' }
)
workerProxy = Comlink.wrap<StepParseWorkerApi>(worker)
}
return workerProxy
}
/**
* OpenCascade WASM Worker
* WASM
*/
export async function preloadOcct(): Promise<void> {
if (workerReady) return
if (workerInitPromise) {
await workerInitPromise
return
}
workerInitPromise = (async () => {
try {
const proxy = getWorkerProxy()
await proxy.init()
workerReady = true
} catch (err) {
workerInitPromise = null
throw err
}
})()
await workerInitPromise
}
/**
* OpenCascade Worker
*/
export function isOcctLoaded(): boolean {
return workerReady
}
/**
* Worker
*/
export function terminateWorker(): void {
if (workerProxy) {
workerProxy[Comlink.releaseProxy]()
workerProxy = null
}
if (worker) {
worker.terminate()
worker = null
workerReady = false
workerInitPromise = null
}
}
/**
* STEP
*/
export class StepLoader {
/** 边缘线默认颜色 */
private static readonly EDGE_COLOR = 0x333333
/** 边缘线默认线宽 */
private static readonly EDGE_LINE_WIDTH = 1
constructor() {
// 初始化 BVH 加速
initBVH()
}
/**
*
*/
validateFile(file: File): FileValidationResult {
if (!file) {
return { valid: false, error: '请选择文件' }
}
if (file.size === 0) {
return { valid: false, error: '文件为空' }
}
const fileName = file.name.toLowerCase()
const validExtensions = ['.step', '.stp']
const hasValidExtension = validExtensions.some(ext => fileName.endsWith(ext))
if (!hasValidExtension) {
return { valid: false, error: '仅支持 .step 或 .stp 格式文件' }
}
// 文件大小限制 (500MB)
const maxSize = 500 * 1024 * 1024
if (file.size > maxSize) {
return { valid: false, error: '文件大小超过 500MB 限制' }
}
return { valid: true, file }
}
/**
* STEP 使 Worker 线
*/
async loadFile(
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<{ solids: SolidObject[]; group: THREE.Group; treeNodes: TreeNode[] }> {
// 阶段 1:读取文件
onProgress?.({
status: 'uploading',
progress: 5,
message: '正在读取文件...'
})
const fileBuffer = await this.readFileAsArrayBuffer(file)
// 阶段 2:在 Worker 中解析 STEP(不阻塞主线程)
onProgress?.({
status: 'parsing',
progress: 10,
message: '正在初始化 OpenCascade 引擎...'
})
const { solids: serializedSolids, tree } = await this.parseInWorker(fileBuffer, onProgress)
// 阶段 3:在主线程构建 Three.js 对象
onProgress?.({
status: 'parsing',
progress: 80,
message: '正在构建 3D 模型...'
})
await this.yieldToMain()
const { solids, group } = this.buildThreeJSObjects(serializedSolids)
// ★ 按原始索引排序,保证 solids[solidIndex] 与树节点的 solidIndex 一致
solids.sort((a, b) => {
const aIdx = parseInt(a.id.replace('solid_', ''))
const bIdx = parseInt(b.id.replace('solid_', ''))
return aIdx - bIdx
})
// 阶段 4:构建结构树
const treeNodes = this.buildTreeNodes(tree)
onProgress?.({
status: 'success',
progress: 100,
message: '加载完成'
})
return { solids, group, treeNodes }
}
/**
* Worker STEP Comlink
*/
private async parseInWorker(
fileBuffer: ArrayBuffer,
onProgress?: (progress: UploadProgress) => void
): Promise<{ solids: SerializedSolidData[]; tree: SerializedTreeNode }> {
const proxy = getWorkerProxy()
const progressCallback = onProgress
? Comlink.proxy((stage: string, percent: number) => {
onProgress({
status: 'parsing',
progress: Math.min(Math.round(percent * 0.7) + 10, 78),
message: stage
})
})
: undefined
const result = await proxy.parse(fileBuffer, progressCallback)
return { solids: result.solids, tree: result.tree }
}
/**
* Three.js
* Solid GPU
* InstancedMesh InstancedMeshDraw Calls N 1
*/
private buildThreeJSObjects(serializedSolids: SerializedSolidData[]): {
solids: SolidObject[]
group: THREE.Group
} {
const group = new THREE.Group()
const solids: SolidObject[] = []
// 材质缓存:colorHex → MeshStandardMaterial
const materialCache = new Map<string, THREE.MeshStandardMaterial>()
// ★ InstancedMesh 阈值:≥ 3 个相同几何体才合并
const INSTANCE_THRESHOLD = 3
// Phase 1: 计算每个 Solid 的几何体指纹
interface SolidInfo {
index: number
data: SerializedSolidData
fingerprint: string
centroid: THREE.Vector3
}
const solidInfos: SolidInfo[] = serializedSolids.map((sd, i) => ({
index: i,
data: sd,
fingerprint: this.computeGeometryFingerprint(sd),
centroid: this.computeCentroid(sd.positions)
}))
// Phase 2: 按指纹分组
const groups = new Map<string, SolidInfo[]>()
for (const info of solidInfos) {
const list = groups.get(info.fingerprint) || []
list.push(info)
groups.set(info.fingerprint, list)
}
// Phase 3: 逐组构建 Mesh / InstancedMesh
for (const [, members] of groups) {
if (members.length >= INSTANCE_THRESHOLD) {
this.createInstancedSolids(members, materialCache, solids, group)
} else {
for (const member of members) {
this.createRegularSolid(member.data, member.index, materialCache, solids, group)
}
}
}
return { solids, group }
}
// ========== 几何体指纹 & 辅助方法 ==========
/**
*
* + + +
*/
private computeGeometryFingerprint(solidData: SerializedSolidData): string {
const posCount = solidData.positions.length / 3
const idxCount = solidData.indices.length
const faceCount = solidData.faceGroups.length
// 包围盒尺寸
let minX = Infinity, minY = Infinity, minZ = Infinity
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
for (let i = 0; i < solidData.positions.length; i += 3) {
const x = solidData.positions[i], y = solidData.positions[i + 1], z = solidData.positions[i + 2]
if (x < minX) minX = x; if (y < minY) minY = y; if (z < minZ) minZ = z
if (x > maxX) maxX = x; if (y > maxY) maxY = y; if (z > maxZ) maxZ = z
}
const w = (maxX - minX).toFixed(2)
const h = (maxY - minY).toFixed(2)
const d = (maxZ - minZ).toFixed(2)
// 面类型分布(防止不同形状但包围盒相同的误合并)
const faceTypeCounts: Record<string, number> = {}
for (const geom of solidData.faceGeometries) {
faceTypeCounts[geom.type] = (faceTypeCounts[geom.type] || 0) + 1
}
const faceTypeStr = Object.entries(faceTypeCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([t, c]) => `${t}${c}`)
.join(',')
return `${posCount}_${idxCount}_${faceCount}_${w}_${h}_${d}_${faceTypeStr}`
}
/**
* InstancedMesh
*/
private computeCentroid(positions: Float32Array): THREE.Vector3 {
const centroid = new THREE.Vector3()
const count = positions.length / 3
for (let i = 0; i < positions.length; i += 3) {
centroid.x += positions[i]
centroid.y += positions[i + 1]
centroid.z += positions[i + 2]
}
centroid.divideScalar(count)
return centroid
}
/**
*
*/
private getOrCreateMaterial(
solidData: SerializedSolidData,
cache: Map<string, THREE.MeshStandardMaterial>
): THREE.MeshStandardMaterial {
let colorHex = '8899aa'
if (solidData.color && solidData.color.length >= 3) {
colorHex = new THREE.Color(solidData.color[0], solidData.color[1], solidData.color[2]).getHexString()
}
let mat = cache.get(colorHex)
if (!mat) {
mat = new THREE.MeshStandardMaterial({
color: parseInt(colorHex, 16),
metalness: 0.3,
roughness: 0.6,
side: THREE.DoubleSide,
transparent: true,
opacity: 1
})
cache.set(colorHex, mat)
}
return mat
}
/**
*
*/
private computeBBoxFromPositions(
positions: Float32Array
): { min: THREE.Vector3; max: THREE.Vector3; center: THREE.Vector3 } | undefined {
if (positions.length < 3) return undefined
let minX = Infinity, minY = Infinity, minZ = Infinity
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1], z = positions[i + 2]
if (x < minX) minX = x; if (y < minY) minY = y; if (z < minZ) minZ = z
if (x > maxX) maxX = x; if (y > maxY) maxY = y; if (z > maxZ) maxZ = z
}
const min = new THREE.Vector3(minX, minY, minZ)
const max = new THREE.Vector3(maxX, maxY, maxZ)
const center = new THREE.Vector3().addVectors(min, max).multiplyScalar(0.5)
return { min, max, center }
}
// ========== Regular Mesh 创建 ==========
/**
* Mesh使
*/
private createRegularSolid(
solidData: SerializedSolidData,
solidIndex: number,
materialCache: Map<string, THREE.MeshStandardMaterial>,
solids: SolidObject[],
group: THREE.Group
): void {
const geometry = this.createGeometry(solidData)
const material = this.getOrCreateMaterial(solidData, materialCache)
const mesh = new THREE.Mesh(geometry, material)
mesh.name = solidData.name || `Solid_${solidIndex}`
mesh.userData = {
meshIndex: solidIndex,
solidIndex,
faceGroups: solidData.faceGroups,
faceGeometries: solidData.faceGeometries
}
buildBVH(geometry)
const edgeLines = this.createEdgeLines(geometry)
if (edgeLines) {
mesh.add(edgeLines) // 作为子对象跟随变换
}
// 构建拓扑边线段(可拾取)
const topologyEdges = this.createTopologyEdges(solidData)
if (topologyEdges) {
topologyEdges.visible = false // 默认隐藏,仅在边粒度模式下显示
mesh.add(topologyEdges)
}
geometry.computeBoundingBox()
const boundingBox = geometry.boundingBox
const center = new THREE.Vector3()
boundingBox?.getCenter(center)
const features = this.buildFeatures(mesh, solidData, solidIndex)
const edgeFeatures = this.buildEdgeFeatures(mesh, solidData, solidIndex)
let colorHex: number | undefined
if (solidData.color && solidData.color.length >= 3) {
colorHex = new THREE.Color(solidData.color[0], solidData.color[1], solidData.color[2]).getHex()
}
const solid: SolidObject = {
id: `solid_${solidIndex}`,
name: mesh.name,
mesh,
edgeLines: edgeLines || undefined,
topologyEdges: topologyEdges || undefined,
edgeFeatures,
treeNodeId: `solid_${solidIndex}`,
boundingBox: boundingBox ? {
min: boundingBox.min.clone(),
max: boundingBox.max.clone(),
center: center.clone()
} : undefined,
features,
visible: true,
opacity: 1,
selected: false,
color: colorHex,
serializedData: solidData
}
solids.push(solid)
group.add(mesh)
}
// ========== InstancedMesh 创建 ==========
/**
* Solid InstancedMesh
* Matrix4
* 使 instanceColor
* 优化7: EdgesGeometry 线 LineSegmentsvertexColors
* N EdgesGeometry + DrawCalls N 1
*/
private createInstancedSolids(
members: { index: number; data: SerializedSolidData; fingerprint: string; centroid: THREE.Vector3 }[],
materialCache: Map<string, THREE.MeshStandardMaterial>,
solids: SolidObject[],
group: THREE.Group
): void {
// 以第一个成员为参考创建共享几何体
const ref = members[0]
const sharedGeometry = this.createGeometry(ref.data)
// 将共享几何体中心化到原点
const refCentroid = ref.centroid
const positions = sharedGeometry.getAttribute('position') as THREE.BufferAttribute
for (let i = 0; i < positions.count; i++) {
positions.setXYZ(
i,
positions.getX(i) - refCentroid.x,
positions.getY(i) - refCentroid.y,
positions.getZ(i) - refCentroid.z
)
}
positions.needsUpdate = true
sharedGeometry.computeVertexNormals()
sharedGeometry.computeBoundingBox()
// BVH 加速(共享几何体只构建一次)
buildBVH(sharedGeometry)
// InstancedMesh 材质:白色基底(instanceColor 提供实际颜色)
const instanceMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.3,
roughness: 0.6,
side: THREE.DoubleSide,
transparent: true,
opacity: 1
})
const instancedMesh = new THREE.InstancedMesh(sharedGeometry, instanceMaterial, members.length)
instancedMesh.name = `Instanced_${ref.data.name || 'Solid'}_x${members.length}`
// 设置每个实例的变换矩阵和颜色
const tempMatrix = new THREE.Matrix4()
members.forEach((member, i) => {
tempMatrix.makeTranslation(member.centroid.x, member.centroid.y, member.centroid.z)
instancedMesh.setMatrixAt(i, tempMatrix)
let color = new THREE.Color(0x8899aa)
if (member.data.color && member.data.color.length >= 3) {
color = new THREE.Color(member.data.color[0], member.data.color[1], member.data.color[2])
}
instancedMesh.setColorAt(i, color)
})
instancedMesh.instanceMatrix.needsUpdate = true
if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true
group.add(instancedMesh)
// ★ 优化7: EdgesGeometry 只计算一次,合并所有实例的边缘线为单个 LineSegments
let mergedEdgeLines: LineSegments | null = null
const edgeVertexRanges = new Map<number, [number, number]>()
try {
const sharedEdgesGeo = new EdgesGeometry(sharedGeometry, 30)
const edgePosAttr = sharedEdgesGeo.getAttribute('position')
if (edgePosAttr && edgePosAttr.count > 0) {
const vertexCountPerInstance = edgePosAttr.count
const totalVertices = vertexCountPerInstance * members.length
const allPositions = new Float32Array(totalVertices * 3)
const allColors = new Float32Array(totalVertices * 3)
const defaultR = 0.2, defaultG = 0.2, defaultB = 0.2 // #333333
for (let i = 0; i < members.length; i++) {
const member = members[i]
const posOffset = i * vertexCountPerInstance * 3
const startVertex = i * vertexCountPerInstance
// 复制并平移边缘顶点(共享几何体已中心化,加上实例质心位置)
for (let v = 0; v < vertexCountPerInstance; v++) {
allPositions[posOffset + v * 3] = edgePosAttr.getX(v) + member.centroid.x
allPositions[posOffset + v * 3 + 1] = edgePosAttr.getY(v) + member.centroid.y
allPositions[posOffset + v * 3 + 2] = edgePosAttr.getZ(v) + member.centroid.z
allColors[posOffset + v * 3] = defaultR
allColors[posOffset + v * 3 + 1] = defaultG
allColors[posOffset + v * 3 + 2] = defaultB
}
edgeVertexRanges.set(i, [startVertex, vertexCountPerInstance])
}
const mergedGeo = new THREE.BufferGeometry()
mergedGeo.setAttribute('position', new THREE.Float32BufferAttribute(allPositions, 3))
mergedGeo.setAttribute('color', new THREE.Float32BufferAttribute(allColors, 3))
const mergedMaterial = new LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.6,
depthTest: true
})
mergedEdgeLines = new LineSegments(mergedGeo, mergedMaterial)
mergedEdgeLines.name = 'mergedEdgeLines'
mergedEdgeLines.renderOrder = 1
group.add(mergedEdgeLines)
}
sharedEdgesGeo.dispose()
} catch { /* ignore edge creation errors */ }
// ★ 拓扑边线段 — 合并所有实例的拓扑边为单个 LineSegments
let mergedTopologyEdges: LineSegments | null = null
const topologyEdgeVertexRangesAll = new Map<number, Map<number, [number, number]>>() // instanceIdx -> Map<edgeIndex, range>
try {
const refEdgeData = ref.data
if (refEdgeData.edgeGroups && refEdgeData.edgeGroups.length > 0 && refEdgeData.edgePolylines.length > 0) {
// 计算参考实例的拓扑边折线总点数(用于转换为线段格式)
const refPolylines = refEdgeData.edgePolylines
// 创建线段对:每条边的相邻点组成线段 (0-1, 1-2, 2-3, ...)
const segmentsPerEdge: number[][] = []
let totalSegmentVerts = 0
for (const eg of refEdgeData.edgeGroups) {
const segs: number[] = []
for (let p = 0; p < eg.polylineCount - 1; p++) {
const idx0 = (eg.polylineStart + p) * 3
const idx1 = (eg.polylineStart + p + 1) * 3
// 中心化(与 sharedGeometry 一致)
segs.push(
refPolylines[idx0] - refCentroid.x, refPolylines[idx0 + 1] - refCentroid.y, refPolylines[idx0 + 2] - refCentroid.z,
refPolylines[idx1] - refCentroid.x, refPolylines[idx1 + 1] - refCentroid.y, refPolylines[idx1 + 2] - refCentroid.z
)
}
segmentsPerEdge.push(segs)
totalSegmentVerts += segs.length / 3
}
const totalVerts = totalSegmentVerts * members.length
const allPos = new Float32Array(totalVerts * 3)
const allCol = new Float32Array(totalVerts * 3)
const allEdgeIdx = new Float32Array(totalVerts)
const defaultR = 0.4, defaultG = 0.4, defaultB = 0.4
let globalOffset = 0
for (let mi = 0; mi < members.length; mi++) {
const member = members[mi]
const rangesMap = new Map<number, [number, number]>()
let edgeVOffset = globalOffset
for (let ei = 0; ei < segmentsPerEdge.length; ei++) {
const segs = segmentsPerEdge[ei]
const segVertCount = segs.length / 3
const startV = edgeVOffset
for (let v = 0; v < segVertCount; v++) {
const gi = edgeVOffset * 3
allPos[gi] = segs[v * 3] + member.centroid.x
allPos[gi + 1] = segs[v * 3 + 1] + member.centroid.y
allPos[gi + 2] = segs[v * 3 + 2] + member.centroid.z
allCol[gi] = defaultR
allCol[gi + 1] = defaultG
allCol[gi + 2] = defaultB
allEdgeIdx[edgeVOffset] = ei
edgeVOffset++
}
rangesMap.set(ei, [startV, segVertCount])
}
topologyEdgeVertexRangesAll.set(mi, rangesMap)
globalOffset = edgeVOffset
}
const topoGeo = new THREE.BufferGeometry()
topoGeo.setAttribute('position', new THREE.Float32BufferAttribute(allPos, 3))
topoGeo.setAttribute('color', new THREE.Float32BufferAttribute(allCol, 3))
topoGeo.setAttribute('edgeIndex', new THREE.Float32BufferAttribute(allEdgeIdx, 1))
mergedTopologyEdges = new LineSegments(topoGeo, new LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.8,
depthTest: true
}))
mergedTopologyEdges.name = 'mergedTopologyEdges'
mergedTopologyEdges.renderOrder = 2
mergedTopologyEdges.visible = false
group.add(mergedTopologyEdges)
}
} catch { /* ignore */ }
// 为每个实例创建 SolidObject
members.forEach((member, i) => {
const solidIndex = member.index
// 构建特征列表(每个实例的 feature 使用其原始世界坐标)
const features = this.buildFeatures(
instancedMesh as unknown as THREE.Mesh,
member.data,
solidIndex
)
const bbox = this.computeBBoxFromPositions(member.data.positions)
let colorHex: number | undefined
if (member.data.color && member.data.color.length >= 3) {
colorHex = new THREE.Color(member.data.color[0], member.data.color[1], member.data.color[2]).getHex()
}
const range = edgeVertexRanges.get(i)
const topoRanges = topologyEdgeVertexRangesAll.get(i)
// 构建边特征
const edgeFeatures = this.buildEdgeFeatures(
instancedMesh as unknown as THREE.Mesh,
member.data,
solidIndex
)
const solid: SolidObject = {
id: `solid_${solidIndex}`,
name: member.data.name || `Solid_${solidIndex}`,
mesh: instancedMesh as unknown as THREE.Mesh,
instanceId: i,
edgeLines: mergedEdgeLines || undefined,
edgeVertexRange: range,
topologyEdges: mergedTopologyEdges || undefined,
topologyEdgeVertexRanges: topoRanges,
edgeFeatures,
treeNodeId: `solid_${solidIndex}`,
boundingBox: bbox,
features,
visible: true,
opacity: 1,
selected: false,
color: colorHex,
serializedData: member.data
}
solids.push(solid)
})
}
/**
* Three.js
*/
private createGeometry(solidData: SerializedSolidData): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute(solidData.positions, 3)
)
if (solidData.normals && solidData.normals.length > 0) {
geometry.setAttribute(
'normal',
new THREE.Float32BufferAttribute(solidData.normals, 3)
)
// 检查法线是否全 0(Worker 无法提取法线时填充 0)
let allZero = true
for (let i = 0; i < Math.min(solidData.normals.length, 30); i++) {
if (solidData.normals[i] !== 0) { allZero = false; break }
}
if (allZero) {
geometry.computeVertexNormals()
}
} else {
geometry.computeVertexNormals()
}
if (solidData.indices && solidData.indices.length > 0) {
geometry.setIndex(new THREE.BufferAttribute(solidData.indices, 1))
}
// 面索引属性(根据 faceGroups 构建)
if (solidData.faceGroups && solidData.faceGroups.length > 0) {
const vertexCount = solidData.positions.length / 3
const faceIndices = new Float32Array(vertexCount)
// 默认填充 -1
faceIndices.fill(-1)
const indexArray = solidData.indices
for (const group of solidData.faceGroups) {
for (let i = group.start; i < group.start + group.count; i++) {
const vertIdx = indexArray[i]
if (vertIdx !== undefined && vertIdx < vertexCount) {
faceIndices[vertIdx] = group.faceIndex
}
}
}
geometry.setAttribute(
'faceIndex',
new THREE.Float32BufferAttribute(faceIndices, 1)
)
}
return geometry
}
/**
* 线
*/
private createEdgeLines(geometry: THREE.BufferGeometry): LineSegments | null {
try {
const edgesGeo = new EdgesGeometry(geometry, 30) // 30° 阈值
if (edgesGeo.getAttribute('position')?.count === 0) return null
const edgeMaterial = new LineBasicMaterial({
color: StepLoader.EDGE_COLOR,
linewidth: StepLoader.EDGE_LINE_WIDTH,
transparent: true,
opacity: 0.6,
depthTest: true
})
const lines = new LineSegments(edgesGeo, edgeMaterial)
lines.name = 'edgeLines'
lines.renderOrder = 1
return lines
} catch {
return null
}
}
/**
* 线 OCCT Edge
*/
private createTopologyEdges(solidData: SerializedSolidData): LineSegments | null {
if (!solidData.edgeGroups || solidData.edgeGroups.length === 0) return null
if (!solidData.edgePolylines || solidData.edgePolylines.length === 0) return null
try {
// 将折线点转换为线段对格式: (p0,p1), (p1,p2), ...
const segments: number[] = []
const edgeIndices: number[] = []
for (const eg of solidData.edgeGroups) {
for (let p = 0; p < eg.polylineCount - 1; p++) {
const idx0 = (eg.polylineStart + p) * 3
const idx1 = (eg.polylineStart + p + 1) * 3
segments.push(
solidData.edgePolylines[idx0], solidData.edgePolylines[idx0 + 1], solidData.edgePolylines[idx0 + 2],
solidData.edgePolylines[idx1], solidData.edgePolylines[idx1 + 1], solidData.edgePolylines[idx1 + 2]
)
// 每个线段的两个顶点都记录边索引
edgeIndices.push(eg.edgeIndex, eg.edgeIndex)
}
}
if (segments.length === 0) return null
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(segments), 3))
geo.setAttribute('edgeIndex', new THREE.Float32BufferAttribute(new Float32Array(edgeIndices), 1))
const mat = new LineBasicMaterial({
color: 0x444444,
transparent: true,
opacity: 0.8,
depthTest: true
})
const lines = new LineSegments(geo, mat)
lines.name = 'topologyEdges'
lines.renderOrder = 2
return lines
} catch {
return null
}
}
/**
* Worker faceGeometries GeometryFeature
*/
private buildFeatures(
mesh: THREE.Mesh,
solidData: SerializedSolidData,
solidIndex: number
): GeometryFeature[] {
const features: GeometryFeature[] = []
solidData.faceGeometries.forEach((geom, faceIdx) => {
const featureType = this.mapFaceType(geom.type)
const feature: GeometryFeature = {
id: `feature_${solidIndex}_${faceIdx}`,
type: featureType,
mesh,
faceIndex: faceIdx,
solidId: `solid_${solidIndex}`,
treeNodeId: `solid_${solidIndex}_face_${faceIdx}`
}
// 设置几何属性
if (geom.center) {
feature.center = new THREE.Vector3(geom.center[0], geom.center[1], geom.center[2])
}
if (geom.normal) {
feature.normal = new THREE.Vector3(geom.normal[0], geom.normal[1], geom.normal[2]).normalize()
}
if (geom.axis) {
feature.axis = new THREE.Vector3(geom.axis[0], geom.axis[1], geom.axis[2]).normalize()
}
if (geom.radius !== undefined) feature.radius = geom.radius
if (geom.height !== undefined) feature.height = geom.height
if (geom.startAngle !== undefined) feature.startAngle = geom.startAngle
if (geom.endAngle !== undefined) feature.endAngle = geom.endAngle
if (geom.semiAngle !== undefined) feature.semiAngle = geom.semiAngle
if (geom.majorRadius !== undefined) feature.majorRadius = geom.majorRadius
if (geom.minorRadius !== undefined) feature.minorRadius = geom.minorRadius
// 原始颜色
if (mesh.material instanceof THREE.MeshStandardMaterial) {
feature.originalColor = (mesh.material as THREE.MeshStandardMaterial).color.getHex()
}
features.push(feature)
})
return features
}
/**
* FeatureType
*/
private mapFaceType(typeStr: string): FeatureType {
const map: Record<string, FeatureType> = {
plane: FeatureType.PLANE,
cylinder: FeatureType.CYLINDER,
cone: FeatureType.CONE,
sphere: FeatureType.SPHERE,
torus: FeatureType.TORUS,
circle: FeatureType.CIRCLE,
arc: FeatureType.ARC,
face: FeatureType.FACE
}
return map[typeStr] || FeatureType.FACE
}
/**
* Worker edgeGeometries GeometryFeature
*/
private buildEdgeFeatures(
mesh: THREE.Mesh,
solidData: SerializedSolidData,
solidIndex: number
): GeometryFeature[] {
const features: GeometryFeature[] = []
if (!solidData.edgeGeometries) return features
solidData.edgeGeometries.forEach((geom, edgeIdx) => {
const feature: GeometryFeature = {
id: `feature_${solidIndex}_edge_${edgeIdx}`,
type: FeatureType.EDGE,
mesh,
edgeIndex: edgeIdx,
solidId: `solid_${solidIndex}`,
treeNodeId: `solid_${solidIndex}_edge_${edgeIdx}`,
edgeCurveType: geom.curveType,
length: geom.length
}
if (geom.startPoint) {
feature.startPoint = new THREE.Vector3(geom.startPoint[0], geom.startPoint[1], geom.startPoint[2])
}
if (geom.endPoint) {
feature.endPoint = new THREE.Vector3(geom.endPoint[0], geom.endPoint[1], geom.endPoint[2])
}
if (geom.center) {
feature.center = new THREE.Vector3(geom.center[0], geom.center[1], geom.center[2])
}
if (geom.axis) {
feature.axis = new THREE.Vector3(geom.axis[0], geom.axis[1], geom.axis[2]).normalize()
}
if (geom.radius !== undefined) feature.radius = geom.radius
if (geom.startAngle !== undefined) feature.startAngle = geom.startAngle
if (geom.endAngle !== undefined) feature.endAngle = geom.endAngle
features.push(feature)
})
return features
}
/**
* Vue TreeNode[]
*/
private buildTreeNodes(serialTree: SerializedTreeNode): TreeNode[] {
const convert = (node: SerializedTreeNode): TreeNode => {
const treeNode: TreeNode = {
id: node.id,
name: node.name,
type: node.type,
solidIndex: node.solidIndex,
faceIndex: node.faceIndex,
edgeIndex: node.edgeIndex,
color: node.color,
visible: true
}
if (node.children && node.children.length > 0) {
treeNode.children = node.children.map(convert)
}
return treeNode
}
// 如果根只有一个子节点,直接返回子节点列表
const root = convert(serialTree)
return root.children || [root]
}
/**
* 线
*/
private yieldToMain(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
/**
* ArrayBuffer
*/
private readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsArrayBuffer(file)
})
}
}
export default StepLoader
@@ -0,0 +1,918 @@
/**
* STEP Web Worker
* 使 opencascade.js (XDE) 线 STEP
*
*
* - STEPCAFControl_Reader //
* - TopExp_Explorer Compound Solid Face
* - BRepAdaptor_Surface
* - BRepMesh_IncrementalMesh
* - BRep_Tool.Triangulation
* - Transferable
*/
import type {
WorkerRequest,
WorkerResponse,
SerializedSolidData,
SerializedTreeNode,
FaceGroupInfo,
FaceGeometryData,
EdgeGroupInfo,
EdgeGeometryData
} from '../types'
// OpenCascade 实例
let oc: any = null
/**
* 线
*/
function post(msg: WorkerResponse, transfer?: Transferable[]): void {
if (transfer && transfer.length > 0) {
; (self as unknown as Worker).postMessage(msg, transfer)
} else {
self.postMessage(msg)
}
}
/**
* opencascade.js WASMWorker
*/
async function initOC(): Promise<any> {
if (oc) return oc
post({ type: 'progress', stage: '正在加载 OpenCascade WASM 引擎...', percent: 5 })
try {
const initOpenCascade = (await import('opencascade.js')).default
oc = await initOpenCascade()
return oc
} catch (error) {
throw new Error(`OpenCascade WASM 初始化失败: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* GC OCCT
*/
function withGC<T>(fn: (register: <O>(obj: O) => O) => T): T {
const toDelete: any[] = []
const register = <O>(obj: O): O => {
toDelete.push(obj)
return obj
}
try {
return fn(register)
} finally {
for (const obj of toDelete) {
try {
if (obj && typeof obj.delete === 'function') obj.delete()
} catch { /* ignore */ }
}
}
}
/**
* STEP TopoDS_Shape
*/
function readStepFile(fileBuffer: ArrayBuffer): any {
return withGC((r) => {
const fileName = 'model.step'
// 写入 Emscripten 虚拟文件系统
oc.FS.createDataFile('/', fileName, new Uint8Array(fileBuffer), true, true, true)
post({ type: 'progress', stage: '正在读取 STEP 文件...', percent: 15 })
// 使用 STEPControl_Reader(简单可靠)
const reader = r(new oc.STEPControl_Reader_1())
const readResult = reader.ReadFile(fileName)
// 清理虚拟文件
try { oc.FS.unlink(`/${fileName}`) } catch { /* ignore */ }
if (readResult !== oc.IFSelect_ReturnStatus.IFSelect_RetDone) {
throw new Error('STEP 文件读取失败,请检查文件是否损坏')
}
post({ type: 'progress', stage: '正在转换模型数据...', percent: 25 })
// 转换所有根实体
reader.TransferRoots(r(new oc.Message_ProgressRange_1()))
// 获取合并后的形状(不注册到 GC,由调用方管理)
const shape = reader.OneShape()
return shape
})
}
/**
* 使 BRepAdaptor_Surface
*/
function extractFaceGeometry(face: any): FaceGeometryData {
return withGC((r) => {
const adaptor = r(new oc.BRepAdaptor_Surface_2(face, false))
const surfType = adaptor.GetType()
const ga = oc.GeomAbs_SurfaceType
const result: FaceGeometryData = { type: 'face' }
try {
if (surfType === ga.GeomAbs_Plane) {
result.type = 'plane'
const plane = adaptor.Plane()
const loc = plane.Location()
const dir = plane.Axis().Direction()
result.center = [loc.X(), loc.Y(), loc.Z()]
result.normal = [dir.X(), dir.Y(), dir.Z()]
} else if (surfType === ga.GeomAbs_Cylinder) {
const cyl = adaptor.Cylinder()
const ax = cyl.Axis()
const loc = ax.Location()
const dir = ax.Direction()
result.center = [loc.X(), loc.Y(), loc.Z()]
result.axis = [dir.X(), dir.Y(), dir.Z()]
result.normal = [dir.X(), dir.Y(), dir.Z()]
result.radius = cyl.Radius()
// UV 参数域
const uMin = adaptor.FirstUParameter()
const uMax = adaptor.LastUParameter()
const vMin = adaptor.FirstVParameter()
const vMax = adaptor.LastVParameter()
result.uBounds = [uMin, uMax]
result.vBounds = [vMin, vMax]
result.startAngle = uMin
result.endAngle = uMax
// 计算高度
if (isFinite(vMin) && isFinite(vMax)) {
result.height = Math.abs(vMax - vMin)
}
// 判断是否是完整圆柱还是圆弧柱
if (Math.abs(uMax - uMin) < Math.PI * 1.99) {
result.type = 'arc'
} else {
result.type = 'cylinder'
}
} else if (surfType === ga.GeomAbs_Cone) {
result.type = 'cone'
const cone = adaptor.Cone()
const apex = cone.Apex()
const ax = cone.Axis()
const dir = ax.Direction()
result.center = [apex.X(), apex.Y(), apex.Z()]
result.axis = [dir.X(), dir.Y(), dir.Z()]
result.normal = [dir.X(), dir.Y(), dir.Z()]
result.semiAngle = cone.SemiAngle()
result.radius = cone.RefRadius()
} else if (surfType === ga.GeomAbs_Sphere) {
result.type = 'sphere'
const sphere = adaptor.Sphere()
const center = sphere.Location()
result.center = [center.X(), center.Y(), center.Z()]
result.radius = sphere.Radius()
} else if (surfType === ga.GeomAbs_Torus) {
result.type = 'torus'
const torus = adaptor.Torus()
const center = torus.Location()
const dir = torus.Axis().Direction()
result.center = [center.X(), center.Y(), center.Z()]
result.axis = [dir.X(), dir.Y(), dir.Z()]
result.normal = [dir.X(), dir.Y(), dir.Z()]
result.majorRadius = torus.MajorRadius()
result.minorRadius = torus.MinorRadius()
result.radius = torus.MajorRadius()
} else {
// BezierSurface, BSplineSurface, 等 — 只提取 UV 域
result.type = 'face'
}
} catch {
// 几何提取失败时用默认值
result.type = 'face'
}
return result
})
}
/**
* PLANE
*/
function checkCircularPlaneFace(face: any, positions: Float32Array, startVertex: number, vertexCount: number): FaceGeometryData | null {
if (vertexCount < 6) return null
// 计算中心
let cx = 0, cy = 0, cz = 0
for (let i = 0; i < vertexCount; i++) {
const vi = (startVertex + i) * 3
cx += positions[vi]
cy += positions[vi + 1]
cz += positions[vi + 2]
}
cx /= vertexCount
cy /= vertexCount
cz /= vertexCount
// 计算到中心的距离
let sumDist = 0
const distances: number[] = []
for (let i = 0; i < vertexCount; i++) {
const vi = (startVertex + i) * 3
const dx = positions[vi] - cx
const dy = positions[vi + 1] - cy
const dz = positions[vi + 2] - cz
const d = Math.sqrt(dx * dx + dy * dy + dz * dz)
distances.push(d)
sumDist += d
}
const avgDist = sumDist / vertexCount
if (avgDist < 0.001) return null
// 10% 容差检查
const tolerance = avgDist * 0.1
const isCircular = distances.every(d => Math.abs(d - avgDist) < tolerance)
if (!isCircular) return null
return {
type: 'circle',
center: [cx, cy, cz],
radius: avgDist
}
}
/**
* Face
*/
function extractFaceTriangulation(face: any, globalVertexOffset: number): {
positions: number[]
normals: number[]
indices: number[]
vertexCount: number
} | null {
return withGC((r) => {
const location = r(new oc.TopLoc_Location_1())
const triangulationHandle = oc.BRep_Tool.Triangulation(face, location, 0)
if (triangulationHandle.IsNull()) return null
const tri = triangulationHandle.get()
const transformation = location.Transformation()
const nbNodes = tri.NbNodes()
const nbTriangles = tri.NbTriangles()
if (nbNodes === 0 || nbTriangles === 0) return null
const positions: number[] = []
const normals: number[] = []
// 提取顶点位置
for (let i = 1; i <= nbNodes; i++) {
const node = tri.Node(i)
const p = node.Transformed(transformation)
positions.push(p.X(), p.Y(), p.Z())
}
// 提取法线
const hasNormals = tri.HasNormals()
if (hasNormals) {
for (let i = 1; i <= nbNodes; i++) {
try {
const normal = tri.Normal(i)
normals.push(normal.X(), normal.Y(), normal.Z())
} catch {
normals.push(0, 1, 0) // fallback
}
}
} else {
// 填充零法线,后续在 Three.js 中 computeVertexNormals
for (let i = 0; i < nbNodes; i++) {
normals.push(0, 0, 0)
}
}
// 提取三角形索引并处理面朝向
const orient = face.Orientation_1()
const isReversed = (orient === oc.TopAbs_Orientation.TopAbs_REVERSED)
const indices: number[] = []
for (let i = 1; i <= nbTriangles; i++) {
const triangle = tri.Triangle(i)
let n1 = triangle.Value(1)
let n2 = triangle.Value(2)
const n3 = triangle.Value(3)
// 反面翻转绕序
if (isReversed) {
const tmp = n1
n1 = n2
n2 = tmp
}
// OCCT 索引 1-based → 0-based + 全局偏移
indices.push(
n1 - 1 + globalVertexOffset,
n2 - 1 + globalVertexOffset,
n3 - 1 + globalVertexOffset
)
}
// 如果反面,也翻转法线
if (isReversed && hasNormals) {
for (let i = 0; i < normals.length; i++) {
normals[i] = -normals[i]
}
}
return { positions, normals, indices, vertexCount: nbNodes }
})
}
/**
* 使 BRepAdaptor_Curve
*/
function extractEdgeGeometry(edge: any): EdgeGeometryData {
return withGC((r) => {
const adaptor = r(new oc.BRepAdaptor_Curve_2(edge))
const curveTypeEnum = adaptor.GetType()
const ga = oc.GeomAbs_CurveType
let curveType = 'other'
const result: Partial<EdgeGeometryData> = {}
try {
if (curveTypeEnum === ga.GeomAbs_Line) {
curveType = 'line'
} else if (curveTypeEnum === ga.GeomAbs_Circle) {
curveType = 'circle'
const circ = adaptor.Circle()
result.radius = circ.Radius()
const center = circ.Location()
result.center = [center.X(), center.Y(), center.Z()]
const dir = circ.Axis().Direction()
result.axis = [dir.X(), dir.Y(), dir.Z()]
} else if (curveTypeEnum === ga.GeomAbs_Ellipse) {
curveType = 'ellipse'
} else if (curveTypeEnum === ga.GeomAbs_BSplineCurve) {
curveType = 'bspline'
} else if (curveTypeEnum === ga.GeomAbs_BezierCurve) {
curveType = 'bezier'
}
} catch { /* ignore */ }
// 提取起止点
const uFirst = adaptor.FirstParameter()
const uLast = adaptor.LastParameter()
result.startAngle = uFirst
result.endAngle = uLast
try {
const pStart = r(new oc.gp_Pnt_1())
adaptor.D0(uFirst, pStart)
result.startPoint = [pStart.X(), pStart.Y(), pStart.Z()]
const pEnd = r(new oc.gp_Pnt_1())
adaptor.D0(uLast, pEnd)
result.endPoint = [pEnd.X(), pEnd.Y(), pEnd.Z()]
} catch {
result.startPoint = [0, 0, 0]
result.endPoint = [0, 0, 0]
}
// 计算长度
let length = 0
try {
length = oc.GCPnts_AbscissaPoint.Length_3(adaptor)
} catch {
// 回退:用起止点距离估算
if (result.startPoint && result.endPoint) {
const dx = result.endPoint[0] - result.startPoint[0]
const dy = result.endPoint[1] - result.startPoint[1]
const dz = result.endPoint[2] - result.startPoint[2]
length = Math.sqrt(dx * dx + dy * dy + dz * dz)
}
}
result.length = length
return {
curveType,
length: result.length || 0,
startPoint: result.startPoint || [0, 0, 0],
endPoint: result.endPoint || [0, 0, 0],
radius: result.radius,
center: result.center,
axis: result.axis,
startAngle: result.startAngle,
endAngle: result.endAngle
}
})
}
/**
* 线
*/
function discretizeEdge(edge: any): number[] {
return withGC((r) => {
const adaptor = r(new oc.BRepAdaptor_Curve_2(edge))
const points: number[] = []
try {
// 使用 GCPnts_TangentialDeflection 自适应离散化
const deflector = r(new oc.GCPnts_TangentialDeflection_2(
adaptor, 0.1, 0.1, 2, 200, 0.0001
))
const nbPoints = deflector.NbPoints()
for (let i = 1; i <= nbPoints; i++) {
const p = deflector.Value(i)
points.push(p.X(), p.Y(), p.Z())
}
} catch {
// 回退:简单均匀采样
try {
const uFirst = adaptor.FirstParameter()
const uLast = adaptor.LastParameter()
const nbSamples = 20
for (let i = 0; i <= nbSamples; i++) {
const u = uFirst + (uLast - uFirst) * i / nbSamples
const p = r(new oc.gp_Pnt_1())
adaptor.D0(u, p)
points.push(p.X(), p.Y(), p.Z())
}
} catch { /* ignore */ }
}
return points
})
}
/**
* Solid
*/
function extractEdgesFromSolid(solidShape: any): {
edgeGroups: EdgeGroupInfo[]
edgeGeometries: EdgeGeometryData[]
edgePolylines: Float32Array
} {
const edgeGroups: EdgeGroupInfo[] = []
const edgeGeometries: EdgeGeometryData[] = []
const allPolylineData: number[] = []
// 用 IndexedMapOfShape 去重边
const edgeMap = new oc.TopTools_IndexedMapOfShape_1()
oc.TopExp.MapShapes_1(solidShape, oc.TopAbs_ShapeEnum.TopAbs_EDGE, edgeMap)
// 构建 edge → 相邻 face 的映射
const edgeFaceMap = new oc.TopTools_IndexedDataMapOfShapeListOfShape_1()
oc.TopExp.MapShapesAndAncestors(
solidShape,
oc.TopAbs_ShapeEnum.TopAbs_EDGE,
oc.TopAbs_ShapeEnum.TopAbs_FACE,
edgeFaceMap
)
// 构建 face 索引映射(用于查找相邻面索引)
const faceMap = new oc.TopTools_IndexedMapOfShape_1()
oc.TopExp.MapShapes_1(solidShape, oc.TopAbs_ShapeEnum.TopAbs_FACE, faceMap)
const nbEdges = edgeMap.Extent()
let polylineOffset = 0
for (let i = 1; i <= nbEdges; i++) {
try {
const edge = oc.TopoDS.Edge_1(edgeMap.FindKey(i))
// 跳过退化边(seam 边等)
if (oc.BRep_Tool.Degenerated(edge)) continue
// 提取几何信息
const geom = extractEdgeGeometry(edge)
// 离散化为折线
const polyline = discretizeEdge(edge)
if (polyline.length < 6) continue // 至少 2 个点
const edgeIndex = edgeGroups.length
const polylineCount = polyline.length / 3
// 查找相邻面
const adjacentFaceIndices: number[] = []
try {
if (edgeFaceMap.Contains(edgeMap.FindKey(i))) {
const faceList = edgeFaceMap.FindFromKey(edgeMap.FindKey(i))
const iter = new oc.TopTools_ListIteratorOfListOfShape_2(faceList)
for (; iter.More(); iter.Next()) {
const adjFace = iter.Value()
const faceIdx = faceMap.FindIndex(adjFace)
if (faceIdx > 0) {
adjacentFaceIndices.push(faceIdx - 1) // 0-based
}
}
iter.delete()
}
} catch { /* ignore */ }
edgeGroups.push({
edgeIndex,
polylineStart: polylineOffset,
polylineCount,
adjacentFaceIndices
})
edgeGeometries.push(geom)
for (let j = 0; j < polyline.length; j++) {
allPolylineData.push(polyline[j])
}
polylineOffset += polylineCount
} catch { /* skip problematic edges */ }
}
edgeMap.delete()
edgeFaceMap.delete()
faceMap.delete()
return {
edgeGroups,
edgeGeometries,
edgePolylines: new Float32Array(allPolylineData)
}
}
/**
* Solid
*/
function countSolids(shape: any): number {
const explorer = new oc.TopExp_Explorer_2(
shape, oc.TopAbs_ShapeEnum.TopAbs_SOLID, oc.TopAbs_ShapeEnum.TopAbs_SHAPE
)
let count = 0
for (; explorer.More(); explorer.Next()) count++
explorer.delete()
if (count === 0) {
const shellExplorer = new oc.TopExp_Explorer_2(
shape, oc.TopAbs_ShapeEnum.TopAbs_FACE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE
)
count = shellExplorer.More() ? 1 : 0
shellExplorer.delete()
}
return Math.max(count, 1)
}
/**
* Solid
*/
function extractSingleSolid(solidShape: any, solidIndex: number): SerializedSolidData | null {
const allPositions: number[] = []
const allNormals: number[] = []
const allIndices: number[] = []
const faceGroups: FaceGroupInfo[] = []
const faceGeometries: FaceGeometryData[] = []
let globalVertexOffset = 0
let faceIndex = 0
// 遍历 Solid 中的所有 Face
const faceExplorer = new oc.TopExp_Explorer_2(
solidShape,
oc.TopAbs_ShapeEnum.TopAbs_FACE,
oc.TopAbs_ShapeEnum.TopAbs_SHAPE
)
for (; faceExplorer.More(); faceExplorer.Next()) {
const face = oc.TopoDS.Face_1(faceExplorer.Current())
// 提取三角化数据
const meshData = extractFaceTriangulation(face, globalVertexOffset)
if (!meshData) {
faceIndex++
continue
}
const indexStart = allIndices.length
// 合并数据
for (let i = 0; i < meshData.positions.length; i++) allPositions.push(meshData.positions[i])
for (let i = 0; i < meshData.normals.length; i++) allNormals.push(meshData.normals[i])
for (let i = 0; i < meshData.indices.length; i++) allIndices.push(meshData.indices[i])
faceGroups.push({
start: indexStart,
count: meshData.indices.length,
faceIndex
})
// 提取面的精确几何信息
let geom = extractFaceGeometry(face)
// 对 PLANE 类型面额外检测是否为圆形
if (geom.type === 'plane') {
const circleCheck = checkCircularPlaneFace(
face,
new Float32Array(meshData.positions),
0,
meshData.vertexCount
)
if (circleCheck) {
// 保留原始法向信息
circleCheck.normal = geom.normal
geom = circleCheck
}
}
faceGeometries.push(geom)
globalVertexOffset += meshData.vertexCount
faceIndex++
}
faceExplorer.delete()
if (allPositions.length === 0) return null
// 提取拓扑边数据
const edgeData = extractEdgesFromSolid(solidShape)
return {
name: `Solid_${solidIndex}`,
positions: new Float32Array(allPositions),
normals: new Float32Array(allNormals),
indices: new Uint32Array(allIndices),
faceGroups,
faceGeometries,
edgeGroups: edgeData.edgeGroups,
edgeGeometries: edgeData.edgeGeometries,
edgePolylines: edgeData.edgePolylines
}
}
// buildTreeData 已移除,改用 parseStepFile 中的统一递归构建
/**
* Solid Edge
*/
function buildSolidTreeNode(solidIndex: number, solidData?: SerializedSolidData): SerializedTreeNode | null {
if (!solidData) return null
const solidNode: SerializedTreeNode = {
id: `solid_${solidIndex}`,
name: solidData.name || `Solid_${solidIndex}`,
type: 'solid',
solidIndex,
children: []
}
// 添加 Edge 子节点
solidData.edgeGeometries.forEach((geom, edgeIdx) => {
const edgeTypeName = getEdgeDisplayName(geom.curveType)
solidNode.children!.push({
id: `solid_${solidIndex}_edge_${edgeIdx}`,
name: `${edgeTypeName}_${edgeIdx}`,
type: 'edge',
solidIndex,
edgeIndex: edgeIdx
})
})
return solidNode
}
/**
* 线
*/
function getEdgeDisplayName(curveType: string): string {
const names: Record<string, string> = {
line: '直线',
circle: '圆弧',
ellipse: '椭圆弧',
bspline: 'B样条曲线',
bezier: '贝塞尔曲线',
other: '曲线'
}
return names[curveType] || '边'
}
/**
* STEP
* solidIndex
*
*/
function parseStepFile(fileBuffer: ArrayBuffer): {
solids: SerializedSolidData[]
tree: SerializedTreeNode
transferList: Transferable[]
} {
// 1. 读取 STEP 文件
const shape = readStepFile(fileBuffer)
post({ type: 'progress', stage: '正在三角化模型...', percent: 40 })
// 2. 三角化整个形状
withGC((r) => {
r(new oc.BRepMesh_IncrementalMesh_2(shape, 0.1, false, 0.5, false))
})
post({ type: 'progress', stage: '正在提取网格数据...', percent: 50 })
// 3. 统一递归提取网格 + 构建树
const totalSolids = countSolids(shape)
const solids: SerializedSolidData[] = []
let solidIndex = 0
let compoundIndex = 0
/**
* Mesh TreeNode
* solidIndex Solid tree.solidIndex === solids[]
*/
function processShape(s: any, depth: number): SerializedTreeNode | null {
const sType = s.ShapeType()
// SOLID → 提取网格 + 生成树节点
if (sType === oc.TopAbs_ShapeEnum.TopAbs_SOLID) {
const solidData = extractSingleSolid(s, solidIndex)
if (!solidData) return null
solids.push(solidData)
const node = buildSolidTreeNode(solidIndex, solidData)
solidIndex++
const pct = 50 + Math.round((solidIndex / Math.max(totalSolids, 1)) * 30)
post({ type: 'progress', stage: `正在处理实体 ${solidIndex}/${totalSolids}...`, percent: Math.min(pct, 80) })
return node
}
// COMPOUND / COMPSOLID → 递归子形状
if (sType === oc.TopAbs_ShapeEnum.TopAbs_COMPOUND ||
sType === oc.TopAbs_ShapeEnum.TopAbs_COMPSOLID) {
const compId = compoundIndex++
const children: SerializedTreeNode[] = []
const iter = new oc.TopoDS_Iterator_2(s, true, true)
for (; iter.More(); iter.Next()) {
const childNode = processShape(iter.Value(), depth + 1)
if (childNode) children.push(childNode)
}
iter.delete()
if (children.length === 0) return null
// 顶层 Compound 使用 root 类型
if (depth === 0) {
return { id: 'root', name: 'Model', type: 'root', children }
}
return {
id: `compound_${compId}`,
name: `Component_${compId}`,
type: 'compound',
children
}
}
// SHELL / FACE 等 → 作为单个 Solid 提取
if (sType === oc.TopAbs_ShapeEnum.TopAbs_SHELL ||
sType === oc.TopAbs_ShapeEnum.TopAbs_FACE) {
const solidData = extractSingleSolid(s, solidIndex)
if (!solidData) return null
solids.push(solidData)
const node = buildSolidTreeNode(solidIndex, solidData)
solidIndex++
return node
}
return null
}
const shapeType = shape.ShapeType()
let tree: SerializedTreeNode
if (shapeType === oc.TopAbs_ShapeEnum.TopAbs_COMPOUND ||
shapeType === oc.TopAbs_ShapeEnum.TopAbs_COMPSOLID) {
tree = processShape(shape, 0) || { id: 'root', name: 'Model', type: 'root', children: [] }
} else {
const childNode = processShape(shape, 1)
tree = {
id: 'root',
name: 'Model',
type: 'root',
children: childNode ? [childNode] : []
}
}
post({ type: 'progress', stage: '正在传输数据...', percent: 85 })
// 4. 收集 Transferable
const transferList: Transferable[] = []
for (const solid of solids) {
transferList.push(solid.positions.buffer)
transferList.push(solid.normals.buffer)
transferList.push(solid.indices.buffer)
if (solid.edgePolylines.byteLength > 0) {
transferList.push(solid.edgePolylines.buffer)
}
}
// 5. 清理 shape
try { shape.delete() } catch { /* ignore */ }
return { solids, tree, transferList }
}
// ============ Comlink API ============
import * as Comlink from 'comlink'
/** 进度回调类型 */
export type ProgressCallback = (stage: string, percent: number) => void
/**
* Worker API Comlink
*/
const workerApi = {
/**
* OpenCascade WASM
*/
async init(): Promise<void> {
await initOC()
},
/**
* STEP
* @param fileBuffer STEP
* @param onProgress Comlink.proxy
*/
async parse(
fileBuffer: ArrayBuffer,
onProgress?: ProgressCallback
): Promise<{ solids: SerializedSolidData[]; tree: SerializedTreeNode }> {
// 覆盖 post 函数以使用 Comlink 进度回调
const origPost = (self as any).__origPost
if (onProgress) {
// 劫持 post 以转发 progress 事件到 Comlink 回调
; (self as any).__progressCb = onProgress
}
await initOC()
// 解析 STEP 文件
const { solids, tree } = parseStepFile(fileBuffer)
; (self as any).__progressCb = null
return { solids, tree }
}
}
// 劫持 post 函数,使 progress 事件可通过 Comlink 回调转发
const origPost = post
; (self as any).__origPost = origPost
// 重写 post 以支持 Comlink 模式
function postHook(msg: WorkerResponse, transfer?: Transferable[]): void {
if (msg.type === 'progress' && (self as any).__progressCb) {
try {
; (self as any).__progressCb(msg.stage, msg.percent)
} catch { /* ignore callback errors */ }
}
// 仍然发送原始 postMessage 以保持兼容性
origPost(msg, transfer)
}
// 替换全局 post
; (post as any) = postHook
export type StepParseWorkerApi = typeof workerApi
Comlink.expose(workerApi)
// ============ 传统 postMessage API(保持向后兼容) ============
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
// 如果消息由 Comlink 处理,则跳过
if (event.data && typeof event.data === 'object' && !('type' in event.data)) return
const request = event.data
try {
switch (request.type) {
case 'init': {
await initOC()
origPost({ type: 'ready' })
break
}
case 'parse': {
await initOC()
const { solids, tree, transferList } = parseStepFile(request.fileBuffer)
origPost({ type: 'progress', stage: '传输数据中...', percent: 95 })
origPost(
{ type: 'result', solids, tree, success: true },
transferList
)
break
}
}
} catch (error) {
origPost({
type: 'error',
message: error instanceof Error ? error.message : '未知解析错误'
})
}
}
// 通知主线程 Worker 已加载
origPost({ type: 'progress', stage: 'Worker 已就绪', percent: 0 })
@@ -0,0 +1,363 @@
/**
* URDF XML
*/
import type { URDFRobot, URDFLink, URDFJoint, URDFOrigin, JointLimits, JointType, InertialParams } from '../types'
import * as THREE from 'three'
/** 序列化选项 */
export interface SerializeOptions {
/**
* linkId Link Link
* COM Link
*/
linkRestInverses?: Map<string, THREE.Matrix4>
/**
* 线mm m 0.001
* /
*/
unitScale?: number
/**
* baseLinkOrientation base_link (T × R)
* base_link origin使 URDF
*/
basePoseInverse?: THREE.Matrix4
/** base_link 对应的 linkId,配合 basePoseInverse 使用 */
baseLinkId?: string
}
/**
* URDFRobot URDF XML
*/
export function serializeURDF(robot: URDFRobot, options?: SerializeOptions): string {
const lines: string[] = []
const s = options?.unitScale ?? 1
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push(`<robot name="${escapeXml(robot.name)}">`)
for (const link of robot.links) {
const restInverse = options?.linkRestInverses?.get(link.id)
lines.push(serializeLink(link, s, restInverse))
}
for (const joint of robot.joints) {
lines.push(serializeJoint(joint, robot, s, options))
}
lines.push('</robot>')
return lines.join('\n')
}
function serializeLink(link: URDFLink, unitScale: number, restInverse?: THREE.Matrix4): string {
const lines: string[] = []
const s = unitScale
lines.push(` <link name="${escapeXml(link.name)}">`)
if (link.inertial) {
// 如果提供了 restInverse,将质心从世界坐标变换到 Link 局部坐标
let comLocal = link.inertial.com
if (restInverse) {
const me = restInverse.elements
const [cx, cy, cz] = comLocal
comLocal = [
me[0] * cx + me[4] * cy + me[8] * cz + me[12],
me[1] * cx + me[5] * cy + me[9] * cz + me[13],
me[2] * cx + me[6] * cy + me[10] * cz + me[14]
]
}
// 应用单位缩放(mm → m
const comScaled: [number, number, number] = [comLocal[0] * s, comLocal[1] * s, comLocal[2] * s]
// 惯性张量:原始值在 STEP 世界坐标轴下(由 InertiaWorker 计算),URDF 要求在 link-local 轴下。
// 若 restInverse 包含旋转(link frame ≠ world frame),必须执行轴旋转变换:
// I_local = R_wl · I_world · R_wl^T
// 其中 R_wl 为 restInverse 的旋转部分(将世界向量映射到 link-local 向量)。
// 仅平移(无旋转)时 R_wl = I,变换是恒等的,可统一调用。
let inertiaLocal = link.inertial.inertia as [number, number, number, number, number, number]
if (restInverse) {
inertiaLocal = rotateInertiaTensor(inertiaLocal, restInverse)
}
const [ixx, ixy, ixz, iyy, iyz, izz] = inertiaLocal
lines.push(' <inertial>')
lines.push(` <mass value="${fmtNum(link.inertial.mass)}"/>`)
lines.push(` <origin xyz="${fmtVec3(comScaled)}" rpy="0 0 0"/>`)
// inertia 已转换到 link-local 轴、SI 单位 kg·m²,直接写入
lines.push(` <inertia ixx="${fmtNum(ixx)}" ixy="${fmtNum(ixy)}" ixz="${fmtNum(ixz)}" iyy="${fmtNum(iyy)}" iyz="${fmtNum(iyz)}" izz="${fmtNum(izz)}"/>`)
lines.push(' </inertial>')
}
// Visual — 引用 STL 网格
if (link.solidIds.length > 0) {
lines.push(' <visual>')
lines.push(' <origin xyz="0 0 0" rpy="0 0 0"/>')
lines.push(' <geometry>')
lines.push(` <mesh filename="meshes/${escapeXml(link.name)}.stl"/>`)
lines.push(' </geometry>')
lines.push(' </visual>')
lines.push(' <collision>')
lines.push(' <origin xyz="0 0 0" rpy="0 0 0"/>')
lines.push(' <geometry>')
lines.push(` <mesh filename="meshes/${escapeXml(link.name)}.stl"/>`)
lines.push(' </geometry>')
lines.push(' </collision>')
}
lines.push(' </link>')
return lines.join('\n')
}
function serializeJoint(joint: URDFJoint, robot: URDFRobot, unitScale: number, options?: SerializeOptions): string {
const lines: string[] = []
const s = unitScale
const parentLink = robot.links.find(l => l.id === joint.parentLinkId)
const childLink = robot.links.find(l => l.id === joint.childLinkId)
const parentName = parentLink?.name || joint.parentLinkId
const childName = childLink?.name || joint.childLinkId
// 是否是 base_link 直接子关节:需要将 origin 从 STEP 世界坐标变换到 URDF 基坐标系
const isBaseChild = !!options?.basePoseInverse && !!options?.baseLinkId
&& joint.parentLinkId === options.baseLinkId
let xyzFinal = joint.origin.xyz as [number, number, number]
let rpyFinal = joint.origin.rpy as [number, number, number]
// 合并 axisOffset 到 origin.xyz
const axOff = joint.axisOffset || [0, 0, 0]
xyzFinal = [
xyzFinal[0] + axOff[0],
xyzFinal[1] + axOff[1],
xyzFinal[2] + axOff[2]
]
if (isBaseChild) {
const bpi = options!.basePoseInverse!
const me = bpi.elements
const [ox, oy, oz] = xyzFinal
// 变换平移分量: bpi 全变换(平移 + 旋转)
xyzFinal = [
me[0] * ox + me[4] * oy + me[8] * oz + me[12],
me[1] * ox + me[5] * oy + me[9] * oz + me[13],
me[2] * ox + me[6] * oy + me[10] * oz + me[14]
]
// 变换旋转分量: R_bpi × R(joint.rpy) → 提取新 RPY
const rJoint = new THREE.Matrix4().makeRotationFromEuler(
new THREE.Euler(joint.origin.rpy[0], joint.origin.rpy[1], joint.origin.rpy[2], 'ZYX')
)
const rBpi = new THREE.Matrix4().extractRotation(bpi)
const rCombined = new THREE.Matrix4().multiplyMatrices(rBpi, rJoint)
rpyFinal = matrixToRPY(rCombined)
}
// origin xyz 需要缩放,rpy 不变(弧度)
const xyzScaled: [number, number, number] = [
xyzFinal[0] * s,
xyzFinal[1] * s,
xyzFinal[2] * s
]
lines.push(` <joint name="${escapeXml(joint.name)}" type="${joint.type}">`)
lines.push(` <parent link="${escapeXml(parentName)}"/>`)
lines.push(` <child link="${escapeXml(childName)}"/>`)
lines.push(` <origin xyz="${fmtVec3(xyzScaled)}" rpy="${fmtVec3(rpyFinal)}"/>`)
lines.push(` <axis xyz="${fmtVec3(joint.axis)}"/>`)
if (joint.type !== 'fixed') {
// prismatic 关节的 limits 是线性尺寸,需要缩放;revolute/continuous 是弧度,不缩放
const isPrismatic = joint.type === 'prismatic'
const limitScale = isPrismatic ? s : 1
const velScale = isPrismatic ? s : 1 // prismatic: m/srevolute: rad/s
lines.push(` <limit lower="${fmtNum(joint.limits.lower * limitScale)}" upper="${fmtNum(joint.limits.upper * limitScale)}" effort="${fmtNum(joint.limits.effort)}" velocity="${fmtNum(joint.limits.velocity * velScale)}"/>`)
}
lines.push(' </joint>')
return lines.join('\n')
}
/**
* URDF XML URDFRobot
*/
export function deserializeURDF(xml: string): URDFRobot {
const parser = new DOMParser()
const doc = parser.parseFromString(xml, 'application/xml')
const errorNode = doc.querySelector('parsererror')
if (errorNode) {
throw new Error('URDF XML 解析失败: ' + errorNode.textContent)
}
const robotEl = doc.querySelector('robot')
if (!robotEl) {
throw new Error('URDF XML 中未找到 <robot> 元素')
}
const robotName = robotEl.getAttribute('name') || 'robot'
const links: URDFLink[] = []
const joints: URDFJoint[] = []
// 解析 Links
const linkEls = robotEl.querySelectorAll(':scope > link')
linkEls.forEach((el, idx) => {
const name = el.getAttribute('name') || `Link_${idx + 1}`
const link: URDFLink = {
id: `link_${idx + 1}`,
name,
solidIds: [],
inertial: null,
}
const inertialEl = el.querySelector('inertial')
if (inertialEl) {
link.inertial = parseInertial(inertialEl)
}
links.push(link)
})
// 构建 name → link id 映射
const nameToId = new Map<string, string>()
links.forEach(l => nameToId.set(l.name, l.id))
// 解析 Joints
const jointEls = robotEl.querySelectorAll(':scope > joint')
jointEls.forEach((el, idx) => {
const name = el.getAttribute('name') || `Joint_${idx + 1}`
const type = (el.getAttribute('type') || 'fixed') as JointType
const parentEl = el.querySelector('parent')
const childEl = el.querySelector('child')
const parentName = parentEl?.getAttribute('link') || ''
const childName = childEl?.getAttribute('link') || ''
const originEl = el.querySelector('origin')
const origin = parseOrigin(originEl)
const axisEl = el.querySelector('axis')
const axis = parseVec3(axisEl?.getAttribute('xyz') || '0 0 1') as [number, number, number]
const limitEl = el.querySelector('limit')
const limits = parseLimits(limitEl)
joints.push({
id: `joint_${idx + 1}`,
name,
type,
parentLinkId: nameToId.get(parentName) || parentName,
childLinkId: nameToId.get(childName) || childName,
origin,
axis,
limits,
currentValue: 0,
axisOffset: [0, 0, 0] as [number, number, number]
})
})
return { name: robotName, links, joints }
}
// ============ 辅助函数 ============
function parseInertial(el: Element): InertialParams {
const massEl = el.querySelector('mass')
const mass = parseFloat(massEl?.getAttribute('value') || '0')
const originEl = el.querySelector('origin')
const com = parseVec3(originEl?.getAttribute('xyz') || '0 0 0') as [number, number, number]
const inertiaEl = el.querySelector('inertia')
const ixx = parseFloat(inertiaEl?.getAttribute('ixx') || '0')
const ixy = parseFloat(inertiaEl?.getAttribute('ixy') || '0')
const ixz = parseFloat(inertiaEl?.getAttribute('ixz') || '0')
const iyy = parseFloat(inertiaEl?.getAttribute('iyy') || '0')
const iyz = parseFloat(inertiaEl?.getAttribute('iyz') || '0')
const izz = parseFloat(inertiaEl?.getAttribute('izz') || '0')
return { mass, com, inertia: [ixx, ixy, ixz, iyy, iyz, izz] }
}
function parseOrigin(el: Element | null): URDFOrigin {
if (!el) return { xyz: [0, 0, 0], rpy: [0, 0, 0] }
return {
xyz: parseVec3(el.getAttribute('xyz') || '0 0 0') as [number, number, number],
rpy: parseVec3(el.getAttribute('rpy') || '0 0 0') as [number, number, number]
}
}
function parseLimits(el: Element | null): JointLimits {
if (!el) return { lower: -3.14159, upper: 3.14159, effort: 100, velocity: 1 }
return {
lower: parseFloat(el.getAttribute('lower') || '-3.14159'),
upper: parseFloat(el.getAttribute('upper') || '3.14159'),
effort: parseFloat(el.getAttribute('effort') || '100'),
velocity: parseFloat(el.getAttribute('velocity') || '1')
}
}
function parseVec3(str: string): [number, number, number] {
const parts = str.trim().split(/\s+/).map(Number)
return [parts[0] || 0, parts[1] || 0, parts[2] || 0]
}
function fmtNum(n: number): string {
return Number.isFinite(n) ? parseFloat(n.toFixed(8)).toString() : '0'
}
/**
*
*
* URDF <inertia> link frame <origin rpy="0 0 0">
* InertiaWorker STEP I_local = R · I · Rᵀ
*
* @param inertia [ixx, ixy, ixz, iyy, iyz, izz]kg·m²
* @param m restInverse R link-local
* @returns [ixx, ixy, ixz, iyy, iyz, izz]link-local kg·m²
*/
function rotateInertiaTensor(
inertia: readonly [number, number, number, number, number, number],
m: THREE.Matrix4
): [number, number, number, number, number, number] {
// Three.js Matrix4 按列存储:elements[col*4 + row]
// R[row][col] = e[col*4 + row]
const e = m.elements
const Rx = [e[0], e[4], e[8]] // row 0 of rotation
const Ry = [e[1], e[5], e[9]] // row 1
const Rz = [e[2], e[6], e[10]] // row 2
const [Ixx, Ixy, Ixz, Iyy, Iyz, Izz] = inertia
// 对称惯性矩阵 MM[i][j] = r_i^T · I · r_j,其中 r_i 为 R 的第 i 行向量
// I_local[i][j] = (R·I·Rᵀ)[i][j] = r_i · (I · r_j)
const dot = (r: number[], c: number[]) =>
r[0] * (c[0] * Ixx + c[1] * Ixy + c[2] * Ixz) +
r[1] * (c[0] * Ixy + c[1] * Iyy + c[2] * Iyz) +
r[2] * (c[0] * Ixz + c[1] * Iyz + c[2] * Izz)
return [
dot(Rx, Rx), // ixx_local
dot(Rx, Ry), // ixy_local
dot(Rx, Rz), // ixz_local
dot(Ry, Ry), // iyy_local
dot(Ry, Rz), // iyz_local
dot(Rz, Rz), // izz_local
]
}
function fmtVec3(v: [number, number, number] | number[]): string {
return `${fmtNum(v[0])} ${fmtNum(v[1])} ${fmtNum(v[2])}`
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/**
* ZYX intrinsic RPY Three.js 'ZYX' Euler
*/
function matrixToRPY(m: THREE.Matrix4): [number, number, number] {
const euler = new THREE.Euler().setFromRotationMatrix(m, 'ZYX')
return [euler.x, euler.y, euler.z]
}
@@ -0,0 +1,28 @@
/**
*
*/
export { StepLoader, preloadOcct, isOcctLoaded, terminateWorker } from './StepLoader'
export { SceneManager } from './SceneManager'
export { SelectionManager } from './SelectionManager'
export { LineMeasurementTool } from './LineMeasurementTool'
export { FrameVisualizer } from './FrameVisualizer'
export { ForwardKinematics } from './ForwardKinematics'
export { JointSnapVisualizer } from './JointSnapVisualizer'
export { serializeURDF, deserializeURDF } from './URDFSerializer'
export {
createRenderer,
isWebGPUAvailable,
isWebGPURenderer,
configureRenderer,
takeScreenshot
} from './RendererFactory'
export type { SceneManagerConfig } from './SceneManager'
export type { SelectionManagerConfig, SelectionEvent } from './SelectionManager'
export type { LineMeasurementToolConfig, LineMeasurementData } from './LineMeasurementTool'
export type { RendererType, UniversalRenderer, RendererConfig, RendererResult } from './RendererFactory'
export type { InertiaWorkerApi } from './InertiaWorker'
export type { ExportWorkerApi } from './ExportWorker'
export type { KinematicsWorkerApi } from './KinematicsWorker'
export type { JointSnapVisualizerConfig } from './JointSnapVisualizer'
@@ -0,0 +1,51 @@
/**
* Worker
* ExportWorker URDF ZIP
*/
// 主线程也引用一次,避免 Vite 首次打开 Worker 时才发现 jszip 并整页 reload
import 'jszip'
import * as Comlink from 'comlink'
import type { ExportWorkerApi } from './ExportWorker'
import type { SerializedSolidData } from '../types'
let worker: Worker | null = null
let workerProxy: Comlink.Remote<ExportWorkerApi> | null = null
function getProxy(): Comlink.Remote<ExportWorkerApi> {
if (!workerProxy) {
worker = new Worker(
new URL('./ExportWorker.ts', import.meta.url),
{ type: 'module' }
)
workerProxy = Comlink.wrap<ExportWorkerApi>(worker)
}
return workerProxy
}
/**
* Worker URDF ZIP STL + ZIP Worker 线
*/
export async function exportURDFInWorker(
urdfXml: string,
linkSolidMap: Record<string, SerializedSolidData[]>,
linkRestInverseMap: Record<string, number[]>,
unitScale: number,
onProgress?: (stage: string, percent: number) => void
): Promise<ArrayBuffer> {
const proxy = getProxy()
return proxy.exportURDF(
urdfXml,
linkSolidMap,
linkRestInverseMap,
unitScale,
onProgress ? Comlink.proxy(onProgress) : undefined
)
}
export function disposeExportWorker(): void {
workerProxy?.[Comlink.releaseProxy]()
worker?.terminate()
worker = null
workerProxy = null
}
@@ -0,0 +1,154 @@
/**
* Worker Composable
* 使 InertiaWorker Comlink
*/
import * as Comlink from 'comlink'
import type { InertiaWorkerApi } from './InertiaWorker'
import type { SerializedSolidData, InertialParams } from '../types'
let worker: Worker | null = null
let workerProxy: Comlink.Remote<InertiaWorkerApi> | null = null
let initPromise: Promise<void> | null = null
/**
* Worker OC
*/
async function getProxy(): Promise<Comlink.Remote<InertiaWorkerApi>> {
if (!workerProxy) {
worker = new Worker(
new URL('./InertiaWorker.ts', import.meta.url),
{ type: 'module' }
)
workerProxy = Comlink.wrap<InertiaWorkerApi>(worker)
}
if (!initPromise) {
initPromise = workerProxy.init()
}
await initPromise
return workerProxy
}
/**
* Link
* @param solidDataList Link Solid
* @param density (kg/m³)
* @returns InertialParams
*/
export async function computeLinkInertia(
solidDataList: SerializedSolidData[],
density: number
): Promise<InertialParams> {
const proxy = await getProxy()
// InertiaWorker 仅使用 positions 和 indices 字段。
// solidDataList 来自 Vue reactive store,对象被 Proxy 包裹,
// 浏览器 postMessage 的 structuredClone 不支持 Proxy → 会抛出 DataCloneError。
// 解决方案:提取所需字段重建纯数据对象(不复制 TypedArray 内容,仅重新包装)。
const plainList: SerializedSolidData[] = solidDataList.map(d => ({
name: d.name ?? '',
positions: d.positions,
normals: d.normals ?? new Float32Array(0),
indices: d.indices,
faceGroups: [],
faceGeometries: [],
edgeGroups: [],
edgeGeometries: [],
edgePolylines: new Float32Array(0),
}))
return proxy.computeInertia(plainList, density)
}
/**
* density=1 totalMass
*
*
* inertia_at_mass = refInertia × (mass / refMass)
*
* @param links Link
* @returns Map<linkId, InertialParams>density=1
*/
export async function computeRefInertias(
links: { linkId: string; solidDataList: SerializedSolidData[] }[]
): Promise<Map<string, InertialParams>> {
const validLinks = links.filter(l => l.solidDataList.length > 0)
if (validLinks.length === 0) return new Map()
const result = new Map<string, InertialParams>()
for (const l of validLinks) {
try {
const r = await computeLinkInertia(l.solidDataList, 1)
if (r.mass > 0) result.set(l.linkId, r)
} catch {
// 单个 Link 失败不影响其他
}
}
return result
}
/**
* Worker
*/
export function disposeInertiaWorker(): void {
if (workerProxy) {
workerProxy[Comlink.releaseProxy]()
workerProxy = null
}
if (worker) {
worker.terminate()
worker = null
}
initPromise = null
}
/**
* Link
*
* density=1 Link
* k = totalMass / totalRefMass Link
*
*
* @param links Link solidDataList Link
* @param totalMass (kg)
* @returns Map<linkId, InertialParams>
*/
export async function computeAllLinksInertia(
links: { linkId: string; solidDataList: SerializedSolidData[] }[],
totalMass: number
): Promise<Map<string, InertialParams>> {
// 过滤掉没有几何数据的 Link
const validLinks = links.filter(l => l.solidDataList.length > 0)
if (validLinks.length === 0) return new Map()
// 以 density=1 顺序计算各 Link 的参考惯性(质量 = 体积)
// 注意:使用顺序计算而非 Promise.all,避免并发向同一 Worker 发送多条消息时
// 任意单条失败导致整批结果全部丢失的问题。
const refResults: (InertialParams | null)[] = []
for (const l of validLinks) {
try {
const r = await computeLinkInertia(l.solidDataList, 1)
refResults.push(r)
} catch {
// 单个 Link 计算失败时跳过,不影响其余 Link
refResults.push(null)
}
}
// 仅对计算成功的 Link 求参考总质量
const totalRefMass = refResults.reduce<number>((sum, r) => sum + (r?.mass ?? 0), 0)
if (totalRefMass <= 0) return new Map()
const k = totalMass / totalRefMass
// 按缩放因子分配质量和惯性张量,跳过计算失败的 Link
const result = new Map<string, InertialParams>()
for (let i = 0; i < validLinks.length; i++) {
const ref = refResults[i]
if (!ref || ref.mass <= 0) continue // 零体积或失败的 Link 不写入结果
result.set(validLinks[i].linkId, {
mass: ref.mass * k,
com: ref.com, // 质心位置与密度无关
inertia: ref.inertia.map(v => v * k) as InertialParams['inertia'],
})
}
return result
}

Some files were not shown because too many files have changed in this diff Show More