diff --git a/scripts/verify-nozzle.js b/scripts/verify-nozzle.js index 8d6b113..64e8633 100644 --- a/scripts/verify-nozzle.js +++ b/scripts/verify-nozzle.js @@ -65,6 +65,9 @@ try { [1440, 900], [1280, 720], [1920, 1080], + [2048, 990], + [2560, 1238], + [1366, 768], [390, 844], ]) { await page.setViewportSize({ width, height }); @@ -75,6 +78,7 @@ try { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight, scene: rect(document.querySelector("#scene")), + parts: JSON.parse(document.querySelector("#scene").dataset.nozzleBounds), labels: [...document.querySelectorAll(".nozzle-label")].map(rect), panel: rect(document.querySelector('[data-panel="nozzle"]')), foot: rect( @@ -83,6 +87,14 @@ try { }; }); assert.ok(layout.width <= width); + assert.equal(layout.parts.length, 12); + layout.parts.forEach((part, index) => { + assert.ok(part.top >= 20 && part.bottom <= layout.scene.height - 28, + `${width} 部件 ${index + 1} 上下未裁切:${JSON.stringify(part)}`); + const inset = (layout.scene.width < 550 ? 90 : 144) + 8; + assert.ok(part.left >= inset && part.right <= layout.scene.width - inset, + `${width} 部件 ${index + 1} 不被标签遮挡`); + }); if (width >= 1100) { assert.ok(layout.height <= height); assert.ok(layout.foot.bottom <= layout.panel.bottom + 1); @@ -111,6 +123,7 @@ try { height, scene: layout.scene, labels: layout.labels.length, + parts: layout.parts, }); } await page.setViewportSize({ width: 1440, height: 900 }); diff --git a/src/views/Monitor/NozzleFraming.js b/src/views/Monitor/NozzleFraming.js new file mode 100644 index 0000000..f0a5c3e --- /dev/null +++ b/src/views/Monitor/NozzleFraming.js @@ -0,0 +1,41 @@ +import * as THREE from "three"; + +export function boxCorners(box) { + return [box.min.x, box.max.x].flatMap((x) => + [box.min.y, box.max.y].flatMap((y) => + [box.min.z, box.max.z].map((z) => new THREE.Vector3(x, y, z)), + ), + ); +} + +// 将部件包围盒全部纳入透视视锥,并留出两侧标签与底部说明空间。 +export function fitNozzleCamera(camera, controls, boxes, width, height) { + const bounds = boxes.reduce((result, box) => result.union(box), new THREE.Box3()); + const center = bounds.getCenter(new THREE.Vector3()); + const backward = camera.position.clone().sub(controls.target).normalize(); + const right = new THREE.Vector3().crossVectors(camera.up, backward).normalize(); + const up = new THREE.Vector3().crossVectors(backward, right).normalize(); + const labelWidth = width < 550 ? 90 : 144; + const horizontal = Math.max(0.15, 1 - (2 * (labelWidth + 22)) / width); + const vertical = Math.max(0.2, 1 - 80 / height); + const tangent = Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV() / 2)); + let distance = controls.minDistance; + for (const box of boxes) { + for (const point of boxCorners(box)) { + const relative = point.sub(center); + const depth = relative.dot(backward); + distance = Math.max(distance, + depth + Math.abs(relative.dot(right)) / (tangent * camera.aspect * horizontal), + depth + Math.abs(relative.dot(up)) / (tangent * vertical)); + } + } + distance *= 1.04; + controls.maxDistance = Math.max(1000, distance * 2); + controls.target.copy(center); + camera.position.copy(center).addScaledVector(backward, distance); + camera.far = Math.max(1600, distance * 4); + camera.updateProjectionMatrix(); + camera.lookAt(center); + controls.update(); + camera.updateMatrixWorld(true); +} diff --git a/src/views/Monitor/NozzleScene.js b/src/views/Monitor/NozzleScene.js index 18ffcfd..6b8775d 100644 --- a/src/views/Monitor/NozzleScene.js +++ b/src/views/Monitor/NozzleScene.js @@ -1,5 +1,6 @@ import * as THREE from "three"; import "./Nozzle.css"; +import { boxCorners, fitNozzleCamera } from "./NozzleFraming.js"; const modules = [ { @@ -116,6 +117,10 @@ export class NozzleScene { return group; }); this.build(); + this.partBounds = this.parts.map((part) => { + const box = new THREE.Box3().setFromObject(part); + return box.translate(part.position.clone().negate()); + }); this.overlay = document.createElement("div"); this.overlay.className = "nozzle-annotations"; this.overlay.hidden = true; @@ -287,30 +292,33 @@ export class NozzleScene { } focus() { + const { camera, controls } = this.owner; + camera.position.copy(controls.target).add(new THREE.Vector3(0.32, 0.42, 1).multiplyScalar(340)); + this.fit(); + } + + boxes() { + this.group.updateMatrixWorld(true); + return this.parts.map((part, index) => this.partBounds[index].clone().applyMatrix4(part.matrixWorld)); + } + + fit() { const { camera, controls, container } = this.owner; - const narrow = container.clientWidth < 550; - const available = Math.max( - 0.3, - (container.clientWidth - (narrow ? 180 : 300)) / container.clientWidth, - ); - const distance = Math.max(340, 170 / (camera.aspect * available * 0.63)); - controls.target.set(0, 22, 0); - camera.position.copy( - new THREE.Vector3(0.32, 0.42, 1) - .normalize() - .multiplyScalar(distance) - .add(controls.target), - ); - controls.update(); + if (!container.clientWidth || !container.clientHeight) return; + fitNozzleCamera(camera, controls, this.boxes(), container.clientWidth, container.clientHeight); } update(disassembled) { - this.amount += ((disassembled ? 1 : 0) - this.amount) * 0.09; + const target = disassembled ? 1 : 0; + const changing = Math.abs(target - this.amount) > 0.0001; + this.amount = changing ? this.amount + (target - this.amount) * 0.09 : target; this.parts.forEach((part, i) => { part.position .fromArray(modules[i].base) .addScaledVector(new THREE.Vector3(...modules[i].offset), this.amount); }); + if (changing || this.lastDisassembled !== disassembled) this.fit(); + this.lastDisassembled = disassembled; this.owner.container.dataset.nozzleExplode = this.amount.toFixed(3); this.annotate(); } @@ -328,6 +336,16 @@ export class NozzleScene { labelWidth = narrow ? 90 : 144; const slots = [[], []]; this.group.updateMatrixWorld(true); + camera.updateMatrixWorld(true); + this.owner.container.dataset.nozzleBounds = JSON.stringify(this.boxes().map((box) => { + const points = boxCorners(box).map((point) => point.project(camera)); + return { + left: Math.min(...points.map((point) => (point.x + 1) * w / 2)), + right: Math.max(...points.map((point) => (point.x + 1) * w / 2)), + top: Math.min(...points.map((point) => (1 - point.y) * h / 2)), + bottom: Math.max(...points.map((point) => (1 - point.y) * h / 2)), + }; + })); this.parts.forEach((part, i) => { const p = part.getWorldPosition(new THREE.Vector3()).project(camera); slots[i % 2].push({ i, x: ((p.x + 1) * w) / 2, y: ((1 - p.y) * h) / 2 });