Files
FreeCADWasmDev/web/src/scene-command-host.ts
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

155 lines
9.0 KiB
TypeScript

import * as THREE from "three";
import type { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { createElement, ZoomIn, ZoomOut, Maximize, RotateCcw, RotateCw, Save, History, type IconNode } from "lucide";
import { registerSceneCommandHost, type SceneDisplayState } from "./scene-command-adapter.js";
import "./scene-command-host.css";
import { attachOverlapPicker, type PickableSceneObject } from "./scene-overlap-picker.js";
import { attachCameraCommands } from "./scene-camera-commands.js";
export function attachSceneCommands(scene: THREE.Scene, controls: OrbitControls, container: HTMLElement,
documentId: () => string, invalidate: () => void,
picking?: { canvas: HTMLCanvasElement; objects(): PickableSceneObject[]; select(id: string): void },
onControlsChanged?: (next: OrbitControls) => void): { dispose(): void } {
const picker = picking ? attachOverlapPicker(picking.canvas, container, controls.object,
documentId, picking.objects, picking.select) : undefined;
const cross = new THREE.AxesHelper(10);
cross.visible = false;
scene.add(cross);
const toolbar = document.createElement("div");
toolbar.className = "scene-display-tools";
toolbar.setAttribute("aria-label", "3D display controls");
const axes = document.createElement("input");
axes.type = "checkbox"; axes.setAttribute("aria-label", "Axis cross");
const play = document.createElement("input");
play.type = "checkbox"; play.setAttribute("aria-label", "Turntable");
const speed = document.createElement("input");
speed.type = "range"; speed.min = "-10"; speed.max = "10"; speed.step = "0.25"; speed.value = "2";
speed.setAttribute("aria-label", "Turntable speed");
const label = (text: string, control: HTMLElement) => {
const element = document.createElement("label");
element.append(control, document.createTextNode(text));
toolbar.append(element);
};
label("轴十字", axes); label("转台", play); label("速度", speed);
const stop = document.createElement("button");
stop.type = "button"; stop.textContent = "停止"; stop.title = "停止转台";
toolbar.append(stop);
if (picker) {
const choose = document.createElement("button");
choose.type = "button"; choose.textContent = "选择重叠对象"; choose.title = "选择重叠对象";
choose.onclick = () => picker.show();
toolbar.append(choose);
}
const oldPosition = container.style.position;
if (getComputedStyle(container).position === "static") container.style.position = "relative";
container.append(toolbar);
const states = new Map<string, SceneDisplayState>();
const state = () => states.get(documentId()) ?? { axes: false, rotating: false, speed: 2 };
const apply = (next: SceneDisplayState) => {
states.set(documentId(), next);
cross.visible = next.axes;
controls.autoRotate = next.rotating;
controls.autoRotateSpeed = next.speed;
axes.checked = next.axes; play.checked = next.rotating; speed.value = String(next.speed);
invalidate();
return { ...next };
};
axes.onchange = () => apply({ ...state(), axes: axes.checked });
play.onchange = () => apply({ ...state(), rotating: play.checked });
speed.oninput = () => apply({ ...state(), speed: Number(speed.value) });
stop.onclick = () => apply({ ...state(), rotating: false });
const cameraCommands = onControlsChanged ? attachCameraCommands(() => controls, next => {
controls.removeEventListener("change", changed);
controls = next;
controls.addEventListener("change", changed);
onControlsChanged(next);
}, documentId, () => picking?.objects() ?? []) : undefined;
const runCameraCommand = (command: string, action: unknown) => {
const camera = cameraCommands?.execute(command, action);
if (!camera) return;
const next = { ...state(), camera };
return action === "read" || action === "cancel" ? next : apply({ ...next, rotating: controls.autoRotate });
};
if (cameraCommands) {
const error = document.createElement("span");
error.className = "scene-camera-error"; error.setAttribute("role", "alert"); error.hidden = true;
const execute = (command: string) => {
try { runCameraCommand(command, "apply"); error.hidden = true; error.textContent = ""; }
catch (cause) { error.textContent = cause instanceof Error ? cause.message : String(cause); error.hidden = false; }
};
const views = document.createElement("select");
views.setAttribute("aria-label", "标准视图"); views.title = "标准视图";
for (const [command, title] of [["", "视图"], ["Std_ViewTop", "顶视图"], ["Std_ViewBottom", "底视图"],
["Std_ViewFront", "前视图"], ["Std_ViewRear", "后视图"], ["Std_ViewLeft", "左视图"], ["Std_ViewRight", "右视图"],
["Std_ViewIsometric", "等轴测"], ["Std_ViewDimetric", "正二轴测"], ["Std_ViewTrimetric", "正三轴测"]]) {
const option = document.createElement("option"); option.value = command; option.textContent = title; views.append(option);
}
views.onchange = () => { if (views.value) execute(views.value); views.value = ""; };
toolbar.append(views);
const button = (command: string, title: string, icon: IconNode) => {
const element = document.createElement("button"); element.type = "button";
element.className = "scene-camera-button"; element.title = title; element.setAttribute("aria-label", title);
element.append(createElement(icon, { width: 16, height: 16, "aria-hidden": "true" }));
element.onclick = () => execute(command); toolbar.append(element);
};
button("Std_ViewZoomIn", "放大", ZoomIn); button("Std_ViewZoomOut", "缩小", ZoomOut);
button("Std_ViewFitAll", "适配全部", Maximize);
button("Std_ViewRotateLeft", "向左旋转视图", RotateCcw); button("Std_ViewRotateRight", "向右旋转视图", RotateCw);
button("Std_StoreWorkingView", "存储工作视图", Save); button("Std_RecallWorkingView", "恢复工作视图", History);
toolbar.append(error);
}
const unregister = registerSceneCommandHost({
documentId,
visible: () => container.isConnected && container.getClientRects().length > 0,
execute(command, args) {
const action = args.action ?? "apply";
if (!["read", "apply", "accept", "cancel"].includes(String(action))) throw new Error("Unknown view action");
const next = { ...state() };
const cameraResult = runCameraCommand(command, action);
if (cameraResult) return cameraResult;
if (command === "Std_ClarifySelection") {
if (!picker) throw new Error("This view has no object picking provider");
if (action === "read") return { ...next, selectionScope: "rendered-object", ...picker.snapshot() };
if (action === "cancel") { picker.close(); return { ...next, selectionScope: "rendered-object", selectionPending: false, candidates: [] }; }
const point = args.point;
if (point !== undefined && (!Array.isArray(point) || point.length !== 2 || point.some(value => typeof value !== "number" || !Number.isFinite(value))))
throw new Error("point requires two finite canvas coordinates");
const bounds = picking!.canvas.getBoundingClientRect();
const candidates = picker.show(Array.isArray(point) ? { x: bounds.left + point[0], y: bounds.top + point[1] } : undefined);
return { ...next, selectionScope: "rendered-object", selectionPending: candidates.length > 0, candidates };
}
if (action === "read") return next;
if (command === "Std_AxisCross") {
if (args.enabled !== undefined && typeof args.enabled !== "boolean") throw new Error("enabled must be boolean");
if (action === "cancel") return next;
next.axes = typeof args.enabled === "boolean" ? args.enabled : !next.axes;
} else if (command === "Std_DemoMode") {
if (args.speed !== undefined && (typeof args.speed !== "number" || !Number.isFinite(args.speed) || Math.abs(args.speed) > 10)) throw new Error("Turntable speed must be between -10 and 10");
if (args.play !== undefined && typeof args.play !== "boolean") throw new Error("play must be boolean");
next.rotating = action === "cancel" ? false : typeof args.play === "boolean" ? args.play : !next.rotating;
if (typeof args.speed === "number") next.speed = args.speed;
toolbar.hidden = false;
} else throw new Error("This view has no handler for the requested command");
return apply(next);
},
});
// A view may switch documents without being remounted.
let active = documentId();
const changed = () => {
if (active === documentId()) return;
active = documentId();
picker?.close();
apply({ ...state(), rotating: false });
};
controls.addEventListener("change", changed);
return { dispose() {
cameraCommands?.dispose();
picker?.dispose();
unregister(); controls.removeEventListener("change", changed); controls.autoRotate = false;
toolbar.remove(); cross.removeFromParent(); cross.geometry.dispose();
const materials = Array.isArray(cross.material) ? cross.material : [cross.material];
materials.forEach(material => material.dispose());
container.style.position = oldPosition;
} };
}