Initial commit: step2urdf tool with handtuned JSON import.
Includes FastAPI backend, vendored step2urdf frontend, and A7 handtuned arm JSON for URDF generation.
@@ -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
|
||||
@@ -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/
|
||||
@@ -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 step2urdf’s 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.
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
@@ -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,线性单位米、角度弧度。",
|
||||
"旋转关节默认绕关节局部 Z(axis=[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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,15 @@
|
||||
*.sh
|
||||
node_modules
|
||||
*.md
|
||||
*.woff
|
||||
*.ttf
|
||||
.vscode
|
||||
.idea
|
||||
dist
|
||||
/public
|
||||
/docs
|
||||
.husky
|
||||
.local
|
||||
/bin
|
||||
/src/mock/*
|
||||
stats.html
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
ignore-dep-scripts=false
|
||||
@@ -0,0 +1,10 @@
|
||||
/dist/*
|
||||
.local
|
||||
/node_modules/**
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
||||
|
||||
/public/*
|
||||
stats.html
|
||||
pnpm-lock.yaml
|
||||
@@ -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
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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, user‑friendly 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 real‑time 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`.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 33 KiB |
@@ -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,线性单位米、角度弧度。",
|
||||
"旋转关节默认绕关节局部 Z(axis=[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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,3 @@
|
||||
// 后端微服务模块前缀
|
||||
export const PORT1 = "/geeker";
|
||||
export const PORT2 = "/hooks";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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("请求失败!");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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", {});
|
||||
};
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 加载进度 0–100 */
|
||||
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 ==========
|
||||
|
||||
// 深度监听关节所有属性变化(currentValue、origin、axis、limits 等),实时更新 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)
|
||||
}
|
||||
|
||||
// ——— 暴露给父组件(3D→UI 双向同步) ———
|
||||
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">基坐标系 RPY(rad)</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·m²)</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 版本兼容问题,结果与解析解完全一致。
|
||||
*
|
||||
* 单位约定(与项目其余部分一致):
|
||||
* 输入坐标 : mm(STEP 世界坐标)
|
||||
* 输入密度 : kg/m³
|
||||
* 输出质量 : kg
|
||||
* 输出质心 : mm(URDFSerializer 负责 ×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 轴对齐 normal,X/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 Calls(render 后捕获) */
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归释放对象资源(包括 Mesh、Line、LineSegments 等)
|
||||
*/
|
||||
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
|
||||
@@ -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:大量相同几何体合并为 InstancedMesh,Draw 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 只计算一次,所有实例的边缘线合并为单个 LineSegments(vertexColors)
|
||||
* 减少 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 WASM(Worker 内单例)
|
||||
*/
|
||||
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/s,revolute: 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
|
||||
|
||||
// 对称惯性矩阵 M:M[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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从旋转矩阵提取 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
|
||||
}
|
||||