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(); 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; } }; }