commit 93773f3887a68cf9d05ce60405ab9858206a79cc Author: sunxianghui Date: Wed Aug 26 15:32:47 2026 +0800 Initial commit: step2urdf tool with handtuned JSON import. Includes FastAPI backend, vendored step2urdf frontend, and A7 handtuned arm JSON for URDF generation. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ebf7c1d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d4e76e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fdc88a8 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9a55287 --- /dev/null +++ b/app/config.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..afa1c71 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..f238794 --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/a7_gold.py b/app/services/a7_gold.py new file mode 100644 index 0000000..2d162aa --- /dev/null +++ b/app/services/a7_gold.py @@ -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, + ) diff --git a/app/services/mesh_export.py b/app/services/mesh_export.py new file mode 100644 index 0000000..a401510 --- /dev/null +++ b/app/services/mesh_export.py @@ -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} diff --git a/app/services/pipeline.py b/app/services/pipeline.py new file mode 100644 index 0000000..96eddd8 --- /dev/null +++ b/app/services/pipeline.py @@ -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 diff --git a/app/services/profiles.py b/app/services/profiles.py new file mode 100644 index 0000000..4e59de9 --- /dev/null +++ b/app/services/profiles.py @@ -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) diff --git a/app/services/step_text.py b/app/services/step_text.py new file mode 100644 index 0000000..1e44e35 --- /dev/null +++ b/app/services/step_text.py @@ -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", + }, + } diff --git a/app/services/urdf_builder.py b/app/services/urdf_builder.py new file mode 100644 index 0000000..feb040d --- /dev/null +++ b/app/services/urdf_builder.py @@ -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 .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), + } diff --git a/app/services/zhipu.py b/app/services/zhipu.py new file mode 100644 index 0000000..a819a37 --- /dev/null +++ b/app/services/zhipu.py @@ -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) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..da4f215 --- /dev/null +++ b/app/static/app.js @@ -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(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..b1bd217 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,61 @@ + + + + + + Legacy UI — step2urdf-tool + + + +
+
+
+

LEGACY

+

旧版静态 UI(已降级)

+

+ 主界面请用 step2urdf 前端:开发时打开 + http://127.0.0.1:5678 + (Vite + 智谱建议关节 + 导出 URDF)。本页仅保留调试。 +

+
+
checking…
+
+ +
+

1. 上传 STEP

+
+ + +
+
等待上传…
+
+ +
+

2. 智谱关节提案

+
+ + +
+ + +
等待提案…
+
+ +
+

3. 导出 URDF(服务端草稿)

+ +
等待导出…
+
+
+ + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..99e5b52 --- /dev/null +++ b/app/static/style.css @@ -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); } diff --git a/data/handtuned_arm.json b/data/handtuned_arm.json new file mode 100644 index 0000000..5f5b6aa --- /dev/null +++ b/data/handtuned_arm.json @@ -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 + } + } + ] +} diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..ecea360 --- /dev/null +++ b/frontend/.editorconfig @@ -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 diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..0c3be4e --- /dev/null +++ b/frontend/.env.development @@ -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"]] + diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..5f50bff --- /dev/null +++ b/frontend/.env.production @@ -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 diff --git a/frontend/.env.test b/frontend/.env.test new file mode 100644 index 0000000..52a4634 --- /dev/null +++ b/frontend/.env.test @@ -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" diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000..2debdc3 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,15 @@ +*.sh +node_modules +*.md +*.woff +*.ttf +.vscode +.idea +dist +/public +/docs +.husky +.local +/bin +/src/mock/* +stats.html diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..84e2508 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -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- 使用注释或要求在指令后进行描述 + "@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", // 防止 + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..51c66a1 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.jpg b/frontend/public/favicon.jpg new file mode 100644 index 0000000..74f3625 Binary files /dev/null and b/frontend/public/favicon.jpg differ diff --git a/frontend/public/handtuned_arm.json b/frontend/public/handtuned_arm.json new file mode 100644 index 0000000..5f5b6aa --- /dev/null +++ b/frontend/public/handtuned_arm.json @@ -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 + } + } + ] +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f83878a --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/frontend/src/api/config/servicePort.ts b/frontend/src/api/config/servicePort.ts new file mode 100644 index 0000000..42b7887 --- /dev/null +++ b/frontend/src/api/config/servicePort.ts @@ -0,0 +1,3 @@ +// 后端微服务模块前缀 +export const PORT1 = "/geeker"; +export const PORT2 = "/hooks"; diff --git a/frontend/src/api/helper/axiosCancel.ts b/frontend/src/api/helper/axiosCancel.ts new file mode 100644 index 0000000..0b791bf --- /dev/null +++ b/frontend/src/api/helper/axiosCancel.ts @@ -0,0 +1,55 @@ +import { CustomAxiosRequestConfig } from "../index"; +import qs from "qs"; + +// 声明一个 Map 用于存储每个请求的标识和取消函数 +let pendingMap = new Map(); + +// 序列化参数,确保对象属性顺序一致 +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(); + } +} diff --git a/frontend/src/api/helper/checkStatus.ts b/frontend/src/api/helper/checkStatus.ts new file mode 100644 index 0000000..ab2c685 --- /dev/null +++ b/frontend/src/api/helper/checkStatus.ts @@ -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("请求失败!"); + } +}; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..854daad --- /dev/null +++ b/frontend/src/api/index.ts @@ -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(url: string, params?: object, _object = {}): Promise> { + return this.service.get(url, { params, ..._object }); + } + post(url: string, params?: object | string, _object = {}): Promise> { + return this.service.post(url, params, _object); + } + postAudio(url: string, params?: object | string, _object = {}): Promise { + return this.service.post(url, params, { ..._object, responseType: "blob", headers: { Accept: "audio/wav" } }); + } + put(url: string, params?: object, _object = {}): Promise> { + return this.service.put(url, params, _object); + } + delete(url: string, params?: any, _object = {}): Promise> { + return this.service.delete(url, { params, ..._object }); + } + download(url: string, params?: object, _object = {}): Promise { + return this.service.post(url, params, { ..._object, responseType: "blob" }); + } +} + +export default new RequestHttp(config); diff --git a/frontend/src/api/interface/index.ts b/frontend/src/api/interface/index.ts new file mode 100644 index 0000000..520cd6d --- /dev/null +++ b/frontend/src/api/interface/index.ts @@ -0,0 +1,96 @@ +// 请求响应参数(不包含data) +export interface Result { + code: string; + msg: string; +} + +// 请求响应参数(包含data) +export interface ResultData extends Result { + data: T; +} + +// 分页响应参数 +export interface ResPage { + 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; + } +} diff --git a/frontend/src/api/modules/upload.ts b/frontend/src/api/modules/upload.ts new file mode 100644 index 0000000..718b0f4 --- /dev/null +++ b/frontend/src/api/modules/upload.ts @@ -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(PORT1 + `/file/upload/img`, params, { cancel: false }); +}; + +// 视频上传 +export const uploadVideo = (params: FormData) => { + return http.post(PORT1 + `/file/upload/video`, params, { cancel: false }); +}; +export const uploadAudioApi = (params: FormData) => { + return http.post("/PostVoiceFile", params, { cancel: false }); +}; + +export const postResultApi = (params: { text: string }) => { + return http.post("/PostResultText", params, { cancel: false }); +}; +export const AudioResultApi = (params: { text: string; language: string }) => { + return http.postAudio("/ResultVoice", params, { loading: false }); +}; +export const SendMessageApi = () => { + return http.get("/SendMessage", {}, { loading: true }); +}; +export const getExcelFileAPi = () => { + return http.download("/DownExcel", {}); +}; diff --git a/frontend/src/api/modules/user.ts b/frontend/src/api/modules/user.ts new file mode 100644 index 0000000..f2b5eb2 --- /dev/null +++ b/frontend/src/api/modules/user.ts @@ -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>(PORT1 + `/user/list`, params); +}; + +// 获取树形用户列表 +export const getUserTreeList = (params: User.ReqUserParams) => { + return http.post>(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(PORT1 + `/user/status`); +}; + +// 获取用户性别字典 +export const getUserGender = () => { + return http.get(PORT1 + `/user/gender`); +}; + +// 获取用户部门列表 +export const getUserDepartment = () => { + return http.get(PORT1 + `/user/department`, {}, { cancel: false }); +}; + +// 获取用户角色字典 +export const getUserRole = () => { + return http.get(PORT1 + `/user/role`); +}; diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css new file mode 100644 index 0000000..8816868 --- /dev/null +++ b/frontend/src/assets/base.css @@ -0,0 +1,86 @@ +/* color palette from */ +: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; +} diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000..7565660 --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..cb43e3e --- /dev/null +++ b/frontend/src/assets/main.css @@ -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; + } + + +} diff --git a/frontend/src/assets/svgs/github.svg b/frontend/src/assets/svgs/github.svg new file mode 100644 index 0000000..a8d1174 --- /dev/null +++ b/frontend/src/assets/svgs/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svgs/logo.svg b/frontend/src/assets/svgs/logo.svg new file mode 100644 index 0000000..7565660 --- /dev/null +++ b/frontend/src/assets/svgs/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/ECharts/config/index.ts b/frontend/src/components/ECharts/config/index.ts new file mode 100644 index 0000000..e7adc76 --- /dev/null +++ b/frontend/src/components/ECharts/config/index.ts @@ -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; diff --git a/frontend/src/components/ECharts/index.vue b/frontend/src/components/ECharts/index.vue new file mode 100644 index 0000000..a9f9382 --- /dev/null +++ b/frontend/src/components/ECharts/index.vue @@ -0,0 +1,104 @@ + + + diff --git a/frontend/src/components/Loading/fullScreen.ts b/frontend/src/components/Loading/fullScreen.ts new file mode 100644 index 0000000..ea4dc92 --- /dev/null +++ b/frontend/src/components/Loading/fullScreen.ts @@ -0,0 +1,45 @@ +import { ElLoading } from "element-plus"; + +/* 全局请求 loading */ +let loadingInstance: ReturnType; + +/** + * @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(); + } +}; diff --git a/frontend/src/components/Loading/index.scss b/frontend/src/components/Loading/index.scss new file mode 100644 index 0000000..f1a7125 --- /dev/null +++ b/frontend/src/components/Loading/index.scss @@ -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; + } +} diff --git a/frontend/src/components/Loading/index.vue b/frontend/src/components/Loading/index.vue new file mode 100644 index 0000000..5b209a5 --- /dev/null +++ b/frontend/src/components/Loading/index.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/LoadingOverlay.vue b/frontend/src/components/StepViewer/components/LoadingOverlay.vue new file mode 100644 index 0000000..9aa2667 --- /dev/null +++ b/frontend/src/components/StepViewer/components/LoadingOverlay.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/MeasurementPanel.vue b/frontend/src/components/StepViewer/components/MeasurementPanel.vue new file mode 100644 index 0000000..1f575c9 --- /dev/null +++ b/frontend/src/components/StepViewer/components/MeasurementPanel.vue @@ -0,0 +1,261 @@ + + + + + + + diff --git a/frontend/src/components/StepViewer/components/ModelTree.vue b/frontend/src/components/StepViewer/components/ModelTree.vue new file mode 100644 index 0000000..2302eaa --- /dev/null +++ b/frontend/src/components/StepViewer/components/ModelTree.vue @@ -0,0 +1,423 @@ + + + + + + + diff --git a/frontend/src/components/StepViewer/components/SidePanel.vue b/frontend/src/components/StepViewer/components/SidePanel.vue new file mode 100644 index 0000000..1bc21a6 --- /dev/null +++ b/frontend/src/components/StepViewer/components/SidePanel.vue @@ -0,0 +1,177 @@ + + + + + + + diff --git a/frontend/src/components/StepViewer/components/StatsPanel.vue b/frontend/src/components/StepViewer/components/StatsPanel.vue new file mode 100644 index 0000000..e3d84a6 --- /dev/null +++ b/frontend/src/components/StepViewer/components/StatsPanel.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/StepViewer.vue b/frontend/src/components/StepViewer/components/StepViewer.vue new file mode 100644 index 0000000..479ec46 --- /dev/null +++ b/frontend/src/components/StepViewer/components/StepViewer.vue @@ -0,0 +1,946 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/Toolbar.vue b/frontend/src/components/StepViewer/components/Toolbar.vue new file mode 100644 index 0000000..9737ff4 --- /dev/null +++ b/frontend/src/components/StepViewer/components/Toolbar.vue @@ -0,0 +1,649 @@ + + + + + + +/* ─── 全局覆盖 el-dialog 内部间距(dialog 被 teleport 到 body,需非 scoped) ─── */ + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/FloatingJointControl.vue b/frontend/src/components/StepViewer/components/URDFBuilder/FloatingJointControl.vue new file mode 100644 index 0000000..3761874 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/FloatingJointControl.vue @@ -0,0 +1,171 @@ + + + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/JointSlider.vue b/frontend/src/components/StepViewer/components/URDFBuilder/JointSlider.vue new file mode 100644 index 0000000..a0e6501 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/JointSlider.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/JointWizard.vue b/frontend/src/components/StepViewer/components/URDFBuilder/JointWizard.vue new file mode 100644 index 0000000..91d2983 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/JointWizard.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/JointsModule.vue b/frontend/src/components/StepViewer/components/URDFBuilder/JointsModule.vue new file mode 100644 index 0000000..88b234e --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/JointsModule.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/LinksModule.vue b/frontend/src/components/StepViewer/components/URDFBuilder/LinksModule.vue new file mode 100644 index 0000000..2464cc7 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/LinksModule.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/URDFEditor.vue b/frontend/src/components/StepViewer/components/URDFBuilder/URDFEditor.vue new file mode 100644 index 0000000..429fcb2 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/URDFEditor.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/URDFJointProperties.vue b/frontend/src/components/StepViewer/components/URDFBuilder/URDFJointProperties.vue new file mode 100644 index 0000000..7af84c7 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/URDFJointProperties.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/URDFLeftPanel.vue b/frontend/src/components/StepViewer/components/URDFBuilder/URDFLeftPanel.vue new file mode 100644 index 0000000..9905e85 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/URDFLeftPanel.vue @@ -0,0 +1,566 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/URDFLinkProperties.vue b/frontend/src/components/StepViewer/components/URDFBuilder/URDFLinkProperties.vue new file mode 100644 index 0000000..a14f48c --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/URDFLinkProperties.vue @@ -0,0 +1,563 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/URDFRightPanel.vue b/frontend/src/components/StepViewer/components/URDFBuilder/URDFRightPanel.vue new file mode 100644 index 0000000..d33f874 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/URDFRightPanel.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/ViewControls.vue b/frontend/src/components/StepViewer/components/URDFBuilder/ViewControls.vue new file mode 100644 index 0000000..9bfed34 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/ViewControls.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/URDFBuilder/ZhipuAssistDialog.vue b/frontend/src/components/StepViewer/components/URDFBuilder/ZhipuAssistDialog.vue new file mode 100644 index 0000000..30bde88 --- /dev/null +++ b/frontend/src/components/StepViewer/components/URDFBuilder/ZhipuAssistDialog.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/frontend/src/components/StepViewer/components/composables/useURDFScene.ts b/frontend/src/components/StepViewer/components/composables/useURDFScene.ts new file mode 100644 index 0000000..60cb804 --- /dev/null +++ b/frontend/src/components/StepViewer/components/composables/useURDFScene.ts @@ -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 { + 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 { + + 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() + 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 = {} + const linkRestInverseMap: Record = {} + + 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, + } +} diff --git a/frontend/src/components/StepViewer/core/BVHAccelerator.ts b/frontend/src/components/StepViewer/core/BVHAccelerator.ts new file mode 100644 index 0000000..1c8d544 --- /dev/null +++ b/frontend/src/components/StepViewer/core/BVHAccelerator.ts @@ -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 +} diff --git a/frontend/src/components/StepViewer/core/ExportWorker.ts b/frontend/src/components/StepViewer/core/ExportWorker.ts new file mode 100644 index 0000000..0a56b49 --- /dev/null +++ b/frontend/src/components/StepViewer/core/ExportWorker.ts @@ -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, + 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, + linkRestInverseMap: Record, + unitScale: number, + onProgress?: (stage: string, percent: number) => void + ): Promise { + 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) diff --git a/frontend/src/components/StepViewer/core/ForwardKinematics.ts b/frontend/src/components/StepViewer/core/ForwardKinematics.ts new file mode 100644 index 0000000..f64c660 --- /dev/null +++ b/frontend/src/components/StepViewer/core/ForwardKinematics.ts @@ -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() + + /** 静息变换:linkId → value=0 时的世界矩阵(用于 delta 计算) */ + private restTransforms = new Map() + + /** 运动学树:parentLinkId → {joint, childLinkId}[] */ + private kinematicTree = new Map() + + /** 根 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() + + 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 { + 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, + links: URDFLink[], + solidMap: Map + ): 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): 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 + } +} diff --git a/frontend/src/components/StepViewer/core/FrameVisualizer.ts b/frontend/src/components/StepViewer/core/FrameVisualizer.ts new file mode 100644 index 0000000..47805fd --- /dev/null +++ b/frontend/src/components/StepViewer/core/FrameVisualizer.ts @@ -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() + + 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 约定一致 + * 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() + } + } + }) +} diff --git a/frontend/src/components/StepViewer/core/InertiaWorker.ts b/frontend/src/components/StepViewer/core/InertiaWorker.ts new file mode 100644 index 0000000..6bf5d9c --- /dev/null +++ b/frontend/src/components/StepViewer/core/InertiaWorker.ts @@ -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 { /* no-op */ }, + + /** + * 从三角网格(可含多个 Solid)计算惯性参数。 + * @param solidDataList Link 绑定的所有 Solid(使用 positions + indices 字段) + * @param density 材料密度 (kg/m³) + */ + async computeInertia( + solidDataList: SerializedSolidData[], + density: number + ): Promise { + + // ── 一次遍历,累计所有积分项(单位均为 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) diff --git a/frontend/src/components/StepViewer/core/JointSnapVisualizer.ts b/frontend/src/components/StepViewer/core/JointSnapVisualizer.ts new file mode 100644 index 0000000..4ff35b7 --- /dev/null +++ b/frontend/src/components/StepViewer/core/JointSnapVisualizer.ts @@ -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() + } + } + }) + } +} diff --git a/frontend/src/components/StepViewer/core/KinematicsWorker.ts b/frontend/src/components/StepViewer/core/KinematicsWorker.ts new file mode 100644 index 0000000..5fef581 --- /dev/null +++ b/frontend/src/components/StepViewer/core/KinematicsWorker.ts @@ -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) diff --git a/frontend/src/components/StepViewer/core/LineMeasurementTool.ts b/frontend/src/components/StepViewer/core/LineMeasurementTool.ts new file mode 100644 index 0000000..085cae4 --- /dev/null +++ b/frontend/src/components/StepViewer/core/LineMeasurementTool.ts @@ -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 = 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 diff --git a/frontend/src/components/StepViewer/core/RendererFactory.ts b/frontend/src/components/StepViewer/core/RendererFactory.ts new file mode 100644 index 0000000..ccd916c --- /dev/null +++ b/frontend/src/components/StepViewer/core/RendererFactory.ts @@ -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 { + 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 { + 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 { + // 尝试使用 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') +} diff --git a/frontend/src/components/StepViewer/core/SceneManager.ts b/frontend/src/components/StepViewer/core/SceneManager.ts new file mode 100644 index 0000000..6d57fa2 --- /dev/null +++ b/frontend/src/components/StepViewer/core/SceneManager.ts @@ -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 + + 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 { + 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 { + 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, 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 diff --git a/frontend/src/components/StepViewer/core/SelectionManager.ts b/frontend/src/components/StepViewer/core/SelectionManager.ts new file mode 100644 index 0000000..ebe6f38 --- /dev/null +++ b/frontend/src/components/StepViewer/core/SelectionManager.ts @@ -0,0 +1,1871 @@ +/** + * 选择管理器 + * 处理特征选择、hover 高亮和多选功能 + * + * 性能优化说明: + * 1. hover 检测使用 RAF 节流,与屏幕刷新率同步 + * 2. hover 高亮使用 emissive 颜色变化而非材质切换 + * 3. 缓存 domRect 避免强制回流 + * 4. 缓存 visibleMeshes 避免重复创建数组 + * 5. 构建特征索引 Map 加速查找 + * 6. 支持 InstancedMesh,通过 instanceId 映射到 SolidObject + */ + +import * as THREE from 'three' +import type { ArcballControls } from 'three/examples/jsm/controls/ArcballControls.js' +import type { + GeometryFeature, + SolidObject, + SelectionInfo, + GranularityMode +} from '../types' + +/** + * RAF 节流函数 - 与屏幕刷新率同步 + */ +function rafThrottle void>( + fn: T +): T & { cancel: () => void } { + let rafId: number | null = null + let lastArgs: Parameters | null = null + + const throttled = function (this: any, ...args: Parameters) { + lastArgs = args + if (rafId === null) { + rafId = requestAnimationFrame(() => { + rafId = null + if (lastArgs) { + fn.apply(this, lastArgs) + lastArgs = null + } + }) + } + } as T & { cancel: () => void } + + throttled.cancel = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + lastArgs = null + } + + return throttled +} + +/** + * 选择管理器配置 + */ +export interface SelectionManagerConfig { + camera: THREE.Camera + scene: THREE.Scene + domElement: HTMLElement + /** ArcballControls 引用,用于精确检测拖动/旋转操作 */ + controls?: ArcballControls + highlightColor?: number + selectionColor?: number + /** hover / 选择变化后请求重新渲染 */ + onRenderRequest?: () => void +} + +/** + * 选择事件 + */ +export interface SelectionEvent { + selections: SelectionInfo[] + added?: SelectionInfo + removed?: SelectionInfo + /** 选中的树节点 ID 列表 */ + selectedTreeNodeIds?: string[] +} + +/** + * 选择管理器类 + */ +export class SelectionManager { + private camera: THREE.Camera + private scene: THREE.Scene + private domElement: HTMLElement + private raycaster: THREE.Raycaster + private mouse: THREE.Vector2 + + // 颜色配置 + private highlightColor: number + private selectionColor: number + + // 选择状态 + private solids: SolidObject[] = [] + private selectedSolids: Set = new Set() + private selectedFeatures: Map = new Map() + /** 是否启用交互(画线测量模式时禁用) */ + private enabled = true + + /** 选择模式:single = 默认单选,multi = 多选 */ + private selectionMode: 'single' | 'multi' = 'single' + + // 面级高亮覆盖层(overlay mesh 方式,同时支持 Regular Mesh 和 InstancedMesh) + private faceHighlightOverlays: Map = new Map() + private faceHighlightMaterial!: THREE.MeshStandardMaterial + + // 边级高亮覆盖层 + private edgeHighlightOverlays: Map = new Map() + private edgeHighlightMaterial!: THREE.LineBasicMaterial + /** hover 时的临时边覆盖层 */ + private hoverEdgeOverlay: THREE.LineSegments | null = null + + // 选择粒度模式 + private granularityMode: GranularityMode = 'solid' + /** edge 拾取专用 raycaster(线段拾取需要独立 threshold) */ + private edgeRaycaster: THREE.Raycaster + + // Hover 状态(纯 3D 视觉效果,不与树联动) + private hoveredFeature: GeometryFeature | null = null + private hoveredMesh: THREE.Mesh | null = null + private hoveredBrepFaceIndex: number = -1 + private hoveredSolid: SolidObject | null = null + /** Regular Mesh hover: 保存原始 emissive */ + private originalEmissive: THREE.Color | null = null + + // 高亮材质缓存(用于选中状态 - 仅 Regular Mesh) + private highlightMaterials: Map = new Map() + private originalMaterials: Map = new Map() + + // InstancedMesh 选中状态:保存原始 instanceColor + private originalInstanceColors: Map = new Map() + /** InstancedMesh 实例引用缓存 (uuid → InstancedMesh) */ + private instancedMeshRefs: Map = new Map() + + // 事件回调 + private onSelectCallback?: (event: SelectionEvent) => void + private onHoverCallback?: (feature: GeometryFeature | null) => void + private onRenderRequest?: () => void + + // 边缘线高亮颜色 + private static readonly EDGE_DEFAULT_COLOR = 0x333333 + private static readonly EDGE_HOVER_COLOR = 0xffdd00 + private static readonly EDGE_SELECTED_COLOR = 0xff4400 + private static readonly EDGE_DEFAULT_OPACITY = 0.6 + private static readonly EDGE_HIGHLIGHT_OPACITY = 1.0 + + // 性能优化相关 + private rafThrottledMouseMove: ((event: MouseEvent) => void) & { cancel: () => void } + private isDragging = false + private mouseDownPos = { x: 0, y: 0 } + private readonly DRAG_THRESHOLD = 5 + private lastHoverX = 0 + private lastHoverY = 0 + private readonly HOVER_PIXEL_THRESHOLD_SQ = 9 + + // ArcballControls 拖动检测 + private orbitControls: ArcballControls | null = null + private isOrbitActive = false + + // 缓存优化 + private cachedRect: DOMRect | null = null + private cachedMeshes: THREE.Mesh[] = [] + private featureIndexMap: Map> = new Map() + /** 边特征索引 Map: solidId → Map */ + private edgeIndexMap: Map> = new Map() + /** 拓扑边线段缓存(用于边粒度模式的 raycaster) */ + private cachedTopologyEdges: THREE.LineSegments[] = [] + /** Regular Mesh → SolidObject */ + private meshToSolid: Map = new Map() + /** solidId → SolidObject O(1) 查找 */ + private solidIdMap: Map = new Map() + /** InstancedMesh UUID → (instanceId → SolidObject) */ + private instancedMeshToSolids: Map> = new Map() + private resizeObserver: ResizeObserver | null = null + + constructor(config: SelectionManagerConfig) { + this.camera = config.camera + this.scene = config.scene + this.domElement = config.domElement + this.raycaster = new THREE.Raycaster() + this.mouse = new THREE.Vector2() + this.onRenderRequest = config.onRenderRequest + + // 优化射线检测参数 + this.raycaster.params.Line = { threshold: 1 } + this.raycaster.params.Points = { threshold: 1 } + // BVH firstHitOnly 优化:只返回最近交点 + ; (this.raycaster as any).firstHitOnly = true + + this.highlightColor = config.highlightColor ?? 0x00ff00 + this.selectionColor = config.selectionColor ?? 0xff8800 + + // RAF 节流的 hover 检测 + this.rafThrottledMouseMove = rafThrottle(this.performHoverCheck.bind(this)) + + // 边拾取专用 raycaster + this.edgeRaycaster = new THREE.Raycaster() + this.edgeRaycaster.params.Line = { threshold: 2 } + + // 初始化 domRect 缓存 + this.updateCachedRect() + + // 监听 resize 更新缓存 + this.resizeObserver = new ResizeObserver(() => { + this.updateCachedRect() + }) + this.resizeObserver.observe(this.domElement) + + // 面级高亮材质(所有覆盖层共享) + this.faceHighlightMaterial = new THREE.MeshStandardMaterial({ + color: 0xff8800, + emissive: 0xff6600, + emissiveIntensity: 0.3, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.75, + depthTest: true, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -1, + }) + + // 边级高亮材质 + this.edgeHighlightMaterial = new THREE.LineBasicMaterial({ + color: 0xff6600, + linewidth: 2, + transparent: false, + depthTest: true + }) + + // 绑定控制器事件 + this.orbitControls = config.controls ?? null + if (this.orbitControls) { + this.orbitControls.addEventListener('start', this.handleOrbitStart) + this.orbitControls.addEventListener('end', this.handleOrbitEnd) + } + + // 绑定 DOM 事件 + this.domElement.addEventListener('click', this.handleClick) + this.domElement.addEventListener('mousemove', this.handleMouseMove) + this.domElement.addEventListener('mousedown', this.handleMouseDown) + this.domElement.addEventListener('mouseup', this.handleMouseUp) + this.domElement.addEventListener('mouseleave', this.handleMouseLeave) + this.domElement.addEventListener('contextmenu', this.handleContextMenu) + } + + /** + * 更新缓存的 domRect(避免频繁调用 getBoundingClientRect 导致强制回流) + */ + private updateCachedRect(): void { + this.cachedRect = this.domElement.getBoundingClientRect() + } + + // ========== 控制器拖动检测 ========== + + private handleOrbitStart = (): void => { + this.isOrbitActive = true + if (this.hoveredFeature && !this.selectedFeatures.has(this.hoveredFeature.id)) { + this.clearHoverHighlight() + } + } + + private handleOrbitEnd = (): void => { + this.isOrbitActive = false + } + + // ========== Hover 检测(纯 3D 视觉,不与树联动) ========== + + private handleMouseMove = (event: MouseEvent): void => { + if (!this.enabled) return + if (this.isOrbitActive) return + if (this.isDragging) { + const dx = event.clientX - this.mouseDownPos.x + const dy = event.clientY - this.mouseDownPos.y + if (dx * dx + dy * dy > this.DRAG_THRESHOLD * this.DRAG_THRESHOLD) return + } + this.rafThrottledMouseMove(event) + } + + /** + * 执行 hover 检测(被 RAF 节流调用) + */ + private performHoverCheck(event: MouseEvent): void { + if (this.isOrbitActive || this.isDragging) return + + // 像素距离阈值:鼠标移动不足 3px 则跳过 + const hdx = event.clientX - this.lastHoverX + const hdy = event.clientY - this.lastHoverY + if (hdx * hdx + hdy * hdy < this.HOVER_PIXEL_THRESHOLD_SQ) return + this.lastHoverX = event.clientX + this.lastHoverY = event.clientY + + if (this.granularityMode === 'edge') { + this.performEdgeHoverCheck(event) + return + } + + // 面模式原有逻辑 + + const intersects = this.getIntersects(event) + + if (intersects.length === 0) { + if (this.hoveredFeature) { + this.clearHoverHighlight() + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredSolid = null + this.hoveredBrepFaceIndex = -1 + this.onRenderRequest?.() + } + return + } + + const intersection = intersects[0] + const mesh = intersection.object as THREE.Mesh + + // BRep 面级缓存 + const currentBrepFaceIndex = this.getBrepFaceIndex(mesh, intersection) + if (mesh === this.hoveredMesh && currentBrepFaceIndex === this.hoveredBrepFaceIndex) return + + const solid = this.findSolidFromIntersection(intersection) + if (!solid) { + if (this.hoveredFeature) { + this.clearHoverHighlight() + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredSolid = null + this.hoveredBrepFaceIndex = -1 + this.onRenderRequest?.() + } + return + } + + // 清除之前的 hover 高亮 + this.clearHoverHighlight() + + const feature = this.findFeatureAtPoint(solid, intersection) + + if (feature && !this.selectedFeatures.has(feature.id)) { + this.applyHoverHighlight(mesh, solid) + } + + this.hoveredFeature = feature + this.hoveredMesh = mesh + this.hoveredSolid = solid + this.hoveredBrepFaceIndex = currentBrepFaceIndex + this.onRenderRequest?.() + } + + /** + * 获取交点对应的 BRep 面索引 + */ + private getBrepFaceIndex(mesh: THREE.Mesh, intersection: THREE.Intersection): number { + const faceIdx = intersection.faceIndex + if (faceIdx === undefined || faceIdx === null) return -1 + + const geometry = mesh.geometry as THREE.BufferGeometry + const faceIndexAttr = geometry.getAttribute('faceIndex') + if (!faceIndexAttr) return -1 + + const index = geometry.getIndex() + let vertexIndex: number + if (index) { + vertexIndex = index.getX(faceIdx * 3) + } else { + vertexIndex = faceIdx * 3 + } + return Math.floor(faceIndexAttr.getX(vertexIndex)) + } + + /** + * 应用 hover 高亮 + * Regular Mesh → emissive 颜色变化 + * InstancedMesh → 仅边缘线高亮 + */ + private applyHoverHighlight(mesh: THREE.Mesh, solid: SolidObject): void { + if (!(mesh instanceof THREE.InstancedMesh)) { + const material = mesh.material as THREE.MeshStandardMaterial + if (material && material.emissive) { + this.originalEmissive = material.emissive.clone() + material.emissiveIntensity = 0.3 + } + } + this.hoverSolidEdgeLines(solid, true) + } + + /** + * 清除 hover 高亮 + */ + private clearHoverHighlight(): void { + if (this.hoveredMesh && this.originalEmissive) { + const material = this.hoveredMesh.material as THREE.MeshStandardMaterial + if (material && material.emissive) { + material.emissive.copy(this.originalEmissive) + material.emissiveIntensity = 0 + } + this.originalEmissive = null + } + if (this.hoveredSolid) { + this.hoverSolidEdgeLines(this.hoveredSolid, false) + // 边模式 hover:恢复拓扑边颜色 + if (this.granularityMode === 'edge' && this.hoveredBrepFaceIndex >= 0) { + if (this.hoveredFeature && !this.selectedFeatures.has(this.hoveredFeature.id)) { + this.setTopologyEdgeColor(this.hoveredSolid, this.hoveredBrepFaceIndex, 0x444444) + } + } + } + // 清除 hover 边覆盖层 + this.removeHoverEdgeOverlay() + } + + /** + * 创建 hover 状态的边覆盖层(depthTest:false 使其在模型前方可见) + */ + private createHoverEdgeOverlay(solid: SolidObject, edgeIndex: number): void { + this.removeHoverEdgeOverlay() + if (!solid.topologyEdges) return + + const srcGeo = solid.topologyEdges.geometry + const edgeIndexAttr = srcGeo.getAttribute('edgeIndex') as THREE.BufferAttribute + const posAttr = srcGeo.getAttribute('position') as THREE.BufferAttribute + if (!edgeIndexAttr || !posAttr) return + + const positions: number[] = [] + for (let i = 0; i < edgeIndexAttr.count; i++) { + if (Math.floor(edgeIndexAttr.getX(i)) === edgeIndex) { + positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i)) + } + } + if (positions.length === 0) return + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(positions), 3)) + + this.hoverEdgeOverlay = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ + color: SelectionManager.EDGE_HOVER_COLOR, + depthTest: false, + transparent: true, + opacity: 1, + })) + this.hoverEdgeOverlay.renderOrder = 998 + this.hoverEdgeOverlay.matrixAutoUpdate = false + this.hoverEdgeOverlay.matrix.copy(solid.topologyEdges.matrixWorld) + this.scene.add(this.hoverEdgeOverlay) + } + + /** + * 移除 hover 边覆盖层 + */ + private removeHoverEdgeOverlay(): void { + if (this.hoverEdgeOverlay) { + this.scene.remove(this.hoverEdgeOverlay) + this.hoverEdgeOverlay.geometry.dispose() + ; (this.hoverEdgeOverlay.material as THREE.Material).dispose() + this.hoverEdgeOverlay = null + } + } + + /** + * Hover 边缘线高亮(不覆盖已选中的高亮) + * ★ 支持合并边缘线(edgeVertexRange)和独立边缘线 + */ + private hoverSolidEdgeLines(solid: SolidObject, hover: boolean): void { + if (!solid.edgeLines) return + if (solid.selected) return + + if (solid.edgeVertexRange) { + // 合并边缘线:通过顶点色控制单个实例的边缘线颜色 + this.setEdgeVertexColors( + solid.edgeLines, + solid.edgeVertexRange, + hover ? SelectionManager.EDGE_HOVER_COLOR : SelectionManager.EDGE_DEFAULT_COLOR + ) + } else { + // 独立边缘线:改变材质颜色 + const material = solid.edgeLines.material as THREE.LineBasicMaterial + if (hover) { + material.color.setHex(SelectionManager.EDGE_HOVER_COLOR) + material.opacity = SelectionManager.EDGE_HIGHLIGHT_OPACITY + } else { + material.color.setHex(SelectionManager.EDGE_DEFAULT_COLOR) + material.opacity = SelectionManager.EDGE_DEFAULT_OPACITY + } + material.needsUpdate = true + } + } + + private handleMouseUp = (): void => { + this.isDragging = false + } + + private handleMouseLeave = (): void => { + this.isDragging = false + this.rafThrottledMouseMove.cancel() + if (this.hoveredFeature) { + this.clearHoverHighlight() + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredSolid = null + this.hoveredBrepFaceIndex = -1 + this.onRenderRequest?.() + } + } + + // ========== Solid 对象管理 ========== + + /** + * 设置 Solid 对象列表(支持 Regular Mesh + InstancedMesh) + */ + setSolids(solids: SolidObject[]): void { + this.solids = solids + + this.meshToSolid.clear() + this.solidIdMap.clear() + this.instancedMeshToSolids.clear() + this.instancedMeshRefs.clear() + + solids.forEach(solid => { + this.solidIdMap.set(solid.id, solid) + if (solid.instanceId !== undefined && solid.mesh instanceof THREE.InstancedMesh) { + // InstancedMesh + const uuid = solid.mesh.uuid + if (!this.instancedMeshToSolids.has(uuid)) { + this.instancedMeshToSolids.set(uuid, new Map()) + } + this.instancedMeshToSolids.get(uuid)!.set(solid.instanceId, solid) + this.instancedMeshRefs.set(uuid, solid.mesh as unknown as THREE.InstancedMesh) + } else { + // Regular Mesh + this.meshToSolid.set(solid.mesh, solid) + } + }) + + this.updateCachedMeshes() + this.updateCachedTopologyEdges() + this.buildFeatureIndexMap() + } + + /** + * 更新缓存的拓扑边线段数组 + */ + private updateCachedTopologyEdges(): void { + const edgeSet = new Set() + this.solids.forEach(s => { + if (s.visible && s.topologyEdges) edgeSet.add(s.topologyEdges) + }) + this.cachedTopologyEdges = Array.from(edgeSet) + } + + /** + * 更新缓存的可见 meshes 数组(去重,InstancedMesh 只出现一次) + */ + private updateCachedMeshes(): void { + const meshSet = new Set() + this.solids.forEach(s => { + if (s.visible) meshSet.add(s.mesh) + }) + this.cachedMeshes = Array.from(meshSet) + } + + /** + * 构建特征索引 Map(用于 O(1) 查找) + */ + private buildFeatureIndexMap(): void { + this.featureIndexMap.clear() + this.edgeIndexMap.clear() + this.solids.forEach(solid => { + const featureMap = new Map() + solid.features.forEach(feature => { + if (feature.faceIndex !== undefined) { + featureMap.set(feature.faceIndex, feature) + } + }) + this.featureIndexMap.set(solid.id, featureMap) + + const edgeMap = new Map() + solid.edgeFeatures.forEach(feature => { + if (feature.edgeIndex !== undefined) { + edgeMap.set(feature.edgeIndex, feature) + } + }) + this.edgeIndexMap.set(solid.id, edgeMap) + }) + } + + /** + * 启用/禁用选择管理器交互(用于画线测量模式) + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled + if (!enabled) { + // 禁用时清除 hover + this.clearHoverHighlight() + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredSolid = null + this.hoveredBrepFaceIndex = -1 + } + } + + /** + * 设置选择模式(已废弃,默认多选) + */ + setSelectionMode(mode: 'single' | 'multi'): void { + this.selectionMode = String(mode) === 'multi' ? 'multi' : 'single' + } + + /** + * 设置选择回调 + */ + onSelect(callback: (event: SelectionEvent) => void): void { + this.onSelectCallback = callback + } + + /** + * 设置 hover 回调(边模式下,鼠标悬停在边上时触发) + */ + onHover(callback: (feature: GeometryFeature | null) => void): void { + this.onHoverCallback = callback + } + + /** + * 处理点击事件 + */ + private handleClick = (event: MouseEvent): void => { + if (!this.enabled) return + // 检查是否是拖动操作(旋转相机等),如果是则不处理选择 + const dx = event.clientX - this.mouseDownPos.x + const dy = event.clientY - this.mouseDownPos.y + const distance = Math.sqrt(dx * dx + dy * dy) + if (distance > this.DRAG_THRESHOLD) { + return + } + + if (this.granularityMode === 'edge') { + this.handleEdgeClick(event) + return + } + + // 面模式原有逻辑 + + const intersects = this.getIntersects(event) + + if (intersects.length === 0) { + // 点击空白区域不再自动取消选择 + // 用户只能通过信息面板的"移除"按钮来取消选中 + return + } + + const intersection = intersects[0] + + // ★ 使用 findSolidFromIntersection 同时支持 Regular Mesh 和 InstancedMesh + const solid = this.findSolidFromIntersection(intersection) + if (!solid) return + + // 查找对应的特征 + const feature = this.findFeatureAtPoint(solid, intersection) + if (!feature) return + + // 处理选择 + this.handleSelection(feature, solid, intersection, event) + } + + /** + * 处理鼠标按下事件 + */ + private handleMouseDown = (event: MouseEvent): void => { + this.mouseDownPos.x = event.clientX + this.mouseDownPos.y = event.clientY + } + + /** + * 处理右键菜单事件 + */ + private handleContextMenu = (event: MouseEvent): void => { + event.preventDefault() + // 可以在这里添加右键菜单逻辑 + } + + /** + * 获取射线交点(使用缓存优化) + */ + private getIntersects(event: MouseEvent): THREE.Intersection[] { + // 使用缓存的 rect(避免强制回流) + 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) + + // 使用缓存的 meshes 数组(避免每次创建新数组) + return this.raycaster.intersectObjects(this.cachedMeshes, false) + } + + /** + * 从射线交点查找对应的 SolidObject(支持 Regular Mesh + InstancedMesh) + */ + private findSolidFromIntersection(intersection: THREE.Intersection): SolidObject | null { + const mesh = intersection.object as THREE.Mesh + + // InstancedMesh 路径 + if (mesh instanceof THREE.InstancedMesh && intersection.instanceId !== undefined) { + const solidsMap = this.instancedMeshToSolids.get(mesh.uuid) + return solidsMap?.get(intersection.instanceId) ?? null + } + + // Regular Mesh 路径 + return this.meshToSolid.get(mesh) ?? null + } + + /** + * 查找点击位置对应的特征 + */ + private findFeatureAtPoint( + solid: SolidObject, + intersection: THREE.Intersection + ): GeometryFeature | null { + const faceIdx = intersection.faceIndex + if (faceIdx === undefined || faceIdx === null) return null + + const geometry = solid.mesh.geometry as THREE.BufferGeometry + const faceIndexAttr = geometry.getAttribute('faceIndex') + + if (!faceIndexAttr) { + // 如果没有 faceIndex 属性,返回整个 solid 作为一个面 + return solid.features[0] || null + } + + // 获取该三角形所属的 BRep 面索引 + const index = geometry.getIndex() + let vertexIndex: number + if (index) { + vertexIndex = index.getX(faceIdx * 3) + } else { + vertexIndex = faceIdx * 3 + } + + const brepFaceIndex = Math.floor(faceIndexAttr.getX(vertexIndex)) + + // 使用特征索引 Map 进行 O(1) 查找(替代 O(n) find) + const featureMap = this.featureIndexMap.get(solid.id) + if (featureMap) { + const feature = featureMap.get(brepFaceIndex) + if (feature) return feature + } + + // 回退到第一个特征 + return solid.features[0] || null + } + + /** + * 处理选择逻辑(统一多选 toggle 模式) + */ + private handleSelection( + feature: GeometryFeature, + solid: SolidObject, + intersection: THREE.Intersection, + event: MouseEvent + ): void { + const selectionInfo: SelectionInfo = { + feature, + solid, + point: intersection.point.clone(), + distance: intersection.distance + } + + // 判断是否多选:多选模式 or Ctrl/Shift 修饰键 + const isMulti = this.selectionMode === 'multi' || event.ctrlKey || event.shiftKey + + if (isMulti) { + // 多选 toggle 逻辑 + if (this.selectedFeatures.has(feature.id)) { + // 已选中 → 取消选择 + this.removeSelection(feature) + if (feature.solidId) { + const s = this.solidIdMap.get(feature.solidId) + if (s && !s.selected) this.highlightSolidEdgeLines(s, false) + } + this.onSelectCallback?.({ + selections: this.getSelections(), + removed: selectionInfo, + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } else { + // 未选中 → 添加到选择 + this.addSelection(feature) + if (solid) this.highlightSolidEdgeLines(solid, true) + this.onSelectCallback?.({ + selections: this.getSelections(), + added: selectionInfo, + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } + } else { + // 单选:清空旧选择,选中新特征 + this.clearSelectionInternal() + this.addSelection(feature) + if (solid) this.highlightSolidEdgeLines(solid, true) + this.onSelectCallback?.({ + selections: this.getSelections(), + added: selectionInfo, + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } + } + + /** + * 添加选择 + */ + private addSelection(feature: GeometryFeature): void { + if (this.selectedFeatures.has(feature.id)) return + + this.selectedFeatures.set(feature.id, feature) + this.applyHighlight(feature, this.selectionColor) + + // 标记 Solid 为选中 + if (feature.solidId) { + const solid = this.solidIdMap.get(feature.solidId) + if (solid) { + solid.selected = true + this.selectedSolids.add(solid.id) + } + } + } + + /** + * 移除选择 + */ + private removeSelection(feature: GeometryFeature): void { + if (!this.selectedFeatures.has(feature.id)) return + + this.selectedFeatures.delete(feature.id) + this.removeHighlight(feature) + // 移除面级高亮 + this.removeFaceHighlight(feature) + // 移除边级高亮 + this.removeEdgeHighlight(feature) + + // ★ 修复:检查同 Solid 下是否还有其他选中 feature,没有才取消 Solid 标记 + if (feature.solidId) { + const solid = this.solidIdMap.get(feature.solidId) + if (solid) { + const hasOtherSelectedFeature = Array.from(this.selectedFeatures.values()) + .some(f => f.solidId === feature.solidId) + if (!hasOtherSelectedFeature) { + solid.selected = false + this.selectedSolids.delete(solid.id) + } + } + } + } + + /** + * 内部清除选择(不触发回调) + * 用于 selectBySolidId / selectByFaceIndex 等内部先清后选的场景, + * 避免触发中间空回调导致 store 中间态和不必要的 Vue 响应式更新 + */ + private clearSelectionInternal(): void { + // ★ 先收集需要恢复的 Regular Mesh(直接保存 mesh + solid 引用,避免后续 O(n) 扫描) + const meshRestoreMap = new Map() + this.selectedFeatures.forEach(feature => { + if (feature.mesh && !(feature.mesh instanceof THREE.InstancedMesh)) { + const meshKey = feature.mesh.uuid + if (!meshRestoreMap.has(meshKey)) { + const solid = feature.solidId ? this.solidIdMap.get(feature.solidId) : undefined + if (solid) meshRestoreMap.set(meshKey, { mesh: feature.mesh, solid }) + } + } + }) + + // ★ 快照 selectedSolids,然后再清空(避免先清空后遍历空集合的 bug) + const previousSolidIds = Array.from(this.selectedSolids) + this.selectedFeatures.clear() + this.selectedSolids.clear() + + // 恢复 Regular Mesh 的原始材质(O(1) 查找,不再扫描 this.solids) + meshRestoreMap.forEach(({ mesh, solid }, meshKey) => { + const originalMaterial = this.originalMaterials.get(meshKey) + if (originalMaterial) { + mesh.material = originalMaterial + const mat = originalMaterial as THREE.MeshStandardMaterial + mat.opacity = solid.opacity + mat.transparent = solid.opacity < 1 + mat.depthWrite = solid.opacity >= 1 + mat.needsUpdate = true + } + this.originalMaterials.delete(meshKey) + }) + + // ★ 恢复 InstancedMesh 的原始 instanceColor + if (this.originalInstanceColors.size > 0) { + const updatedMeshes = new Set() + this.originalInstanceColors.forEach((origColor, key) => { + const sepIdx = key.lastIndexOf(':') + const meshUuid = key.substring(0, sepIdx) + const instanceId = parseInt(key.substring(sepIdx + 1)) + const instMesh = this.instancedMeshRefs.get(meshUuid) + if (instMesh) { + instMesh.setColorAt(instanceId, origColor) + updatedMeshes.add(meshUuid) + } + }) + updatedMeshes.forEach(uuid => { + const instMesh = this.instancedMeshRefs.get(uuid) + if (instMesh?.instanceColor) instMesh.instanceColor.needsUpdate = true + }) + this.originalInstanceColors.clear() + } + + // ★ 仅恢复之前选中的 solid 的边缘线(避免遍历全部 solids) + previousSolidIds.forEach(solidId => { + const s = this.solidIdMap.get(solidId) + if (s) { + s.selected = false + this.highlightSolidEdgeLines(s, false) + } + }) + + // 清除所有面级高亮 + this.clearAllFaceHighlights() + + // ★ 仅在有边级高亮时才清除(避免在 solid 模式下遍历全部拓扑边顶点) + if (this.edgeHighlightOverlays.size > 0) { + this.clearAllEdgeHighlights() + } + } + + /** + * 清空所有选择(公共方法,触发回调) + */ + clearSelection(): void { + this.clearSelectionInternal() + + this.onSelectCallback?.({ + selections: [], + selectedTreeNodeIds: [] + }) + } + + /** + * 取消选择指定特征(公共方法) + * 用于外部(如信息面板)移除特征时同步移除高亮 + * ★ 修复:触发 onSelectCallback 确保树和面板联动更新 + */ + deselectFeature(featureId: string): void { + const feature = this.selectedFeatures.get(featureId) + if (feature) { + this.removeSelection(feature) + // 恢复边缘线 + if (feature.solidId) { + const solid = this.solidIdMap.get(feature.solidId) + // 只有同 Solid 下没有其他选中 feature 时才恢复边缘线 + if (solid && !solid.selected) { + this.highlightSolidEdgeLines(solid, false) + } + } + // ★ 触发回调确保树/面板联动 + this.onSelectCallback?.({ + selections: this.getSelections(), + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } + } + + /** + * 从树节点选中 Solid(树→3D 方向) + * ★ 使用 clearSelectionInternal 避免中间空回调 + * ★ selectedTreeNodeIds 仅包含 solidId(不含面子节点), + * 避免 el-tree-v2 scrollTo 跳到未展开的面子节点导致列表消失 + */ + selectBySolidId(solidId: string, multi = false): void { + const solid = this.solidIdMap.get(solidId) + if (!solid) return + + if (!multi) { + this.clearSelectionInternal() + } + + // 选中该 Solid 的第一个特征(多选模式下支持 toggle) + const feature = solid.features[0] + if (feature) { + if (multi && this.selectedFeatures.has(feature.id)) { + // 多选 toggle:已选中 → 取消 + this.removeSelection(feature) + if (!solid.selected) this.highlightSolidEdgeLines(solid, false) + } else { + this.addSelection(feature) + this.highlightSolidEdgeLines(solid, true) + } + } + + // ★ 树节点 ID:多选时收集全部,单选时只取 solidId + const treeIds = multi + ? this.getSelectedTreeNodeIds() + : (this.selectedFeatures.size > 0 ? [solidId] : []) + this.onSelectCallback?.({ + selections: this.getSelections(), + selectedTreeNodeIds: treeIds + }) + } + + /** + * 从模型树 hover Solid → 临时边缘线高亮(不影响选中状态) + * solidId = null 则清除 hover + */ + hoverBySolidId(solidId: string | null): void { + // 清除旧的 hover + if (this.hoveredSolid && !this.hoveredSolid.selected) { + this.hoverSolidEdgeLines(this.hoveredSolid, false) + } + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredBrepFaceIndex = -1 + + if (!solidId) { + this.hoveredSolid = null + return + } + + const solid = this.solidIdMap.get(solidId) + if (!solid || solid.selected) { + this.hoveredSolid = null + return + } + + this.hoveredSolid = solid + this.hoverSolidEdgeLines(solid, true) + } + + /** + * 从树节点选中 Face(树→3D 方向) + * ★ 使用面级高亮(仅高亮该面),同时保持实体边缘线高亮 + */ + selectByFaceIndex(solidId: string, faceIndex: number, multi = false): void { + const solid = this.solidIdMap.get(solidId) + if (!solid) return + + if (!multi) { + this.clearSelectionInternal() + } + + const feature = solid.features.find(f => f.faceIndex === faceIndex) + if (feature) { + if (multi && this.selectedFeatures.has(feature.id)) { + // 多选 toggle:已选中 → 取消选中 + 移除覆盖层 + this.removeSelection(feature) + if (!solid.selected) this.highlightSolidEdgeLines(solid, false) + } else { + // 添加面级高亮覆盖层 + this.selectedFeatures.set(feature.id, feature) + this.applyFaceHighlight(feature) + this.highlightSolidEdgeLines(solid, true) + solid.selected = true + this.selectedSolids.add(solid.id) + } + } + + // ★ 无论是否有 feature,都触发一次回调通知最终状态 + this.onSelectCallback?.({ + selections: this.getSelections(), + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } + + /** + * 高亮 Solid 的边缘线 + * ★ 支持合并边缘线(edgeVertexRange)和独立边缘线 + */ + private highlightSolidEdgeLines(solid: SolidObject, selected: boolean): void { + if (!solid.edgeLines) return + + if (solid.edgeVertexRange) { + // 合并边缘线:通过顶点色控制 + this.setEdgeVertexColors( + solid.edgeLines, + solid.edgeVertexRange, + selected ? SelectionManager.EDGE_SELECTED_COLOR : SelectionManager.EDGE_DEFAULT_COLOR + ) + } else { + // 独立边缘线:改变材质颜色 + const material = solid.edgeLines.material as THREE.LineBasicMaterial + if (selected) { + material.color.setHex(SelectionManager.EDGE_SELECTED_COLOR) + material.opacity = SelectionManager.EDGE_HIGHLIGHT_OPACITY + material.needsUpdate = true + } else { + material.color.setHex(SelectionManager.EDGE_DEFAULT_COLOR) + material.opacity = SelectionManager.EDGE_DEFAULT_OPACITY + material.needsUpdate = true + } + } + } + + /** + * 设置合并边缘线中指定范围的顶点颜色 + */ + private setEdgeVertexColors( + edgeLines: THREE.LineSegments, + range: [number, number], + colorHex: number + ): void { + const colors = edgeLines.geometry.getAttribute('color') as THREE.BufferAttribute + if (!colors) return + const color = new THREE.Color(colorHex) + const [start, count] = range + for (let i = start; i < start + count; i++) { + colors.setXYZ(i, color.r, color.g, color.b) + } + colors.needsUpdate = true + } + + /** + * 获取当前选中的树节点 ID 列表 + */ + private getSelectedTreeNodeIds(): string[] { + const ids: string[] = [] + this.selectedFeatures.forEach(feature => { + if (feature.treeNodeId) ids.push(feature.treeNodeId) + if (feature.solidId) { + const solidTreeId = feature.solidId // solid_X 格式 + if (!ids.includes(solidTreeId)) ids.push(solidTreeId) + } + }) + return ids + } + + /** + * 获取当前选择 + */ + getSelections(): SelectionInfo[] { + return Array.from(this.selectedFeatures.values()).map(feature => { + const solid = feature.solidId ? this.solidIdMap.get(feature.solidId) : undefined + return { + feature, + solid, + point: feature.center?.clone() || new THREE.Vector3(), + distance: 0 + } + }) + } + + /** + * 获取选中的特征列表 + */ + getSelectedFeatures(): GeometryFeature[] { + return Array.from(this.selectedFeatures.values()) + } + + /** + * 应用高亮效果(用于选中状态) + * Regular Mesh → 材质切换(mesh.uuid 作为 key 保存原始材质) + * InstancedMesh → instanceColor 切换(uuid:instanceId 作为 key 保存原始颜色) + */ + private applyHighlight(feature: GeometryFeature, color: number): void { + const mesh = feature.mesh + if (!mesh) return + + const solid = feature.solidId ? this.solidIdMap.get(feature.solidId) : undefined + + // ★ InstancedMesh 路径:通过 instanceColor 高亮 + if (mesh instanceof THREE.InstancedMesh && solid?.instanceId !== undefined) { + const key = `${mesh.uuid}:${solid.instanceId}` + if (!this.originalInstanceColors.has(key)) { + const origColor = new THREE.Color() + mesh.getColorAt(solid.instanceId, origColor) + this.originalInstanceColors.set(key, origColor.clone()) + } + mesh.setColorAt(solid.instanceId, new THREE.Color(color)) + mesh.instanceColor!.needsUpdate = true + return + } + + // ★ Regular Mesh 路径:材质切换 + const meshKey = mesh.uuid + if (!this.originalMaterials.has(meshKey)) { + this.originalMaterials.set(meshKey, mesh.material) + } + + let highlightMaterial = this.highlightMaterials.get(`${meshKey}_${color}`) + if (!highlightMaterial) { + const origMat = this.originalMaterials.get(meshKey) as THREE.MeshStandardMaterial + highlightMaterial = new THREE.MeshStandardMaterial({ + color: color, + metalness: origMat?.metalness ?? 0.3, + roughness: origMat?.roughness ?? 0.6, + side: THREE.DoubleSide, + transparent: true, + opacity: origMat?.opacity ?? 1, + depthWrite: (origMat?.opacity ?? 1) >= 1, + emissive: new THREE.Color(color).multiplyScalar(0.2) + }) + this.highlightMaterials.set(`${meshKey}_${color}`, highlightMaterial) + } + + mesh.material = highlightMaterial + } + + /** + * 移除高亮效果 + * Regular Mesh → 仅当该 mesh 上不再有其他选中 feature 时才恢复原始材质 + * InstancedMesh → 恢复对应实例的原始 instanceColor + */ + private removeHighlight(feature: GeometryFeature): void { + if (!feature.mesh) return + + const solid = feature.solidId ? this.solidIdMap.get(feature.solidId) : undefined + + // ★ InstancedMesh 路径 + if (feature.mesh instanceof THREE.InstancedMesh && solid?.instanceId !== undefined) { + const key = `${feature.mesh.uuid}:${solid.instanceId}` + const origColor = this.originalInstanceColors.get(key) + if (origColor) { + feature.mesh.setColorAt(solid.instanceId, origColor) + feature.mesh.instanceColor!.needsUpdate = true + this.originalInstanceColors.delete(key) + } + return + } + + // ★ Regular Mesh 路径 + const meshKey = feature.mesh.uuid + + let otherFeatureOnSameMesh = false + this.selectedFeatures.forEach((f) => { + if (f.id !== feature.id && f.mesh === feature.mesh) { + otherFeatureOnSameMesh = true + } + }) + + if (!otherFeatureOnSameMesh) { + const originalMaterial = this.originalMaterials.get(meshKey) + if (originalMaterial) { + feature.mesh.material = originalMaterial + if (solid) { + const mat = originalMaterial as THREE.MeshStandardMaterial + mat.opacity = solid.opacity + mat.transparent = solid.opacity < 1 + mat.depthWrite = solid.opacity >= 1 + mat.needsUpdate = true + } + } + this.originalMaterials.delete(meshKey) + + // ★ 优化16: 清理该 mesh 对应的所有高亮材质(防止反复选择时只增不减) + const keysToDelete: string[] = [] + this.highlightMaterials.forEach((mat, key) => { + if (key.startsWith(`${meshKey}_`)) { + mat.dispose() + keysToDelete.push(key) + } + }) + keysToDelete.forEach(key => this.highlightMaterials.delete(key)) + } + } + + // ========== 面级高亮(overlay mesh 覆盖层方式) ========== + + /** + * 应用面级高亮(创建覆盖层 mesh,同时支持 Regular Mesh 和 InstancedMesh) + * 从几何体中提取指定 BRep 面的三角形,创建半透明橙色覆盖 mesh + */ + applyFaceHighlight(feature: GeometryFeature): void { + if (!feature.mesh || feature.faceIndex === undefined) return + if (this.faceHighlightOverlays.has(feature.id)) return + + const solid = feature.solidId ? this.solidIdMap.get(feature.solidId) : undefined + if (!solid) return + + const geometry = solid.mesh.geometry as THREE.BufferGeometry + const faceIndexAttr = geometry.getAttribute('faceIndex') + if (!faceIndexAttr) return + + const posAttr = geometry.getAttribute('position') + const normalAttr = geometry.getAttribute('normal') + const index = geometry.getIndex() + const targetFaceIndex = feature.faceIndex + + // 收集该 BRep 面的所有三角形顶点 + const positions: number[] = [] + const normals: number[] = [] + + const addVertex = (vertIdx: number) => { + positions.push(posAttr.getX(vertIdx), posAttr.getY(vertIdx), posAttr.getZ(vertIdx)) + if (normalAttr) { + normals.push(normalAttr.getX(vertIdx), normalAttr.getY(vertIdx), normalAttr.getZ(vertIdx)) + } + } + + if (index) { + for (let i = 0; i < index.count; i += 3) { + const vi = index.getX(i) + const brepFace = Math.floor(faceIndexAttr.getX(vi)) + if (brepFace === targetFaceIndex) { + for (let j = 0; j < 3; j++) { + addVertex(index.getX(i + j)) + } + } + } + } else { + for (let i = 0; i < posAttr.count; i += 3) { + const brepFace = Math.floor(faceIndexAttr.getX(i)) + if (brepFace === targetFaceIndex) { + for (let j = 0; j < 3; j++) { + addVertex(i + j) + } + } + } + } + + if (positions.length === 0) return + + // 创建覆盖层几何体 + const overlayGeo = new THREE.BufferGeometry() + overlayGeo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + if (normals.length > 0) { + overlayGeo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + } + + const overlayMesh = new THREE.Mesh(overlayGeo, this.faceHighlightMaterial) + overlayMesh.name = `faceHighlight_${feature.id}` + overlayMesh.renderOrder = 2 + + // InstancedMesh 需要应用实例变换矩阵 + if (solid.mesh instanceof THREE.InstancedMesh && solid.instanceId !== undefined) { + const matrix = new THREE.Matrix4() + solid.mesh.getMatrixAt(solid.instanceId, matrix) + overlayMesh.applyMatrix4(matrix) + } + + this.scene.add(overlayMesh) + this.faceHighlightOverlays.set(feature.id, overlayMesh) + } + + /** + * 移除面级高亮覆盖层 + */ + removeFaceHighlight(feature: GeometryFeature): void { + const overlay = this.faceHighlightOverlays.get(feature.id) + if (overlay) { + this.scene.remove(overlay) + overlay.geometry.dispose() + this.faceHighlightOverlays.delete(feature.id) + } + } + + /** + * 清除所有面级高亮覆盖层 + */ + clearAllFaceHighlights(): void { + this.faceHighlightOverlays.forEach(overlay => { + this.scene.remove(overlay) + overlay.geometry.dispose() + }) + this.faceHighlightOverlays.clear() + } + + /** + * 设置透明度 + */ + setOpacity(solidId: string | null, opacity: number): void { + const targets = solidId + ? this.solids.filter(s => s.id === solidId) + : this.solids + + const isTransparent = opacity < 1 + + targets.forEach(solid => { + solid.opacity = opacity + const material = solid.mesh.material as THREE.MeshStandardMaterial + if (material) { + material.opacity = opacity + material.transparent = isTransparent + material.depthWrite = !isTransparent + material.side = THREE.DoubleSide + material.needsUpdate = true + } + + // 更新该 Solid 的高亮材质 + 原始材质透明度(使用 mesh.uuid 作为 key) + const meshKey = solid.mesh.uuid + this.highlightMaterials.forEach((mat, key) => { + if (key.startsWith(`${meshKey}_`)) { + mat.opacity = opacity + mat.transparent = isTransparent + mat.depthWrite = !isTransparent + mat.side = THREE.DoubleSide + mat.needsUpdate = true + } + }) + + // ★ 同步更新 originalMaterials 中缓存的材质透明度(使用 mesh.uuid 作为 key) + const origMat = this.originalMaterials.get(meshKey) as THREE.MeshStandardMaterial + if (origMat && origMat !== solid.mesh.material) { + origMat.opacity = opacity + origMat.transparent = isTransparent + origMat.depthWrite = !isTransparent + origMat.side = THREE.DoubleSide + origMat.needsUpdate = true + } + }) + } + + /** + * 切换透明度(始终切换所有实体,不受选中状态影响) + */ + toggleTransparency(solidId?: string): void { + if (solidId) { + const solid = this.solidIdMap.get(solidId) + if (solid) { + const newOpacity = solid.opacity > 0.5 ? 0.3 : 1 + this.setOpacity(solidId, newOpacity) + } + } else { + // 始终切换所有实体,不受选中状态影响 + const anyOpaque = this.solids.some(s => s.opacity > 0.5) + const newOpacity = anyOpaque ? 0.3 : 1 + this.setOpacity(null, newOpacity) + } + } + + /** + * 设置全局透明模式(外部驱动,不自行判断方向) + */ + setTransparent(transparent: boolean): void { + this.setOpacity(null, transparent ? 0.3 : 1) + } + + // ========== 边级选择与高亮 ========== + + /** + * 设置选择粒度模式 + */ + setGranularityMode(mode: GranularityMode): void { + if (this.granularityMode === mode) return + this.granularityMode = mode + + // 切换时清除所有高亮和选择 + this.clearSelectionInternal() + this.clearHoverHighlight() + this.removeHoverEdgeOverlay() + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredSolid = null + this.hoveredBrepFaceIndex = -1 + + // 切换拓扑边线段可见性 + this.solids.forEach(s => { + if (s.topologyEdges) { + s.topologyEdges.visible = (mode === 'edge') + } + }) + + // 触发回调通知外部清除选择状态 + this.onSelectCallback?.({ + selections: [], + selectedTreeNodeIds: [] + }) + + this.onRenderRequest?.() + } + + /** + * 获取当前粒度模式 + */ + getGranularityMode(): GranularityMode { + return this.granularityMode + } + + /** + * 边模式下的 hover 检测 + */ + private performEdgeHoverCheck(event: MouseEvent): void { + const edgeHit = this.raycastEdges(event) + + if (!edgeHit) { + if (this.hoveredFeature) { + this.clearHoverHighlight() + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredSolid = null + this.hoveredBrepFaceIndex = -1 + this.onHoverCallback?.(null) + this.onRenderRequest?.() + } + return + } + + const { solid, feature, edgeIndex } = edgeHit + if (feature && feature === this.hoveredFeature) return + + this.clearHoverHighlight() + + if (feature && !this.selectedFeatures.has(feature.id)) { + // 高亮边缘线(hover 颜色)- 使用拓扑边线段 vertex color + 覆盖层 + this.setTopologyEdgeColor(solid, edgeIndex, SelectionManager.EDGE_HOVER_COLOR) + this.createHoverEdgeOverlay(solid, edgeIndex) + } + + this.hoveredFeature = feature + this.hoveredSolid = solid + this.hoveredBrepFaceIndex = edgeIndex + this.onHoverCallback?.(feature) + this.onRenderRequest?.() + } + + /** + * 边模式下的点击处理 + */ + private handleEdgeClick(event: MouseEvent): void { + const edgeHit = this.raycastEdges(event) + + if (!edgeHit) return + + const { solid, feature } = edgeHit + + if (!feature) return + + const selectionInfo: SelectionInfo = { + feature, + solid, + point: feature.startPoint?.clone() || new THREE.Vector3(), + distance: 0 + } + + const isMulti = this.selectionMode === 'multi' || event.ctrlKey || event.shiftKey + + if (isMulti) { + if (this.selectedFeatures.has(feature.id)) { + this.removeSelection(feature) + this.removeEdgeHighlight(feature) + this.onSelectCallback?.({ + selections: this.getSelections(), + removed: selectionInfo, + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } else { + this.selectedFeatures.set(feature.id, feature) + this.applyEdgeHighlight(feature) + if (feature.solidId) { + const s = this.solidIdMap.get(feature.solidId) + if (s) { s.selected = true; this.selectedSolids.add(s.id) } + } + this.onSelectCallback?.({ + selections: this.getSelections(), + added: selectionInfo, + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } + } else { + this.clearSelectionInternal() + this.selectedFeatures.set(feature.id, feature) + this.applyEdgeHighlight(feature) + if (feature.solidId) { + const s = this.solidIdMap.get(feature.solidId) + if (s) { s.selected = true; this.selectedSolids.add(s.id) } + } + this.onSelectCallback?.({ + selections: this.getSelections(), + added: selectionInfo, + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } + } + + /** + * 射线检测拓扑边 + */ + private raycastEdges(event: MouseEvent): { + solid: SolidObject + feature: GeometryFeature + edgeIndex: number + } | null { + const rect = this.cachedRect || this.domElement.getBoundingClientRect() + const mx = ((event.clientX - rect.left) / rect.width) * 2 - 1 + const my = -((event.clientY - rect.top) / rect.height) * 2 + 1 + + // 动态阈值:根据相机距离调整 + const camDist = this.camera instanceof THREE.PerspectiveCamera + ? this.camera.position.length() + : 100 + this.edgeRaycaster.params.Line!.threshold = Math.max(0.5, Math.min(5, camDist * 0.005)) + + this.edgeRaycaster.setFromCamera(new THREE.Vector2(mx, my), this.camera) + const intersects = this.edgeRaycaster.intersectObjects(this.cachedTopologyEdges, false) + + if (intersects.length === 0) return null + + const hit = intersects[0] + const lineSegs = hit.object as THREE.LineSegments + const geo = lineSegs.geometry as THREE.BufferGeometry + const edgeIndexAttr = geo.getAttribute('edgeIndex') + + if (!edgeIndexAttr || hit.index === undefined) return null + + const edgeIndex = Math.floor(edgeIndexAttr.getX(hit.index)) + + // 找到对应的 solid + for (const solid of this.solids) { + if (solid.topologyEdges === lineSegs || + (solid.topologyEdges && solid.topologyEdges === lineSegs)) { + // 对于合并的拓扑边(InstancedMesh),需要通过顶点范围确定实例 + if (solid.topologyEdgeVertexRanges) { + const range = solid.topologyEdgeVertexRanges.get(edgeIndex) + if (range) { + const [start, count] = range + if (hit.index >= start && hit.index < start + count) { + const edgeMap = this.edgeIndexMap.get(solid.id) + const feature = edgeMap?.get(edgeIndex) + if (feature) return { solid, feature, edgeIndex } + } + } + continue + } + + // Regular Mesh 的拓扑边 + const edgeMap = this.edgeIndexMap.get(solid.id) + const feature = edgeMap?.get(edgeIndex) + if (feature) return { solid, feature, edgeIndex } + } + } + + return null + } + + /** + * 应用边级高亮覆盖层 — 创建独立的高亮 LineSegments 覆盖在选中边上层 + */ + applyEdgeHighlight(feature: GeometryFeature): void { + if (!feature.solidId || feature.edgeIndex === undefined) return + if (this.edgeHighlightOverlays.has(feature.id)) return + + const solid = this.solidIdMap.get(feature.solidId) + if (!solid || !solid.topologyEdges) return + + // 修改 vertex color 标记选中态 + this.setTopologyEdgeColor(solid, feature.edgeIndex, SelectionManager.EDGE_SELECTED_COLOR) + + // 提取该边的顶点创建独立的覆盖几何体 + const srcGeo = solid.topologyEdges.geometry + const edgeIndexAttr = srcGeo.getAttribute('edgeIndex') as THREE.BufferAttribute + const posAttr = srcGeo.getAttribute('position') as THREE.BufferAttribute + if (!edgeIndexAttr || !posAttr) return + + const positions: number[] = [] + for (let i = 0; i < edgeIndexAttr.count; i++) { + if (Math.floor(edgeIndexAttr.getX(i)) === feature.edgeIndex) { + positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i)) + } + } + if (positions.length === 0) return + + const overlayGeo = new THREE.BufferGeometry() + overlayGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(positions), 3)) + + const overlay = new THREE.LineSegments(overlayGeo, new THREE.LineBasicMaterial({ + color: SelectionManager.EDGE_SELECTED_COLOR, + depthTest: false, + transparent: true, + opacity: 1, + })) + overlay.renderOrder = 999 + // 继承父级变换 + overlay.matrixAutoUpdate = false + overlay.matrix.copy(solid.topologyEdges.matrixWorld) + + this.scene.add(overlay) + this.edgeHighlightOverlays.set(feature.id, overlay) + } + + /** + * 移除边级高亮覆盖层 + */ + removeEdgeHighlight(feature: GeometryFeature): void { + if (!feature.solidId || feature.edgeIndex === undefined) return + + const solid = feature.solidId ? this.solidIdMap.get(feature.solidId) : undefined + if (solid?.topologyEdges) { + this.setTopologyEdgeColor(solid, feature.edgeIndex, 0x444444) + } + + const overlay = this.edgeHighlightOverlays.get(feature.id) + if (overlay) { + this.scene.remove(overlay) + overlay.geometry.dispose() + ; (overlay.material as THREE.Material).dispose() + this.edgeHighlightOverlays.delete(feature.id) + } + } + + /** + * 清除所有边级高亮 + */ + clearAllEdgeHighlights(): void { + // 恢复所有拓扑边颜色 + this.solids.forEach(solid => { + if (solid.topologyEdges) { + const geo = solid.topologyEdges.geometry + const colAttr = geo.getAttribute('color') as THREE.BufferAttribute + if (colAttr) { + const defaultColor = new THREE.Color(0x444444) + for (let i = 0; i < colAttr.count; i++) { + colAttr.setXYZ(i, defaultColor.r, defaultColor.g, defaultColor.b) + } + colAttr.needsUpdate = true + } + } + }) + this.edgeHighlightOverlays.forEach(overlay => { + this.scene.remove(overlay) + overlay.geometry.dispose() + ; (overlay.material as THREE.Material).dispose() + }) + this.edgeHighlightOverlays.clear() + } + + /** + * 设置拓扑边颜色(通过 vertex color 或材质) + */ + private setTopologyEdgeColor(solid: SolidObject, edgeIndex: number, colorHex: number): void { + if (!solid.topologyEdges) return + + const geo = solid.topologyEdges.geometry + const edgeIndexAttr = geo.getAttribute('edgeIndex') as THREE.BufferAttribute + const colAttr = geo.getAttribute('color') as THREE.BufferAttribute + + if (colAttr && edgeIndexAttr) { + // 有 vertex color(合并的拓扑边线段) + const color = new THREE.Color(colorHex) + for (let i = 0; i < edgeIndexAttr.count; i++) { + if (Math.floor(edgeIndexAttr.getX(i)) === edgeIndex) { + colAttr.setXYZ(i, color.r, color.g, color.b) + } + } + colAttr.needsUpdate = true + } else if (edgeIndexAttr) { + // 无 vertex color 时,添加 color attribute + const positions = geo.getAttribute('position') + const colors = new Float32Array(positions.count * 3) + const defaultColor = new THREE.Color(0x444444) + const targetColor = new THREE.Color(colorHex) + + for (let i = 0; i < positions.count; i++) { + const ei = Math.floor(edgeIndexAttr.getX(i)) + const c = (ei === edgeIndex) ? targetColor : defaultColor + colors[i * 3] = c.r + colors[i * 3 + 1] = c.g + colors[i * 3 + 2] = c.b + } + + geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)) + const mat = solid.topologyEdges.material as THREE.LineBasicMaterial + mat.vertexColors = true + mat.needsUpdate = true + } + } + + /** + * 从树节点选中 Edge(树→3D 方向) + */ + selectByEdgeIndex(solidId: string, edgeIndex: number, multi = false): void { + const solid = this.solidIdMap.get(solidId) + if (!solid) return + + if (!multi) { + this.clearSelectionInternal() + } + + const feature = solid.edgeFeatures.find(f => f.edgeIndex === edgeIndex) + if (feature) { + if (multi && this.selectedFeatures.has(feature.id)) { + this.removeSelection(feature) + this.removeEdgeHighlight(feature) + } else { + this.selectedFeatures.set(feature.id, feature) + this.applyEdgeHighlight(feature) + solid.selected = true + this.selectedSolids.add(solid.id) + } + } + + this.onSelectCallback?.({ + selections: this.getSelections(), + selectedTreeNodeIds: this.getSelectedTreeNodeIds() + }) + } + + /** + * 设置可见性 + */ + setVisibility(solidId: string, visible: boolean): void { + const solid = this.solidIdMap.get(solidId) + if (solid) { + solid.visible = visible + solid.mesh.visible = visible + // 隐藏/显示 solid 的拓扑边线 + if (solid.topologyEdges) { + solid.topologyEdges.visible = visible + } + // 重新构建射线检测缓存,确保隐藏的 mesh 不会遮挡后方模型 + this.updateCachedMeshes() + this.updateCachedTopologyEdges() + } + } + + /** + * 更新相机引用 + */ + updateCamera(camera: THREE.Camera): void { + this.camera = camera + } + + /** + * 销毁选择管理器 + */ + dispose(): void { + // 清理 RAF 节流 + this.rafThrottledMouseMove.cancel() + + // 清理 hover 状态 + this.clearHoverHighlight() + this.hoveredFeature = null + this.hoveredMesh = null + this.hoveredSolid = null + + // 清理控制器事件 + if (this.orbitControls) { + this.orbitControls.removeEventListener('start', this.handleOrbitStart) + this.orbitControls.removeEventListener('end', this.handleOrbitEnd) + this.orbitControls = null + } + + // 清理 ResizeObserver + if (this.resizeObserver) { + this.resizeObserver.disconnect() + this.resizeObserver = null + } + + // 移除所有 DOM 事件监听器 + this.domElement.removeEventListener('click', this.handleClick) + this.domElement.removeEventListener('mousemove', this.handleMouseMove) + this.domElement.removeEventListener('mousedown', this.handleMouseDown) + this.domElement.removeEventListener('mouseup', this.handleMouseUp) + this.domElement.removeEventListener('mouseleave', this.handleMouseLeave) + this.domElement.removeEventListener('contextmenu', this.handleContextMenu) + + this.highlightMaterials.forEach(mat => mat.dispose()) + this.highlightMaterials.clear() + this.originalMaterials.clear() + this.originalInstanceColors.clear() + this.instancedMeshRefs.clear() + this.instancedMeshToSolids.clear() + this.selectedFeatures.clear() + this.selectedSolids.clear() + this.clearAllFaceHighlights() + this.clearAllEdgeHighlights() + this.removeHoverEdgeOverlay() + this.faceHighlightMaterial.dispose() + this.edgeHighlightMaterial.dispose() + this.featureIndexMap.clear() + this.edgeIndexMap.clear() + this.meshToSolid.clear() + this.solidIdMap.clear() + this.cachedMeshes = [] + this.cachedTopologyEdges = [] + this.cachedRect = null + } +} + +export default SelectionManager diff --git a/frontend/src/components/StepViewer/core/StepLoader.ts b/frontend/src/components/StepViewer/core/StepLoader.ts new file mode 100644 index 0000000..24133be --- /dev/null +++ b/frontend/src/components/StepViewer/core/StepLoader.ts @@ -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 | null = null +let workerReady = false +let workerInitPromise: Promise | null = null + +/** + * 获取或创建 Worker 实例和 Comlink 代理(单例) + */ +function getWorkerProxy(): Comlink.Remote { + if (!workerProxy) { + worker = new Worker( + new URL('./StepParseWorker.ts', import.meta.url), + { type: 'module' } + ) + workerProxy = Comlink.wrap(worker) + } + return workerProxy +} + +/** + * 预加载 OpenCascade WASM 模块(通过 Worker) + * 可在应用初始化时调用,提前加载 WASM 避免首次上传延迟 + */ +export async function preloadOcct(): Promise { + 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() + + // ★ 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() + 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 = {} + 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 + ): 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, + 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, + 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() + + 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>() // instanceIdx -> Map + + 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() + 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 = { + 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 { + return new Promise(resolve => setTimeout(resolve, 0)) + } + + /** + * 读取文件为 ArrayBuffer + */ + private readFileAsArrayBuffer(file: File): Promise { + 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 diff --git a/frontend/src/components/StepViewer/core/StepParseWorker.ts b/frontend/src/components/StepViewer/core/StepParseWorker.ts new file mode 100644 index 0000000..b994d1c --- /dev/null +++ b/frontend/src/components/StepViewer/core/StepParseWorker.ts @@ -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 { + 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(fn: (register: (obj: O) => O) => T): T { + const toDelete: any[] = [] + const register = (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 = {} + + 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 = { + 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 { + 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) => { + // 如果消息由 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 }) diff --git a/frontend/src/components/StepViewer/core/URDFSerializer.ts b/frontend/src/components/StepViewer/core/URDFSerializer.ts new file mode 100644 index 0000000..b5edadc --- /dev/null +++ b/frontend/src/components/StepViewer/core/URDFSerializer.ts @@ -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 + /** + * 单位缩放系数,应用于所有线性尺寸(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('') + lines.push(``) + + 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('') + return lines.join('\n') +} + +function serializeLink(link: URDFLink, unitScale: number, restInverse?: THREE.Matrix4): string { + const lines: string[] = [] + const s = unitScale + lines.push(` `) + + 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(' ') + lines.push(` `) + lines.push(` `) + // inertia 已转换到 link-local 轴、SI 单位 kg·m²,直接写入 + lines.push(` `) + lines.push(' ') + } + + // Visual — 引用 STL 网格 + if (link.solidIds.length > 0) { + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` `) + lines.push(' ') + lines.push(' ') + + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` `) + lines.push(' ') + lines.push(' ') + } + + lines.push(' ') + 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(` `) + lines.push(` `) + lines.push(` `) + lines.push(` `) + lines.push(` `) + + 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(` `) + } + + lines.push(' ') + 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 中未找到 元素') + } + + 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() + 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 规范要求 值表达在与 link frame 对齐的轴下(当 时)。 + * 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, ''') +} + +/** + * 从旋转矩阵提取 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] +} diff --git a/frontend/src/components/StepViewer/core/index.ts b/frontend/src/components/StepViewer/core/index.ts new file mode 100644 index 0000000..e3bd659 --- /dev/null +++ b/frontend/src/components/StepViewer/core/index.ts @@ -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' diff --git a/frontend/src/components/StepViewer/core/useExportWorker.ts b/frontend/src/components/StepViewer/core/useExportWorker.ts new file mode 100644 index 0000000..4ba72d4 --- /dev/null +++ b/frontend/src/components/StepViewer/core/useExportWorker.ts @@ -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 | null = null + +function getProxy(): Comlink.Remote { + if (!workerProxy) { + worker = new Worker( + new URL('./ExportWorker.ts', import.meta.url), + { type: 'module' } + ) + workerProxy = Comlink.wrap(worker) + } + return workerProxy +} + +/** + * 在 Worker 中生成 URDF ZIP 包(STL 生成 + ZIP 打包均在 Worker 线程) + */ +export async function exportURDFInWorker( + urdfXml: string, + linkSolidMap: Record, + linkRestInverseMap: Record, + unitScale: number, + onProgress?: (stage: string, percent: number) => void +): Promise { + 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 +} diff --git a/frontend/src/components/StepViewer/core/useInertiaWorker.ts b/frontend/src/components/StepViewer/core/useInertiaWorker.ts new file mode 100644 index 0000000..fe37047 --- /dev/null +++ b/frontend/src/components/StepViewer/core/useInertiaWorker.ts @@ -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 | null = null +let initPromise: Promise | null = null + +/** + * 获取或创建 Worker 实例(单例),并确保 OC 已初始化 + */ +async function getProxy(): Promise> { + if (!workerProxy) { + worker = new Worker( + new URL('./InertiaWorker.ts', import.meta.url), + { type: 'module' } + ) + workerProxy = Comlink.wrap(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 { + 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(density=1 原始值) + */ +export async function computeRefInertias( + links: { linkId: string; solidDataList: SerializedSolidData[] }[] +): Promise> { + const validLinks = links.filter(l => l.solidDataList.length > 0) + if (validLinks.length === 0) return new Map() + + const result = new Map() + 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 + */ +export async function computeAllLinksInertia( + links: { linkId: string; solidDataList: SerializedSolidData[] }[], + totalMass: number +): Promise> { + // 过滤掉没有几何数据的 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((sum, r) => sum + (r?.mass ?? 0), 0) + if (totalRefMass <= 0) return new Map() + + const k = totalMass / totalRefMass + + // 按缩放因子分配质量和惯性张量,跳过计算失败的 Link + const result = new Map() + 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 +} diff --git a/frontend/src/components/StepViewer/core/useKinematicsWorker.ts b/frontend/src/components/StepViewer/core/useKinematicsWorker.ts new file mode 100644 index 0000000..69a4829 --- /dev/null +++ b/frontend/src/components/StepViewer/core/useKinematicsWorker.ts @@ -0,0 +1,74 @@ +/** + * 运动学 Worker Composable + * 使用单例模式管理 KinematicsWorker 实例和 Comlink 代理 + */ + +import * as Comlink from 'comlink' +import type { KinematicsWorkerApi } from './KinematicsWorker' +import type { KinematicsResult } from '../types' + +let worker: Worker | null = null +let workerProxy: Comlink.Remote | null = null + +/** + * 获取或创建 Worker 实例(单例) + */ +function getProxy(): Comlink.Remote { + if (!workerProxy) { + worker = new Worker( + new URL('./KinematicsWorker.ts', import.meta.url), + { type: 'module' } + ) + workerProxy = Comlink.wrap(worker) + } + return workerProxy +} + +/** + * 计算关节相对于父级的局部变换(异步,在 Worker 中执行) + * + * @param parentWorldMatrix Three.js Matrix4.elements (Float32Array / number[]) + * @param snapPosition 吸附点世界坐标 [x, y, z] + * @param snapNormal 吸附法线世界方向 [x, y, z] + * @returns { xyz, rpy } 相对坐标 + */ +export async function computeRelativeTransform( + parentWorldMatrix: ArrayLike, + snapPosition: [number, number, number], + snapNormal: [number, number, number] +): Promise { + const proxy = getProxy() + + // Three.js Matrix4.elements 是 Float64Array / number[],转为 Float32Array + const matBuf = new Float32Array(16) + for (let i = 0; i < 16; i++) matBuf[i] = parentWorldMatrix[i] + + const posBuf = new Float32Array(snapPosition) + const normBuf = new Float32Array(snapNormal) + + try { + const result = await proxy.computeRelativeTransform( + Comlink.transfer(matBuf, [matBuf.buffer]), + Comlink.transfer(posBuf, [posBuf.buffer]), + Comlink.transfer(normBuf, [normBuf.buffer]) + ) + return result + } catch { + // Worker 异常兜底 + return { xyz: [0, 0, 0], rpy: [0, 0, 0] } + } +} + +/** + * 销毁 Worker 实例 + */ +export function disposeKinematicsWorker(): void { + if (workerProxy) { + workerProxy[Comlink.releaseProxy]() + workerProxy = null + } + if (worker) { + worker.terminate() + worker = null + } +} diff --git a/frontend/src/components/StepViewer/index.ts b/frontend/src/components/StepViewer/index.ts new file mode 100644 index 0000000..0b9cd49 --- /dev/null +++ b/frontend/src/components/StepViewer/index.ts @@ -0,0 +1,13 @@ +/** + * STEP Viewer 组件导出 + */ + +import StepViewer from './components/StepViewer.vue' + +export { StepViewer } +export * from './types' +export * from './core' +export { useStepViewerStore } from './stores/useStepViewerStore' +export { useURDFStore } from './stores/useURDFStore' + +export default StepViewer diff --git a/frontend/src/components/StepViewer/stores/useStepViewerStore.ts b/frontend/src/components/StepViewer/stores/useStepViewerStore.ts new file mode 100644 index 0000000..9ebc0cf --- /dev/null +++ b/frontend/src/components/StepViewer/stores/useStepViewerStore.ts @@ -0,0 +1,369 @@ +/** + * STEP Viewer 状态管理 + */ + +import { defineStore } from 'pinia' +import { ref, computed, markRaw } from 'vue' +import type { + SolidObject, + GeometryFeature, + SelectionInfo, + UploadProgress, + TreeNode, +} from '../types' +import type { LineMeasurementData } from '../core/LineMeasurementTool' + +export const useStepViewerStore = defineStore('stepViewer', () => { + // ============ 状态 ============ + + // 上传状态 + const uploadProgress = ref({ + status: 'idle', + progress: 0, + message: '' + }) + + // 模型数据 + const solids = ref([]) + const currentFileName = ref('') + + // 结构树状态 + const treeNodes = ref([]) + const selectedTreeNodeIds = ref([]) + const expandedTreeNodeIds = ref([]) + const treeNodeCount = ref(0) + + // 侧栏状态 + const sidePanelVisible = ref(true) + const sidePanelWidth = ref(280) + + // 选择状态 + const selectedFeatures = ref([]) + + // 画线测量状态 + const lineMeasurements = ref([]) + const isLineMeasureActive = ref(false) + + // 显示设置 + const showAxes = ref(false) + const showGrid = ref(true) + const globalOpacity = ref(0.3) + const isTransparent = ref(false) + + // Solid 显隐状态: solidId -> visible + const solidVisibilityMap = ref(new Map()) + + // ============ 计算属性 ============ + + // 是否已加载模型 + const hasModel = computed(() => solids.value.length > 0) + + // 是否正在加载 + const isLoading = computed(() => + uploadProgress.value.status === 'uploading' || + uploadProgress.value.status === 'parsing' + ) + + // 选中的第一个特征 + const firstSelectedFeature = computed(() => selectedFeatures.value[0] || null) + + // 选中的第二个特征 + const secondSelectedFeature = computed(() => selectedFeatures.value[1] || null) + + // 是否可以测量(选中两个特征) + const canMeasure = computed(() => selectedFeatures.value.length === 2) + + // 所有特征的类型统计 + const featureStats = computed(() => { + const stats: Record = {} + solids.value.forEach(solid => { + solid.features.forEach(feature => { + const type = feature.type + stats[type] = (stats[type] || 0) + 1 + }) + }) + return stats + }) + + /** 扁平化树节点(用于快速查找) */ + const flatTreeNodes = computed(() => { + const result: TreeNode[] = [] + const walk = (nodes: TreeNode[]) => { + for (const node of nodes) { + result.push(node) + if (node.children) walk(node.children) + } + } + walk(treeNodes.value) + return result + }) + + /** 选中节点 ID 的 Set(O(1) 查找) */ + const selectedTreeNodeIdSet = computed(() => new Set(selectedTreeNodeIds.value)) + + /** Solid ID → SolidObject 映射(O(1) 查找,修复数组索引查找错位问题) */ + const solidMap = computed(() => { + const map = new Map() + solids.value.forEach(s => map.set(s.id, s)) + return map + }) + + /** 选中的 Solid 名称列表 */ + const selectedSolidNames = computed(() => { + return selectedTreeNodeIds.value + .map(id => flatTreeNodes.value.find(n => n.id === id)) + .filter(Boolean) + .map(n => n!.name) + }) + + // ============ 动作 ============ + + /** + * 更新上传进度 + */ + function updateUploadProgress(progress: Partial): void { + uploadProgress.value = { ...uploadProgress.value, ...progress } + } + + /** + * 设置模型数据 + * ★ 将大对象 (mesh, serializedData 等) 标记为非响应式,避免 Vue Proxy 包裹导致的性能开销 + */ + function setSolids(newSolids: SolidObject[]): void { + for (const solid of newSolids) { + if (solid.mesh) markRaw(solid.mesh) + if (solid.serializedData) markRaw(solid.serializedData as any) + if (solid.edgeLines) markRaw(solid.edgeLines) + if (solid.topologyEdges) markRaw(solid.topologyEdges) + } + solids.value = newSolids + } + + /** + * 设置结构树节点 + */ + function setTreeNodes(nodes: TreeNode[]): void { + treeNodes.value = nodes + // 默认只展开根层和 Compound 层 + const idsToExpand: string[] = [] + let count = 0 + const walk = (ns: TreeNode[]) => { + for (const n of ns) { + count++ + if (n.type === 'root' || n.type === 'compound') { + idsToExpand.push(n.id) + } + if (n.children) walk(n.children) + } + } + walk(nodes) + expandedTreeNodeIds.value = idsToExpand + treeNodeCount.value = count + } + + /** + * 选中树节点(来自树的交互) + */ + function selectTreeNode(nodeId: string, multi = false): void { + if (multi) { + const idx = selectedTreeNodeIds.value.indexOf(nodeId) + if (idx >= 0) { + selectedTreeNodeIds.value.splice(idx, 1) + } else { + selectedTreeNodeIds.value.push(nodeId) + } + } else { + selectedTreeNodeIds.value = [nodeId] + } + } + + /** + * 从 3D 选中同步到树(3D→树方向) + */ + function syncTreeFromSelection(treeNodeIds: string[]): void { + selectedTreeNodeIds.value = [...treeNodeIds] + } + + /** + * 清空树选择 + */ + function clearTreeSelection(): void { + selectedTreeNodeIds.value = [] + } + + /** + * 设置当前文件名 + */ + function setFileName(name: string): void { + currentFileName.value = name + } + + /** + * 清空模型 + */ + function clearModel(): void { + solids.value = [] + currentFileName.value = '' + selectedFeatures.value = [] + lineMeasurements.value = [] + isLineMeasureActive.value = false + isTransparent.value = false + treeNodes.value = [] + selectedTreeNodeIds.value = [] + expandedTreeNodeIds.value = [] + solidVisibilityMap.value = new Map() + uploadProgress.value = { + status: 'idle', + progress: 0, + message: '' + } + } + + /** + * 设置选中的特征 + */ + function setSelectedFeatures(features: GeometryFeature[]): void { + selectedFeatures.value = features + } + + /** + * 清空选择 + */ + function clearSelection(): void { + selectedFeatures.value = [] + selectedTreeNodeIds.value = [] + } + + // ========== 画线测量 ========== + + function addLineMeasurement(line: LineMeasurementData): void { + lineMeasurements.value.push(line) + } + + function removeLineMeasurement(id: string): void { + const idx = lineMeasurements.value.findIndex(l => l.id === id) + if (idx > -1) lineMeasurements.value.splice(idx, 1) + } + + function clearLineMeasurements(): void { + lineMeasurements.value = [] + } + + function setLineMeasureActive(active: boolean): void { + isLineMeasureActive.value = active + } + + + /** + * 切换 Solid 显隐状态 + */ + function toggleSolidVisibility(solidId: string): void { + const current = solidVisibilityMap.value.get(solidId) ?? true + solidVisibilityMap.value.set(solidId, !current) + // 触发响应式更新 + solidVisibilityMap.value = new Map(solidVisibilityMap.value) + } + + /** + * 获取 Solid 是否可见 + */ + function isSolidVisible(solidId: string): boolean { + return solidVisibilityMap.value.get(solidId) ?? true + } + + /** + * 切换侧栏可见性 + */ + function toggleSidePanel(): void { + sidePanelVisible.value = !sidePanelVisible.value + } + + /** + * 设置侧栏宽度 + */ + function setSidePanelWidth(width: number): void { + sidePanelWidth.value = Math.max(120, Math.min(500, width)) + } + + /** + * 设置显示设置 + */ + function setShowAxes(show: boolean): void { + showAxes.value = show + } + + function setShowGrid(show: boolean): void { + showGrid.value = show + } + + /** + * 设置全局透明度 + */ + function setGlobalOpacity(opacity: number): void { + globalOpacity.value = opacity + } + + /** + * 设置透明模式 + */ + function setTransparent(value: boolean): void { + isTransparent.value = value + } + + return { + // 状态 + uploadProgress, + solids, + currentFileName, + treeNodes, + selectedTreeNodeIds, + expandedTreeNodeIds, + sidePanelVisible, + sidePanelWidth, + selectedFeatures, + lineMeasurements, + isLineMeasureActive, + showAxes, + showGrid, + globalOpacity, + isTransparent, + solidVisibilityMap, + + // 计算属性 + hasModel, + isLoading, + firstSelectedFeature, + secondSelectedFeature, + canMeasure, + featureStats, + flatTreeNodes, + selectedTreeNodeIdSet, + selectedSolidNames, + solidMap, + treeNodeCount, + + // 动作 + updateUploadProgress, + setSolids, + setFileName, + setTreeNodes, + selectTreeNode, + syncTreeFromSelection, + clearTreeSelection, + clearModel, + setSelectedFeatures, + clearSelection, + addLineMeasurement, + removeLineMeasurement, + clearLineMeasurements, + setLineMeasureActive, + toggleSolidVisibility, + isSolidVisible, + toggleSidePanel, + setSidePanelWidth, + setShowAxes, + setShowGrid, + setGlobalOpacity, + setTransparent, + } +}) diff --git a/frontend/src/components/StepViewer/stores/useURDFStore.ts b/frontend/src/components/StepViewer/stores/useURDFStore.ts new file mode 100644 index 0000000..66ee666 --- /dev/null +++ b/frontend/src/components/StepViewer/stores/useURDFStore.ts @@ -0,0 +1,477 @@ +/** + * URDF 构建视图状态管理 + */ + +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' +import * as THREE from 'three' +import type { + URDFRobot, + URDFLink, + URDFJoint, + JointType, + URDFOrigin, + JointLimits, + InertialParams, + BindingModeState, + JointWizardStep +} from '../types' + +/** base_link 固定 ID */ +const BASE_LINK_ID = 'link_base' + +let _nextLinkId = 1 +let _nextJointId = 1 + +/** el-tree 节点类型 */ +export interface URDFTreeNode { + id: string + label: string + nodeType: 'link' | 'joint' + jointType?: JointType + solidCount: number + isBase: boolean + children: URDFTreeNode[] +} + +export const useURDFStore = defineStore('urdf', () => { + // ============ 机器人模型 ============ + const robot = ref({ + name: 'robot', + links: [ + { id: BASE_LINK_ID, name: 'base_link', solidIds: [], inertial: null } + ], + joints: [] + }) + + /** 导出中 loading */ + const exporting = ref(false) + const exportProgress = ref('') + + // ============ UI 状态 ============ + const selectedLinkId = ref(null) + const selectedJointId = ref(null) + + /** Solid 绑定模式 */ + const bindingMode = ref({ active: false, targetLinkId: null }) + + /** 关节创建向导状态 */ + const jointWizardVisible = ref(false) + const jointWizardStep = ref('select-links') + + /** 正在重新拾取边的已有 Joint ID(编辑模式边拾取) */ + const edgePickEditJointId = ref(null) + + /** 显示控制 */ + const showFrames = ref(true) + const urdfEditorVisible = ref(false) + + /** FK 计算后各 Link 的世界变换矩阵(由 StepViewer 写入) */ + const linkWorldTransforms = ref(new Map()) + + /** 坐标轴可视化缩放比(相对于自动计算的基准轴长) */ + const axisHelperScale = ref(1.0) + + /** Base Link 原点拾取交互模式 */ + const basePickMode = ref(false) + /** Base Link 在世界坐标系中的可视化原点(定义运动树计算起点,null = 未初始化) */ + const baseLinkOrigin = ref<[number, number, number] | null>(null) + /** Base Link 坐标系姿态 RPY(弧度):[roll, pitch, yaw],与 URDF 约定一致 + * null 表示默认([0,0,0] = 与世界坐标系同向) */ + const baseLinkRPY = ref<[number, number, number] | null>(null) + + // ============ 计算属性 ============ + + /** Link ID → URDFLink */ + const linkMap = computed(() => { + const map = new Map() + robot.value.links.forEach(l => map.set(l.id, l)) + return map + }) + + /** Joint ID → URDFJoint */ + const jointMap = computed(() => { + const map = new Map() + robot.value.joints.forEach(j => map.set(j.id, j)) + return map + }) + + /** Link name → URDFLink */ + const linkByName = computed(() => { + const map = new Map() + robot.value.links.forEach(l => map.set(l.name, l)) + return map + }) + + /** 每个 Link 作为 child 出现的 Joint(用于构建运动学树) */ + const childJointMap = computed(() => { + const map = new Map() + robot.value.joints.forEach(j => map.set(j.childLinkId, j)) + return map + }) + + /** 每个 Link 作为 parent 出现的 Joint 列表 */ + const parentJointMap = computed(() => { + const map = new Map() + robot.value.joints.forEach(j => { + const list = map.get(j.parentLinkId) || [] + list.push(j) + map.set(j.parentLinkId, list) + }) + return map + }) + + /** 根 Link(没有作为 child 出现在任何 Joint 中的 Link) */ + const rootLinks = computed(() => { + const childIds = new Set(robot.value.joints.map(j => j.childLinkId)) + return robot.value.links.filter(l => !childIds.has(l.id)) + }) + + /** 叶节点 Link(没有作为 parent 出现在任何 Joint 中的 Link) */ + const leafLinks = computed(() => { + const parentIds = new Set(robot.value.joints.map(j => j.parentLinkId)) + return robot.value.links.filter(l => !parentIds.has(l.id)) + }) + + // ============ el-tree 拓扑树数据 ============ + + function buildLinkNode(linkId: string): URDFTreeNode { + const link = linkMap.value.get(linkId) + const childJoints = parentJointMap.value.get(linkId) || [] + return { + id: linkId, + label: link?.name ?? linkId, + nodeType: 'link', + solidCount: link?.solidIds.length ?? 0, + isBase: isBaseLink(linkId), + jointType: undefined, + children: childJoints.map(j => ({ + id: j.id, + label: j.name, + nodeType: 'joint' as const, + jointType: j.type, + solidCount: 0, + isBase: false, + children: linkMap.value.has(j.childLinkId) ? [buildLinkNode(j.childLinkId)] : [] + })) + } + } + + /** el-tree 格式的拓扑树,供 URDFLeftPanel 直接消费 */ + const treeData = computed(() => rootLinks.value.map(l => buildLinkNode(l.id))) + + /** 可用的非 fixed 关节列表(用于滑块面板) */ + const activeJoints = computed(() => { + return robot.value.joints.filter(j => j.type !== 'fixed') + }) + + /** 已绑定的 Solid ID 集合 */ + const boundSolidIds = computed(() => { + const ids = new Set() + robot.value.links.forEach(l => l.solidIds.forEach(id => ids.add(id))) + return ids + }) + + // ============ Link CRUD ============ + + function isBaseLink(linkId: string): boolean { + return linkId === BASE_LINK_ID + } + + function addLink(name?: string): URDFLink { + const id = `link_${_nextLinkId++}` + const link: URDFLink = { + id, + name: name || `Link_${_nextLinkId - 1}`, + solidIds: [], + inertial: null, + } + robot.value.links.push(link) + selectedLinkId.value = id + return link + } + + function removeLink(linkId: string): { ok: boolean; reason?: string } { + if (isBaseLink(linkId)) { + return { ok: false, reason: 'base_link 不能被删除' } + } + // 级联删除关联的 Joint(parentLinkId 或 childLinkId 匹配) + robot.value.joints = robot.value.joints.filter( + j => j.parentLinkId !== linkId && j.childLinkId !== linkId + ) + robot.value.links = robot.value.links.filter(l => l.id !== linkId) + if (selectedLinkId.value === linkId) { + selectedLinkId.value = null + } + return { ok: true } + } + + function renameLink(linkId: string, newName: string): void { + const link = linkMap.value.get(linkId) + if (link) { + link.name = newName + } + } + + function renameJoint(jointId: string, newName: string): void { + const joint = jointMap.value.get(jointId) + if (joint) { + joint.name = newName + } + } + + function bindSolid(linkId: string, solidId: string): void { + const link = linkMap.value.get(linkId) + if (link && !link.solidIds.includes(solidId)) { + link.solidIds.push(solidId) + } + } + + function unbindSolid(linkId: string, solidId: string): void { + const link = linkMap.value.get(linkId) + if (link) { + link.solidIds = link.solidIds.filter(id => id !== solidId) + } + } + + // ============ Joint CRUD ============ + + /** + * 校验 Joint 创建参数。返回 null 表示合法;否则返回错误消息。 + */ + function validateJoint(parentLinkId: string, childLinkId: string, excludeJointId?: string): string | null { + // 1. 禁止自连接 + if (parentLinkId === childLinkId) { + return '父子连杆不能相同' + } + // 2. base_link 不能作为 child + if (childLinkId === BASE_LINK_ID) { + return 'base_link 不能作为 Child(它是根连杆)' + } + // 3. 单一父级约束 — child 只能被一个 Joint 拥有 + const existing = robot.value.joints.find( + j => j.childLinkId === childLinkId && j.id !== excludeJointId + ) + if (existing) { + return `该连杆已作为 "${existing.name}" 的 Child,禁止构成运动学闭环` + } + return null + } + + function addJoint(config: { + name?: string + type: JointType + parentLinkId: string + childLinkId: string + origin: URDFOrigin + axis: [number, number, number] + axisOffset?: [number, number, number] + limits?: JointLimits + }): { ok: true; joint: URDFJoint } | { ok: false; reason: string } { + const err = validateJoint(config.parentLinkId, config.childLinkId) + if (err) return { ok: false, reason: err } + + const id = `joint_${_nextJointId++}` + const joint: URDFJoint = { + id, + name: config.name || `Joint_${_nextJointId - 1}`, + type: config.type, + parentLinkId: config.parentLinkId, + childLinkId: config.childLinkId, + origin: config.origin, + axis: config.axis, + axisOffset: config.axisOffset || [0, 0, 0], + limits: config.limits || ( + config.type === 'prismatic' + ? { lower: -100, upper: 100, effort: 100, velocity: 100 } + : { lower: -3.14159, upper: 3.14159, effort: 10, velocity: 1 } + ), + currentValue: 0 + } + robot.value.joints.push(joint) + selectedJointId.value = id + return { ok: true, joint } + } + + function removeJoint(jointId: string): void { + robot.value.joints = robot.value.joints.filter(j => j.id !== jointId) + if (selectedJointId.value === jointId) { + selectedJointId.value = null + } + } + + function updateJoint(jointId: string, updates: Partial>): void { + const joint = jointMap.value.get(jointId) + if (joint) { + Object.assign(joint, updates) + } + } + + function setJointValue(jointId: string, value: number): void { + const joint = jointMap.value.get(jointId) + if (joint) { + joint.currentValue = Math.max(joint.limits.lower, Math.min(joint.limits.upper, value)) + } + } + + function resetJoints(): void { + robot.value.joints.forEach(j => { j.currentValue = 0 }) + } + + function randomizeJoints(): void { + robot.value.joints.forEach(j => { + if (j.type !== 'fixed') { + j.currentValue = j.limits.lower + Math.random() * (j.limits.upper - j.limits.lower) + } + }) + } + + // ============ 惯性参数更新 ============ + + function setLinkInertial(linkId: string, inertial: InertialParams): void { + const link = linkMap.value.get(linkId) + if (link) { + link.inertial = inertial + } + } + + // ============ 绑定模式管理 ============ + + function startBindingMode(linkId: string): void { + bindingMode.value = { active: true, targetLinkId: linkId } + } + + function stopBindingMode(): void { + bindingMode.value = { active: false, targetLinkId: null } + } + + // ============ 从 URDF 导入 ============ + + function importRobot(imported: URDFRobot): void { + // 确保有 base_link + if (!imported.links.some(l => l.name === 'base_link')) { + imported.links.unshift({ + id: BASE_LINK_ID, + name: 'base_link', + solidIds: [], + inertial: null, + }) + } + robot.value = imported + selectedLinkId.value = null + selectedJointId.value = null + baseLinkOrigin.value = null + basePickMode.value = false + // 重设 ID 计数器 + _nextLinkId = imported.links.length + 1 + _nextJointId = imported.joints.length + 1 + } + + // ============ 树合法性校验 ============ + + /** + * 检查孤岛 Link(除 base_link/root 外,未被任何 Joint 引用为 child 的 Link) + * 返回孤立 Link 名称列表 + */ + function findOrphanLinks(): string[] { + const childIds = new Set(robot.value.joints.map(j => j.childLinkId)) + return robot.value.links + .filter(l => !isBaseLink(l.id) && !childIds.has(l.id)) + .map(l => l.name) + } + + // ============ 重置 ============ + + function clearAll(): void { + robot.value = { + name: 'robot', + links: [ + { id: BASE_LINK_ID, name: 'base_link', solidIds: [], inertial: null } + ], + joints: [] + } + selectedLinkId.value = null + selectedJointId.value = null + bindingMode.value = { active: false, targetLinkId: null } + jointWizardVisible.value = false + jointWizardStep.value = 'select-links' + edgePickEditJointId.value = null + baseLinkOrigin.value = null + baseLinkRPY.value = null + basePickMode.value = false + showFrames.value = true + axisHelperScale.value = 1.0 + linkWorldTransforms.value = new Map() + exporting.value = false + exportProgress.value = '' + _nextLinkId = 1 + _nextJointId = 1 + } + + return { + // 常量 + BASE_LINK_ID, + + // 状态 + robot, + selectedLinkId, + selectedJointId, + bindingMode, + jointWizardVisible, + jointWizardStep, + edgePickEditJointId, + showFrames, + urdfEditorVisible, + exporting, + exportProgress, + linkWorldTransforms, + axisHelperScale, + basePickMode, + baseLinkOrigin, + baseLinkRPY, + + // 计算属性 + linkMap, + jointMap, + linkByName, + childJointMap, + parentJointMap, + rootLinks, + leafLinks, + activeJoints, + boundSolidIds, + treeData, + + // Link CRUD + isBaseLink, + addLink, + removeLink, + renameLink, + renameJoint, + bindSolid, + unbindSolid, + + // Joint CRUD + validateJoint, + addJoint, + removeJoint, + updateJoint, + setJointValue, + resetJoints, + randomizeJoints, + + // 惯性 + setLinkInertial, + + // 绑定模式 + startBindingMode, + stopBindingMode, + + // 校验 + findOrphanLinks, + + // 导入/重置 + importRobot, + clearAll + } +}) diff --git a/frontend/src/components/StepViewer/types/index.ts b/frontend/src/components/StepViewer/types/index.ts new file mode 100644 index 0000000..7cb574b --- /dev/null +++ b/frontend/src/components/StepViewer/types/index.ts @@ -0,0 +1,445 @@ +/** + * STEP Viewer 类型定义 + * 适配 opencascade.js XDE 解析引擎 + */ + +import type { Mesh, Object3D, Vector3, BufferGeometry, LineSegments } from 'three' + +// ============ 文件相关 ============ + +/** 文件校验结果 */ +export interface FileValidationResult { + valid: boolean + error?: string + file?: File +} + +/** 上传状态 */ +export type UploadStatus = 'idle' | 'uploading' | 'parsing' | 'success' | 'error' + +/** 上传进度信息 */ +export interface UploadProgress { + status: UploadStatus + progress: number + message: string +} + +// ============ 结构树相关 ============ + +/** 结构树节点类型 */ +export type TreeNodeType = 'root' | 'compound' | 'solid' | 'shell' | 'edge' + +/** 选择粒度模式 */ +export type GranularityMode = 'solid' | 'edge' + +/** 结构树节点 */ +export interface TreeNode { + id: string + name: string + type: TreeNodeType + children?: TreeNode[] + /** 对应 SolidObject 的索引(solid 类型节点) */ + solidIndex?: number + /** 对应 face 的索引(face 类型节点) */ + faceIndex?: number + /** 对应 edge 的索引(edge 类型节点) */ + edgeIndex?: number + /** 颜色 [R, G, B],0-1 范围 */ + color?: number[] + /** 是否可见 */ + visible?: boolean +} + +// ============ 几何特征相关 ============ + +/** 几何特征类型 */ +export enum FeatureType { + UNKNOWN = 'unknown', + FACE = 'face', + EDGE = 'edge', + VERTEX = 'vertex', + CIRCLE = 'circle', + ARC = 'arc', + LINE = 'line', + CYLINDER = 'cylinder', + PLANE = 'plane', + SPHERE = 'sphere', + CONE = 'cone', + TORUS = 'torus' +} + +/** 几何特征信息 */ +export interface GeometryFeature { + id: string + type: FeatureType + mesh: Mesh + faceIndex?: number + edgeIndex?: number + solidId?: string + /** 结构树节点 ID */ + treeNodeId?: string + // 几何属性 + center?: Vector3 + normal?: Vector3 + radius?: number + startAngle?: number + endAngle?: number + axis?: Vector3 + height?: number + // 圆锥属性 + semiAngle?: number + // 圆环属性 + majorRadius?: number + minorRadius?: number + // 边特有属性 + length?: number + startPoint?: Vector3 + endPoint?: Vector3 + edgeCurveType?: string + // 原始数据 + originalColor?: number + userData?: Record +} + +/** Solid 对象 */ +export interface SolidObject { + id: string + name: string + mesh: Mesh + /** 边缘线 LineSegments(视觉用,EdgesGeometry 生成) */ + edgeLines?: LineSegments + /** 拓扑边线段(可拾取,OCCT Edge 数据生成) */ + topologyEdges?: LineSegments + /** 拓扑边特征列表 */ + edgeFeatures: GeometryFeature[] + /** 结构树节点 ID */ + treeNodeId?: string + boundingBox?: { + min: Vector3 + max: Vector3 + center: Vector3 + } + /** InstancedMesh 中的实例索引(仅 InstancedMesh 合并的 Solid 有值) */ + instanceId?: number + /** 在合并边缘线几何体中的顶点范围 [startVertex, vertexCount](仅 InstancedMesh) */ + edgeVertexRange?: [number, number] + /** 在合并拓扑边线段中每条边的顶点范围 Map(仅 InstancedMesh) */ + topologyEdgeVertexRanges?: Map + features: GeometryFeature[] + visible: boolean + opacity: number + selected: boolean + /** 原始颜色 */ + color?: number + /** 原始序列化数据(URDF 导出用) */ + serializedData?: SerializedSolidData +} + +// ============ 选择相关 ============ + +/** 选中项信息 */ +export interface SelectionInfo { + feature: GeometryFeature + solid?: SolidObject + point: Vector3 + distance: number +} + + + +// ============ 视图控制相关 ============ + +/** 视图预设 */ +export enum ViewPreset { + FRONT = 'front', + BACK = 'back', + TOP = 'top', + BOTTOM = 'bottom', + LEFT = 'left', + RIGHT = 'right', + ISOMETRIC = 'isometric' +} + +/** 相机配置 */ +export interface CameraConfig { + position: Vector3 + target: Vector3 + up: Vector3 + fov: number + near: number + far: number +} + +/** 渲染配置 */ +export interface RenderConfig { + antialias: boolean + backgroundColor: number + ambientLightIntensity: number + directionalLightIntensity: number + enableShadows: boolean +} + +// ============ Face 几何数据(Worker 输出) ============ + +/** 面分组信息(面在索引缓冲中的范围) */ +export interface FaceGroupInfo { + /** 在索引数组中的起始位置 */ + start: number + /** 索引数量 */ + count: number + /** 面索引 */ + faceIndex: number +} + +/** 面的精确几何数据(由 BRepAdaptor_Surface 提取) */ +export interface FaceGeometryData { + type: string // FeatureType string + center?: number[] // [x, y, z] + normal?: number[] // [x, y, z] + radius?: number + axis?: number[] // [x, y, z] + height?: number + semiAngle?: number + majorRadius?: number + minorRadius?: number + uBounds?: number[] // [uMin, uMax] + vBounds?: number[] // [vMin, vMax] + startAngle?: number + endAngle?: number +} + +/** 边分组信息(边的折线顶点在合并数组中的范围) */ +export interface EdgeGroupInfo { + /** 边索引 */ + edgeIndex: number + /** 在 edgePolylines 数组中的起始位置(以 float 为单位,每 3 个 float 一个点) */ + polylineStart: number + /** 折线点数量 */ + polylineCount: number + /** 相邻面索引列表 */ + adjacentFaceIndices: number[] +} + +/** 边的精确几何数据(由 BRepAdaptor_Curve 提取) */ +export interface EdgeGeometryData { + /** 曲线类型: line / circle / ellipse / bspline / bezier / other */ + curveType: string + /** 边长度 */ + length: number + /** 起始点 [x, y, z] */ + startPoint: number[] + /** 终止点 [x, y, z] */ + endPoint: number[] + /** 圆弧/圆的半径 */ + radius?: number + /** 圆弧/圆的圆心 [x, y, z] */ + center?: number[] + /** 圆弧/圆的轴向 [x, y, z] */ + axis?: number[] + /** 起始参数角度 */ + startAngle?: number + /** 终止参数角度 */ + endAngle?: number +} + +// ============ Worker 序列化数据 ============ + +/** Worker 序列化的 Solid 数据(纯数据,不含 Three.js 对象) */ +export interface SerializedSolidData { + name: string + color?: number[] // [R, G, B] 0-1 范围 + positions: Float32Array + normals: Float32Array + indices: Uint32Array + /** 面分组信息 */ + faceGroups: FaceGroupInfo[] + /** 各面的精确几何数据 */ + faceGeometries: FaceGeometryData[] + /** 边分组信息 */ + edgeGroups: EdgeGroupInfo[] + /** 各边的精确几何数据 */ + edgeGeometries: EdgeGeometryData[] + /** 所有边的折线坐标合并数组 [x0,y0,z0, x1,y1,z1, ...] */ + edgePolylines: Float32Array +} + +/** Worker 序列化的结构树 */ +export interface SerializedTreeNode { + id: string + name: string + type: TreeNodeType + children?: SerializedTreeNode[] + solidIndex?: number + faceIndex?: number + edgeIndex?: number + color?: number[] +} + +/** Worker 请求消息 */ +export type WorkerRequest = + | { type: 'init' } + | { type: 'parse'; fileBuffer: ArrayBuffer } + +/** Worker 响应消息 */ +export type WorkerResponse = + | { type: 'ready' } + | { type: 'progress'; stage: string; percent: number } + | { type: 'result'; solids: SerializedSolidData[]; tree: SerializedTreeNode; success: boolean } + | { type: 'error'; message: string } + +// ============ 事件相关 ============ + +/** 查看器事件类型 */ +export type ViewerEventType = + | 'load' + | 'loadProgress' + | 'loadError' + | 'select' + | 'deselect' + | 'hover' + | 'measure' + | 'viewChange' + +/** 查看器事件处理器 */ +export interface ViewerEventHandlers { + onLoad?: () => void + onLoadProgress?: (progress: UploadProgress) => void + onLoadError?: (error: Error) => void + onSelect?: (selections: SelectionInfo[]) => void + onDeselect?: () => void + onHover?: (feature?: GeometryFeature) => void + onViewChange?: (camera: CameraConfig) => void +} + +// ============ 组件 Props ============ + +/** StepViewer 组件 Props */ +export interface StepViewerProps { + width?: number | string + height?: number | string + backgroundColor?: number + showAxes?: boolean + showGrid?: boolean + renderConfig?: Partial +} + +/** 工具栏配置 */ +export interface ToolbarConfig { + showUpload: boolean + showViewControls: boolean + showTransparency: boolean + showMeasurement: boolean + showReset: boolean +} + +// ============ URDF 构建相关 ============ + + + +/** 关节类型 */ +export type JointType = 'revolute' | 'prismatic' | 'fixed' + +/** 惯性参数 */ +export interface InertialParams { + mass: number + /** 质心 [x, y, z](米) */ + com: [number, number, number] + /** 惯性张量 [ixx, ixy, ixz, iyy, iyz, izz] */ + inertia: [number, number, number, number, number, number] +} + +/** URDF 原点 */ +export interface URDFOrigin { + xyz: [number, number, number] + rpy: [number, number, number] +} + +/** URDF Link */ +export interface URDFLink { + id: string + name: string + /** 绑定的 Solid ID 列表 */ + solidIds: string[] + /** 惯性参数(Worker 计算后填充) */ + inertial: InertialParams | null +} + +/** URDF Joint 限位 */ +export interface JointLimits { + lower: number + upper: number + effort: number + velocity: number +} + +/** URDF Joint */ +export interface URDFJoint { + id: string + name: string + type: JointType + parentLinkId: string + childLinkId: string + origin: URDFOrigin + axis: [number, number, number] + /** 轴偏移:在 origin.xyz 基础上的额外平移(用于调整轴线交汇点) */ + axisOffset: [number, number, number] + limits: JointLimits + /** 当前关节值(用于 FK 驱动) */ + currentValue: number +} + +/** URDF 机器人模型 */ +export interface URDFRobot { + name: string + links: URDFLink[] + joints: URDFJoint[] +} + +/** Joint 创建向导步骤 */ +export type JointWizardStep = 'select-links' | 'pick-edge' | 'adjust-origin' | 'set-type' + +/** Solid 绑定模式状态 */ +export interface BindingModeState { + active: boolean + targetLinkId: string | null +} + +/** 惯性计算 Worker 请求 */ +export interface InertiaWorkerRequest { + type: 'compute' + linkId: string + solidDataList: SerializedSolidData[] + density: number +} + +/** 惯性计算 Worker 响应 */ +export interface InertiaWorkerResponse { + type: 'result' + linkId: string + inertial: InertialParams +} + +// ============ 关节智能吸附相关 ============ + +/** 吸附数据(主线程捕获的几何特征) */ +export interface SnapData { + position: [number, number, number] + normal: [number, number, number] + featureType: 'circle' | 'arc' | 'line' +} + +/** 运动学 Worker 输入 */ +export interface KinematicsInput { + /** 父级世界矩阵 4x4 列主序 */ + parentWorldMatrix: Float32Array + /** 吸附点世界坐标 */ + snapPosition: Float32Array + /** 吸附法线/轴线方向 */ + snapNormal: Float32Array +} + +/** 运动学 Worker 输出 */ +export interface KinematicsResult { + xyz: [number, number, number] + rpy: [number, number, number] +} diff --git a/frontend/src/components/StepViewer/utils/applyHandtunedJson.ts b/frontend/src/components/StepViewer/utils/applyHandtunedJson.ts new file mode 100644 index 0000000..afb1a92 --- /dev/null +++ b/frontend/src/components/StepViewer/utils/applyHandtunedJson.ts @@ -0,0 +1,274 @@ +/** + * Apply a hand-tuned robot JSON (from UI screenshots / manual edit) + * into step2urdf's useURDFStore, then user can Export URDF. + * + * JSON joint origins are URDF-style (relative to parent, usually metres). + * step2urdf FK roots base_link at world identity and stores the first joint + * in STEP world millimetres (same as edge-pick). So for joints whose parent + * is base_link we bake: T_world = T_base * T_rel. + */ + +import * as THREE from 'three' +import type { JointType, URDFLink, URDFRobot } from '../types' +import { useURDFStore } from '../stores/useURDFStore' +import { useStepViewerStore } from '../stores/useStepViewerStore' +import type { ApplyResult } from './applyZhipuDraft' + +export interface HandtunedLink { + name: string + solid_names?: string[] +} + +export interface HandtunedJoint { + name: string + joint_type?: string + parent: string + child: string + origin_xyz: number[] + origin_rpy?: number[] + axis?: number[] + limits?: { + lower?: number + upper?: number + effort?: number + velocity?: number + } +} + +export interface HandtunedRobotJson { + robot_name?: string + unit_linear?: 'm' | 'mm' + base_link?: { + name?: string + solid_names?: string[] + origin_xyz?: number[] + origin_rpy?: number[] + up_axis?: string + } + links: HandtunedLink[] + joints: HandtunedJoint[] +} + +function asJointType(t: string | undefined): JointType { + const v = (t || 'revolute').toLowerCase() + if (v === 'prismatic') return 'prismatic' + if (v === 'fixed') return 'fixed' + return 'revolute' +} + +function v3(arr: number[] | undefined, scale = 1): [number, number, number] { + const a = arr || [0, 0, 0] + return [(Number(a[0]) || 0) * scale, (Number(a[1]) || 0) * scale, (Number(a[2]) || 0) * scale] +} + +function axisVec(arr: number[] | undefined): [number, number, number] { + const v = v3(arr ?? [0, 0, 1], 1) + const n = Math.hypot(v[0], v[1], v[2]) || 1 + return [v[0] / n, v[1] / n, v[2] / n] +} + +/** Compose T_base * T_rel → origin xyz/rpy in STEP world (mm). */ +function bakeBaseRelative( + relXyzMm: [number, number, number], + relRpy: [number, number, number], + baseXyzMm: [number, number, number], + baseRpy: [number, number, number] +): { xyz: [number, number, number]; rpy: [number, number, number] } { + const Tbase = new THREE.Matrix4() + .makeTranslation(baseXyzMm[0], baseXyzMm[1], baseXyzMm[2]) + .multiply( + new THREE.Matrix4().makeRotationFromEuler( + new THREE.Euler(baseRpy[0], baseRpy[1], baseRpy[2], 'ZYX') + ) + ) + const Trel = new THREE.Matrix4() + .makeTranslation(relXyzMm[0], relXyzMm[1], relXyzMm[2]) + .multiply( + new THREE.Matrix4().makeRotationFromEuler( + new THREE.Euler(relRpy[0], relRpy[1], relRpy[2], 'ZYX') + ) + ) + const Tw = new THREE.Matrix4().multiplyMatrices(Tbase, Trel) + const p = new THREE.Vector3().setFromMatrixPosition(Tw) + const e = new THREE.Euler().setFromRotationMatrix(Tw, 'ZYX') + return { + xyz: [p.x, p.y, p.z], + rpy: [e.x, e.y, e.z] + } +} + +function findSolidId( + partName: string, + solids: { id: string; name: string }[], + used: Set +): string | null { + const exact = solids.find(s => s.name === partName && !used.has(s.id)) + if (exact) return exact.id + const lower = partName.toLowerCase() + const ci = solids.find(s => s.name.toLowerCase() === lower && !used.has(s.id)) + if (ci) return ci.id + const byId = solids.find(s => s.id === partName && !used.has(s.id)) + if (byId) return byId.id + return ( + solids.find( + s => !used.has(s.id) && (s.name.includes(partName) || partName.includes(s.name)) + )?.id ?? null + ) +} + +/** + * Import handtuned JSON. Linear unit defaults to metres (as in the property panel); + * converts to millimetres for the step2urdf store. + */ +export function applyHandtunedJson(data: HandtunedRobotJson): ApplyResult { + const urdf = useURDFStore() + const viewer = useStepViewerStore() + const warnings: string[] = [] + const unboundParts: string[] = [] + const skippedJoints: string[] = [] + + if (!data.links?.length) { + return { + ok: false, + linksCreated: 0, + jointsCreated: 0, + solidsBound: 0, + unboundParts: [], + skippedJoints: [], + warnings: ['JSON 中没有 links'] + } + } + + const scale = (data.unit_linear || 'm') === 'm' ? 1000 : 1 + const solidsMeta = viewer.solids.map(s => ({ id: s.id, name: s.name })) + const usedSolidIds = new Set() + + const bind = (names: string[] | undefined): string[] => { + const ids: string[] = [] + for (const pn of names || []) { + const sid = findSolidId(pn, solidsMeta, usedSolidIds) + if (sid) { + usedSolidIds.add(sid) + ids.push(sid) + } else { + unboundParts.push(pn) + } + } + return ids + } + + // Prefer explicit links[]; ensure base_link first + let linksIn = [...data.links] + if (data.base_link?.solid_names?.length) { + const bi = linksIn.findIndex(l => l.name === 'base_link' || l.name === data.base_link?.name) + if (bi >= 0) { + linksIn[bi] = { + ...linksIn[bi], + solid_names: data.base_link.solid_names + } + } else { + linksIn = [{ name: 'base_link', solid_names: data.base_link.solid_names }, ...linksIn] + } + } + + const baseId = urdf.BASE_LINK_ID + const newLinks: URDFLink[] = [] + const nameToId = new Map() + + const baseDef = linksIn.find(l => l.name === 'base_link') || { + name: 'base_link', + solid_names: data.base_link?.solid_names || [] + } + const others = linksIn.filter(l => l.name !== 'base_link') + + newLinks.push({ + id: baseId, + name: 'base_link', + solidIds: bind(baseDef.solid_names), + inertial: null + }) + nameToId.set('base_link', baseId) + + let seq = 1 + for (const ld of others) { + const id = `link_${seq++}` + newLinks.push({ + id, + name: ld.name, + solidIds: bind(ld.solid_names), + inertial: null + }) + nameToId.set(ld.name, id) + } + + const baseXyzMm = v3(data.base_link?.origin_xyz, scale) + const baseRpy = v3(data.base_link?.origin_rpy ?? [0, 0, 0], 1) + + const newJoints: URDFRobot['joints'] = [] + let jseq = 1 + for (const jd of data.joints || []) { + const parentId = nameToId.get(jd.parent) + const childId = nameToId.get(jd.child) + if (!parentId || !childId) { + skippedJoints.push(`${jd.name}: missing ${!parentId ? jd.parent : jd.child}`) + continue + } + if (childId === baseId || newJoints.some(j => j.childLinkId === childId)) { + skippedJoints.push(`${jd.name}: invalid child`) + continue + } + const type = asJointType(jd.joint_type) + const lim = jd.limits || {} + let xyz = v3(jd.origin_xyz, scale) + let rpy = v3(jd.origin_rpy, 1) + // JSON 里是相对 parent;页面 FK 的 base_link 在世界原点, + // 挂在 base 下的关节需写成 STEP 世界坐标(与拾取边一致,导出时再扣回 base)。 + if (parentId === baseId && data.base_link?.origin_xyz) { + const baked = bakeBaseRelative(xyz, rpy, baseXyzMm, baseRpy) + xyz = baked.xyz + rpy = baked.rpy + } + newJoints.push({ + id: `joint_${jseq++}`, + name: jd.name, + type, + parentLinkId: parentId, + childLinkId: childId, + origin: { xyz, rpy }, + axis: axisVec(jd.axis), + axisOffset: [0, 0, 0], + limits: { + lower: lim.lower ?? -3.14159, + upper: lim.upper ?? 3.14159, + effort: lim.effort ?? 10, + velocity: lim.velocity ?? 1 + }, + currentValue: 0 + }) + } + + urdf.importRobot({ + name: data.robot_name || 'handtuned_arm', + links: newLinks, + joints: newJoints + }) + + if (data.base_link?.origin_xyz) { + urdf.baseLinkOrigin = baseXyzMm + } + urdf.baseLinkRPY = baseRpy + + warnings.push( + `已导入手调 JSON(${data.unit_linear || 'm'}→mm,base 子关节已烘焙到世界坐标)。可直接导出 URDF。` + ) + + return { + ok: true, + linksCreated: newLinks.length, + jointsCreated: newJoints.length, + solidsBound: usedSolidIds.size, + unboundParts: [...new Set(unboundParts)], + skippedJoints, + warnings + } +} diff --git a/frontend/src/components/StepViewer/utils/applyZhipuDraft.ts b/frontend/src/components/StepViewer/utils/applyZhipuDraft.ts new file mode 100644 index 0000000..9fafeeb --- /dev/null +++ b/frontend/src/components/StepViewer/utils/applyZhipuDraft.ts @@ -0,0 +1,448 @@ +/** + * Apply /api/propose RobotDraft into step2urdf's useURDFStore. + * + * A7 / ARM7 gold drafts: bind Solid_N along the model's longest axis and place + * joint frames along that axis (CAD may be Y-up). + * Generic LLM drafts: apply origins/axes as returned (m→mm heuristic). + */ + +import type { JointType, SolidObject, URDFLink, URDFRobot } from '../types' +import { useURDFStore } from '../stores/useURDFStore' +import { useStepViewerStore } from '../stores/useStepViewerStore' + +export interface ProposeLink { + name: string + part_names?: string[] +} + +export interface ProposeJoint { + name: string + joint_type?: string + parent: string + child: string + origin_xyz?: number[] + origin_rpy?: number[] + axis?: number[] + lower?: number + upper?: number + effort?: number + velocity?: number + rationale?: string +} + +export interface ProposeDraft { + name?: string + robot_name?: string + profile?: string + links?: ProposeLink[] + joints?: ProposeJoint[] + notes?: string[] +} + +export interface ApplyResult { + ok: boolean + linksCreated: number + jointsCreated: number + solidsBound: number + unboundParts: string[] + skippedJoints: string[] + warnings: string[] +} + +const A7_GOLD_CUMZ_MM = [0, 9, 68.5, 191.5, 378.6, 502.6, 590.7, 656.2, 701.2] +const A7_GOLD_SPAN_MM = 701.2 + +function asJointType(t: string | undefined): JointType { + const v = (t || 'revolute').toLowerCase() + if (v === 'prismatic') return 'prismatic' + if (v === 'fixed') return 'fixed' + return 'revolute' +} + +function xyz(arr: number[] | undefined): [number, number, number] { + const a = arr || [0, 0, 0] + return [Number(a[0]) || 0, Number(a[1]) || 0, Number(a[2]) || 0] +} + +function rpy(arr: number[] | undefined): [number, number, number] { + return xyz(arr) +} + +function axisVec(arr: number[] | undefined): [number, number, number] { + const v = xyz(arr ?? [0, 0, 1]) + const n = Math.hypot(v[0], v[1], v[2]) || 1 + return [v[0] / n, v[1] / n, v[2] / n] +} + +function isGenericSolidName(n: string): boolean { + return /^solid[_\s-]?\d+$/i.test(n.trim()) +} + +function isGoldDraft(draft: ProposeDraft): boolean { + if ((draft.profile || '').toLowerCase() === 'a7') return true + const names = (draft.joints || []).map(j => j.name || '') + return names.some(n => /^A1_joint$/i.test(n)) && names.some(n => /^A7_joint$/i.test(n)) +} + +function findSolidId( + partName: string, + solids: { id: string; name: string }[], + used: Set +): string | null { + const exact = solids.find(s => s.name === partName && !used.has(s.id)) + if (exact) return exact.id + const lower = partName.toLowerCase() + const ci = solids.find(s => s.name.toLowerCase() === lower && !used.has(s.id)) + if (ci) return ci.id + const byId = solids.find(s => s.id === partName && !used.has(s.id)) + if (byId) return byId.id + return ( + solids.find( + s => !used.has(s.id) && (s.name.includes(partName) || partName.includes(s.name)) + )?.id ?? null + ) +} + +function solidBounds(solid: SolidObject): { + min: [number, number, number] + max: [number, number, number] + center: [number, number, number] +} | null { + const bb = solid.boundingBox + if (bb?.min && bb?.max) { + return { + min: [bb.min.x, bb.min.y, bb.min.z], + max: [bb.max.x, bb.max.y, bb.max.z], + center: [ + (bb.min.x + bb.max.x) / 2, + (bb.min.y + bb.max.y) / 2, + (bb.min.z + bb.max.z) / 2 + ] + } + } + const pos = solid.serializedData?.positions + if (!pos || pos.length < 3) return null + let xMin = Infinity, yMin = Infinity, zMin = Infinity + let xMax = -Infinity, yMax = -Infinity, zMax = -Infinity + for (let i = 0; i < pos.length; i += 3) { + const x = pos[i], y = pos[i + 1], z = pos[i + 2] + if (x < xMin) xMin = x + if (y < yMin) yMin = y + if (z < zMin) zMin = z + if (x > xMax) xMax = x + if (y > yMax) yMax = y + if (z > zMax) zMax = z + } + if (!Number.isFinite(zMin)) return null + return { + min: [xMin, yMin, zMin], + max: [xMax, yMax, zMax], + center: [(xMin + xMax) / 2, (yMin + yMax) / 2, (zMin + zMax) / 2] + } +} + +function modelAabb(solids: SolidObject[]) { + let xMin = Infinity, yMin = Infinity, zMin = Infinity + let xMax = -Infinity, yMax = -Infinity, zMax = -Infinity + let any = false + for (const s of solids) { + const b = solidBounds(s) + if (!b) continue + any = true + xMin = Math.min(xMin, b.min[0]); yMin = Math.min(yMin, b.min[1]); zMin = Math.min(zMin, b.min[2]) + xMax = Math.max(xMax, b.max[0]); yMax = Math.max(yMax, b.max[1]); zMax = Math.max(zMax, b.max[2]) + } + if (!any) return null + return { min: [xMin, yMin, zMin] as [number, number, number], max: [xMax, yMax, zMax] as [number, number, number] } +} + +function principalAxis(aabb: NonNullable>) { + const spans: [number, number, number] = [ + aabb.max[0] - aabb.min[0], + aabb.max[1] - aabb.min[1], + aabb.max[2] - aabb.min[2] + ] + let index: 0 | 1 | 2 = 0 + if (spans[1] >= spans[0] && spans[1] >= spans[2]) index = 1 + else if (spans[2] >= spans[0] && spans[2] >= spans[1]) index = 2 + const dir: [number, number, number] = [0, 0, 0] + dir[index] = 1 + return { index, min: aabb.min[index], max: aabb.max[index], span: Math.max(spans[index], 1e-6), dir } +} + +function assignAlongAxis( + solids: SolidObject[], + linkNames: string[], + axis: ReturnType +) { + const assigned = new Map() + linkNames.forEach(n => assigned.set(n, [])) + const n = linkNames.length + const edges = A7_GOLD_CUMZ_MM.map(z => axis.min + (z / A7_GOLD_SPAN_MM) * axis.span) + edges.push(axis.max + 1) + for (const s of solids) { + const b = solidBounds(s) + const t = b?.center[axis.index] ?? axis.min + let idx = n - 1 + for (let i = 0; i < n; i++) { + if (t < edges[i + 1]) { + idx = i + break + } + } + assigned.get(linkNames[idx])!.push(s.id) + } + return assigned +} + +function linkWorldPoint( + solidIds: string[], + solidById: Map, + axis: ReturnType, + aabb: NonNullable>, + goldT: number +): [number, number, number] { + const p: [number, number, number] = [ + (aabb.min[0] + aabb.max[0]) / 2, + (aabb.min[1] + aabb.max[1]) / 2, + (aabb.min[2] + aabb.max[2]) / 2 + ] + p[axis.index] = axis.min + goldT * axis.span + let xMin = Infinity, yMin = Infinity, zMin = Infinity + let xMax = -Infinity, yMax = -Infinity, zMax = -Infinity + let found = false + for (const id of solidIds) { + const s = solidById.get(id) + const b = s ? solidBounds(s) : null + if (!b) continue + found = true + xMin = Math.min(xMin, b.min[0]); yMin = Math.min(yMin, b.min[1]); zMin = Math.min(zMin, b.min[2]) + xMax = Math.max(xMax, b.max[0]); yMax = Math.max(yMax, b.max[1]); zMax = Math.max(zMax, b.max[2]) + } + if (found) { + p[0] = (xMin + xMax) / 2 + p[1] = (yMin + yMax) / 2 + p[2] = (zMin + zMax) / 2 + const mins: [number, number, number] = [xMin, yMin, zMin] + p[axis.index] = mins[axis.index] + } + return p +} + +function mapGoldAxisToUp( + goldAxis: [number, number, number], + up: [number, number, number] +): [number, number, number] { + const ul = Math.hypot(up[0], up[1], up[2]) || 1 + const u = [up[0] / ul, up[1] / ul, up[2] / ul] + const c = u[2] + if (c > 0.999) return goldAxis + let ax = -u[1], ay = u[0], az = 0 + if (c < -0.999) { + ax = 1; ay = 0; az = 0 + } + const al = Math.hypot(ax, ay, az) || 1 + ax /= al; ay /= al; az /= al + const k = goldAxis + const kxa = [ay * k[2] - az * k[1], az * k[0] - ax * k[2], ax * k[1] - ay * k[0]] + const adot = ax * k[0] + ay * k[1] + az * k[2] + const s = Math.sqrt(Math.max(0, 1 - c * c)) + return axisVec([ + k[0] * c + kxa[0] * s + ax * adot * (1 - c), + k[1] * c + kxa[1] * s + ay * adot * (1 - c), + k[2] * c + kxa[2] * s + az * adot * (1 - c) + ]) +} + +function maybeMetersToMm(joints: ProposeJoint[]): boolean { + const vals: number[] = [] + for (const j of joints) { + for (const n of j.origin_xyz || []) { + if (Number.isFinite(n)) vals.push(Math.abs(Number(n))) + } + } + if (!vals.length) return false + return Math.max(...vals) > 0 && Math.max(...vals) < 8 +} + +export function collectSolidsGeom(solids: SolidObject[]): Record[] { + return solids.map(s => { + const bb = s.boundingBox + const min = bb?.min ? [bb.min.x, bb.min.y, bb.min.z] : null + const max = bb?.max ? [bb.max.x, bb.max.y, bb.max.z] : null + const center = + min && max + ? [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] + : null + return { id: s.id, name: s.name, min, max, center, unit: 'mm' } + }) +} + +export function applyProposeDraft(draft: ProposeDraft): ApplyResult { + const urdf = useURDFStore() + const viewer = useStepViewerStore() + const warnings: string[] = [] + const unboundParts: string[] = [] + const skippedJoints: string[] = [] + + const linksIn = draft.links || [] + const jointsIn = draft.joints || [] + if (!linksIn.length) { + return { + ok: false, + linksCreated: 0, + jointsCreated: 0, + solidsBound: 0, + unboundParts: [], + skippedJoints: [], + warnings: ['返回中没有 links'] + } + } + + const gold = isGoldDraft(draft) + const robotName = draft.name || draft.robot_name || 'robot' + const allSolids = viewer.solids + const solidsMeta = allSolids.map(s => ({ id: s.id, name: s.name })) + const usedSolidIds = new Set() + const genericCad = allSolids.length > 0 && allSolids.every(s => isGenericSolidName(s.name)) + + let baseDef = linksIn.find(l => l.name === 'base_link') + const otherLinks = linksIn.filter(l => l.name !== 'base_link') + if (!baseDef) { + baseDef = { name: 'base_link', part_names: [] } + warnings.push('草稿无 base_link,已自动补根') + } + + const newLinks: URDFLink[] = [] + const nameToId = new Map() + const baseId = urdf.BASE_LINK_ID + + const bindParts = (partNames: string[]): string[] => { + const ids: string[] = [] + for (const pn of partNames || []) { + const sid = findSolidId(pn, solidsMeta, usedSolidIds) + if (sid) { + usedSolidIds.add(sid) + ids.push(sid) + } else unboundParts.push(pn) + } + return ids + } + + newLinks.push({ + id: baseId, + name: 'base_link', + solidIds: genericCad && gold ? [] : bindParts(baseDef.part_names || []), + inertial: null + }) + nameToId.set('base_link', baseId) + + let linkSeq = 1 + for (const ld of otherLinks) { + const id = `link_${linkSeq++}` + newLinks.push({ + id, + name: ld.name, + solidIds: genericCad && gold ? [] : bindParts(ld.part_names || []), + inertial: null + }) + nameToId.set(ld.name, id) + } + + const aabb = modelAabb(allSolids) + const axis = aabb ? principalAxis(aabb) : null + const solidById = new Map(allSolids.map(s => [s.id, s])) + + if (gold && axis) { + for (const link of newLinks) link.solidIds = [] + usedSolidIds.clear() + const clustered = assignAlongAxis(allSolids, newLinks.map(l => l.name), axis) + for (const link of newLinks) { + for (const sid of clustered.get(link.name) || []) { + usedSolidIds.add(sid) + link.solidIds.push(sid) + } + } + warnings.push(`金标模式:已沿最长轴 ${['X', 'Y', 'Z'][axis.index]} 绑定 Solid。`) + } + + const scaleMm = !gold && maybeMetersToMm(jointsIn) ? 1000 : 1 + if (scaleMm === 1000) warnings.push('LLM origin 像米制,已 ×1000 → mm。') + + const newJoints: URDFRobot['joints'] = [] + let jointSeq = 1 + const goldTs = A7_GOLD_CUMZ_MM.map(z => z / A7_GOLD_SPAN_MM) + let prevWorld: [number, number, number] | null = null + let ji = 0 + + for (const jd of jointsIn) { + const parentId = nameToId.get(jd.parent) + const childId = nameToId.get(jd.child) + if (!parentId || !childId) { + skippedJoints.push(`${jd.name}: missing link`) + continue + } + if (childId === baseId || newJoints.some(j => j.childLinkId === childId)) { + skippedJoints.push(`${jd.name}: invalid child`) + continue + } + const type = asJointType(jd.joint_type) + let originXyz = xyz(jd.origin_xyz) + let jointAxis = axisVec(jd.axis) + + if (gold && aabb && axis) { + const childLink = newLinks.find(l => l.id === childId) + const goldT = goldTs[Math.min(ji + 1, goldTs.length - 1)] + const world = linkWorldPoint(childLink?.solidIds || [], solidById, axis, aabb, goldT) + originXyz = prevWorld + ? [world[0] - prevWorld[0], world[1] - prevWorld[1], world[2] - prevWorld[2]] + : world + prevWorld = world + jointAxis = mapGoldAxisToUp(axisVec(jd.axis), axis.dir) + } else { + originXyz = [originXyz[0] * scaleMm, originXyz[1] * scaleMm, originXyz[2] * scaleMm] + } + ji++ + + newJoints.push({ + id: `joint_${jointSeq++}`, + name: jd.name || `Joint_${jointSeq - 1}`, + type, + parentLinkId: parentId, + childLinkId: childId, + origin: { xyz: originXyz, rpy: rpy(jd.origin_rpy) }, + axis: jointAxis, + axisOffset: [0, 0, 0], + limits: { + lower: jd.lower ?? (type === 'prismatic' ? -100 : -3.14159), + upper: jd.upper ?? (type === 'prismatic' ? 100 : 3.14159), + effort: jd.effort ?? 100, + velocity: jd.velocity ?? 1 + }, + currentValue: 0 + }) + } + + urdf.importRobot({ name: robotName, links: newLinks, joints: newJoints }) + if (gold && aabb && axis) { + const base: [number, number, number] = [ + (aabb.min[0] + aabb.max[0]) / 2, + (aabb.min[1] + aabb.max[1]) / 2, + (aabb.min[2] + aabb.max[2]) / 2 + ] + base[axis.index] = axis.min + urdf.baseLinkOrigin = base + urdf.baseLinkRPY = [0, 0, 0] + warnings.push('已按 ARM7 金标轴写入,并沿手臂摆放。') + } + + return { + ok: true, + linksCreated: newLinks.length, + jointsCreated: newJoints.length, + solidsBound: usedSolidIds.size, + unboundParts: [...new Set(unboundParts)], + skippedJoints, + warnings + } +} diff --git a/frontend/src/components/SvgIcon/index.vue b/frontend/src/components/SvgIcon/index.vue new file mode 100644 index 0000000..30ff7f4 --- /dev/null +++ b/frontend/src/components/SvgIcon/index.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/icons/IconCommunity.vue b/frontend/src/components/icons/IconCommunity.vue new file mode 100644 index 0000000..2dc8b05 --- /dev/null +++ b/frontend/src/components/icons/IconCommunity.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconDocumentation.vue b/frontend/src/components/icons/IconDocumentation.vue new file mode 100644 index 0000000..6d4791c --- /dev/null +++ b/frontend/src/components/icons/IconDocumentation.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconEcosystem.vue b/frontend/src/components/icons/IconEcosystem.vue new file mode 100644 index 0000000..c3a4f07 --- /dev/null +++ b/frontend/src/components/icons/IconEcosystem.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconSupport.vue b/frontend/src/components/icons/IconSupport.vue new file mode 100644 index 0000000..7452834 --- /dev/null +++ b/frontend/src/components/icons/IconSupport.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconTooling.vue b/frontend/src/components/icons/IconTooling.vue new file mode 100644 index 0000000..660598d --- /dev/null +++ b/frontend/src/components/icons/IconTooling.vue @@ -0,0 +1,19 @@ + + diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts new file mode 100644 index 0000000..b45def9 --- /dev/null +++ b/frontend/src/config/index.ts @@ -0,0 +1,19 @@ +// ? 全局默认配置项 + +// 首页地址(默认) +export const HOME_URL: string = "/dialogue"; + +// 登录页地址(默认) +export const LOGIN_URL: string = "/login"; + +// 默认主题颜色 +export const DEFAULT_PRIMARY: string = "#009688"; + +// 路由白名单地址(本地存在的路由 staticRouter.ts 中) +export const ROUTER_WHITE_LIST: string[] = ["/500"]; + +// 高德地图 key +export const AMAP_MAP_KEY: string = ""; + +// 百度地图 key +export const BAIDU_MAP_KEY: string = ""; diff --git a/frontend/src/config/nprogress.ts b/frontend/src/config/nprogress.ts new file mode 100644 index 0000000..e04600c --- /dev/null +++ b/frontend/src/config/nprogress.ts @@ -0,0 +1,12 @@ +import NProgress from "nprogress"; +import "nprogress/nprogress.css"; + +NProgress.configure({ + easing: "ease", // 动画方式 + speed: 500, // 递增进度条的速度 + showSpinner: false, // 是否显示加载ico + trickleSpeed: 200, // 自动递增间隔 + minimum: 0.3 // 初始化时的最小百分比 +}); + +export default NProgress; diff --git a/frontend/src/enums/httpEnum.ts b/frontend/src/enums/httpEnum.ts new file mode 100644 index 0000000..b675ef0 --- /dev/null +++ b/frontend/src/enums/httpEnum.ts @@ -0,0 +1,35 @@ +/** + * @description:请求配置 + */ +export enum ResultEnum { + SUCCESS = 200, + ERROR = 500, + OVERDUE = 401, + TIMEOUT = 9999999999, + TYPE = "success" +} + +/** + * @description:请求方法 + */ +export enum RequestEnum { + GET = "GET", + POST = "POST", + PATCH = "PATCH", + PUT = "PUT", + DELETE = "DELETE" +} + +/** + * @description:常用的 contentTyp 类型 + */ +export enum ContentTypeEnum { + // json + JSON = "application/json;charset=UTF-8", + // text + TEXT = "text/plain;charset=UTF-8", + // form-data 一般配合qs + FORM_URLENCODED = "application/x-www-form-urlencoded;charset=UTF-8", + // form-data 上传 + FORM_DATA = "multipart/form-data;charset=UTF-8" +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..eca6d4e --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,17 @@ +// import "./assets/main.css"; +import "@/styles/reset.scss"; +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import ElementPlus from "element-plus"; +import "@/styles/index.css" +import "element-plus/dist/index.css"; +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import App from "./App.vue"; +import router from "./router"; +import "@/utils/rem"; +const app = createApp(App); +import "virtual:svg-icons-register"; +app.use(createPinia()); +app.use(router); +app.use(ElementPlus,{locale: zhCn}); +app.mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..792a730 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,32 @@ +import NProgress from "@/config/nprogress"; +import { createRouter, createWebHistory } from "vue-router"; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { path: "/", name: "root", component: () => import("@/views/home.vue") }, + + ] +}); +/** + * @description 路由拦截 beforeEach + * */ +router.beforeEach(async (to, from, next) => { + NProgress.start(); + next(); +}); +/** + * @description 路由跳转错误 + * */ +router.onError(error => { + NProgress.done(); + console.warn("路由错误", error.message); +}); + +/** + * @description 路由跳转结束 + * */ +router.afterEach(() => { + NProgress.done(); +}); +export default router; diff --git a/frontend/src/stores/counter.ts b/frontend/src/stores/counter.ts new file mode 100644 index 0000000..b6757ba --- /dev/null +++ b/frontend/src/stores/counter.ts @@ -0,0 +1,12 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + const doubleCount = computed(() => count.value * 2) + function increment() { + count.value++ + } + + return { count, doubleCount, increment } +}) diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/frontend/src/styles/reset.scss b/frontend/src/styles/reset.scss new file mode 100644 index 0000000..f273942 --- /dev/null +++ b/frontend/src/styles/reset.scss @@ -0,0 +1,143 @@ +/* Reset style sheet */ + +/* 目前项目中使用富文本编辑器需要注释,如果你项目中没有使用富文本编辑器,可以取消注释 */ +// html, +// body, +// div, +// span, +// applet, +// object, +// iframe, +// h1, +// h2, +// h3, +// h4, +// h5, +// h6, +// p, +// blockquote, +// pre, +// a, +// abbr, +// acronym, +// address, +// big, +// cite, +// code, +// del, +// dfn, +// em, +// img, +// ins, +// kbd, +// q, +// s, +// samp, +// small, +// strike, +// strong, +// sub, +// sup, +// tt, +// var, +// b, +// u, +// i, +// center, +// dl, +// dt, +// dd, +// ol, +// ul, +// li, +// fieldset, +// form, +// label, +// legend, +// table, +// caption, +// tbody, +// tfoot, +// thead, +// tr, +// th, +// td, +// article, +// aside, +// canvas, +// details, +// embed, +// figure, +// figcaption, +// footer, +// header, +// hgroup, +// menu, +// nav, +// output, +// ruby, +// section, +// summary, +// time, +// mark, +// audio, +// video { +// padding: 0; +// margin: 0; +// font: inherit; +// font-size: 100%; +// vertical-align: baseline; +// border: 0; +// } + +// /* HTML5 display-role reset for older browsers */ +// article, +// aside, +// details, +// figcaption, +// figure, +// footer, +// header, +// hgroup, +// menu, +// nav, +// section { +// display: block; +// } +// body { +// padding: 0; +// margin: 0; +// } +// ol, +// ul { +// list-style: none; +// } +// blockquote, +// q { +// quotes: none; +// } +// blockquote::before, +// blockquote::after, +// q::before, +// q::after { +// content: ""; +// content: none; +// } +// table { +// border-spacing: 0; +// border-collapse: collapse; +// } + +html, +body, +#app { + width: 100vw; + height: 100vh; + padding: 0; + margin: 0; +} + +/* 解决 h1 标签在 webkit 内核浏览器中文字大小失效问题 */ +:-webkit-any(article, aside, nav, section) h1 { + font-size: 2em; +} diff --git a/frontend/src/utils/rem.ts b/frontend/src/utils/rem.ts new file mode 100644 index 0000000..4fc59ab --- /dev/null +++ b/frontend/src/utils/rem.ts @@ -0,0 +1,22 @@ +//基准大小 +// const baseSize = 16; +// 设置 rem 函数 +function setRem() { + let fontSize = 0; + let clientWidth = document.documentElement.clientWidth; + let clientHeight = document.documentElement.clientHeight; + if (clientWidth / clientHeight >= 1.78) { + fontSize = 10 * (clientHeight / 1080); + } else if (clientWidth / clientHeight < 1.78) { + fontSize = 10 * (clientWidth / 1920); + } + // 当前页面宽度相对于 750 宽的缩放比例,可根据自己需要修改. + // 设置页面根节点字体大小 + document.documentElement.style.fontSize = fontSize + "px"; +} +// 初始化 +setRem(); +// 改变窗口大小时重新设置 rem +window.onresize = function () { + setRem(); +}; diff --git a/frontend/src/views/home.vue b/frontend/src/views/home.vue new file mode 100644 index 0000000..7142e91 --- /dev/null +++ b/frontend/src/views/home.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..bc9e5b4 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +export default { + purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + content: [], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..360afb3 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "types": ["vite/client", "element-plus/global"], + + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "noEmit": true, + "baseUrl": "./", + "paths": { + "@": ["src"], + "@/*": ["src/*"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue", + "types/**/*.d.ts", + "build/**/*.ts", + "build/**/*.d.ts", + "vite.config.ts" + ], + "exclude": ["node_modules", "dist", "**/*.js"] +} diff --git a/frontend/types/env.d.ts b/frontend/types/env.d.ts new file mode 100644 index 0000000..c71587c --- /dev/null +++ b/frontend/types/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5f2e393 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,85 @@ +import { fileURLToPath, URL } from "node:url"; + +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { resolve } from "path"; +import { createSvgIconsPlugin } from "vite-plugin-svg-icons"; +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue(), createSvgIconsPlugin({ + iconDirs: [resolve(process.cwd(), "src/assets/svgs")], + symbolId: "icon-[dir]-[name]" + })], + // 包含 wasm 文件作为静态资源 + assetsInclude: ['**/*.wasm'], + // opencascade.js 不能被 Vite 预构建优化 + // jszip/comlink: ExportWorker 懒加载;若不预构建,首次点「导出 URDF」会触发 + // Vite “new dependencies optimized → reloading”,整页刷新导致模型丢失、ZIP 下不来。 + optimizeDeps: { + exclude: ['opencascade.js'], + include: ['jszip', 'comlink'] + }, + // Worker 构建配置 + worker: { + format: 'es', // 使用 ES module 格式,支持 Worker 内 dynamic import + rollupOptions: { + output: { + // Worker 输出格式 + format: 'es' + } + } + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)) + } + }, + server: { + host: "0.0.0.0", + port: 5678, + open: true, + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp' + }, + proxy: { + // Proxy LLM / helper API to local FastAPI (Zhipu key stays server-side) + "/api": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + } + } + }, + css: { + preprocessorOptions: { + scss: { + api: 'modern', + } + } + }, + build: { + outDir: "dist", + minify: "esbuild", + // esbuild 打包更快,但是不能去除 console.log,terser打包慢,但能去除 console.log + // minify: "terser", + // terserOptions: { + // compress: { + // drop_console: viteEnv.VITE_DROP_CONSOLE, + // drop_debugger: true + // } + // }, + sourcemap: false, + // 禁用 gzip 压缩大小报告,可略微减少打包时间 + reportCompressedSize: false, + // 规定触发警告的 chunk 大小 + chunkSizeWarningLimit: 2000, + rollupOptions: { + output: { + // Static resource classification and packaging + chunkFileNames: "assets/js/[name]-[hash].js", + entryFileNames: "assets/js/[name]-[hash].js", + assetFileNames: "assets/[ext]/[name]-[hash].[ext]" + } + } + } +}); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..de5e1d6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +python-multipart>=0.0.12 +pydantic>=2.9.0 +pydantic-settings>=2.6.0 +httpx>=0.27.0 +jinja2>=3.1.4 +aiofiles>=24.1.0 +numpy>=1.26.0 +# Optional geometry backends (install what you can): +# cadquery>=2.4.0 +# cascadio>=0.0.13