1import { CSS2DObject } from "CSS2DRenderer"; 2import { Controller } from "@hotwired/stimulus"; 3import { 4 capturePicture, 5 cleanup, 6 disposeObject, 7 rotateTo, 8 setupLighting, 9 setupViewCube, 10 setupViewer, 11 toggleTransparent, 12 toggleWireframe, 13} from "cad-viewer"; 14import CameraControls from "camera-controls"; 15import * as THREE from "three"; 16 17let scene, renderer; 18let labelRenderer; 19let currentCamera; 20let cameraControls, cameraDirLight; 21let boundedLength; 22let mainObject; 23let loaded = false; 24let wireframed = true; 25let transparent = true; 26const solid = true; 27let mixer; 28let highlightMaterial; 29let material; 30let lineMaterial; 31const clock = new THREE.Clock(); 32const materialColorValue = [0.5803921568627451, 0.6392156862745098, 0.7215686274509804]; 33const highlightColorValue = [0.9803921568627451, 0.8, 0.08235294117647059]; 34const itemConditionMaterialColorTokens = { 35 unspecified: "--color-zinc-300", 36 brand_new: "--color-green-600", 37 refurbished: "--color-green-400", 38 unused: "--color-teal-500", 39 used: "--color-main-500", 40 for_service: "--color-yellow-400", 41 for_scrap: "--color-red-400", 42}; 43let highlightClip; 44let arrowRenderer, arrowScene, arrowCamera; 45let viewerElement, coordinateElement, spinnerElement; 46let minDim, wcs; 47let coordinateVisible = false; 48let assemblyMetadata = null; 49let loadedMetadata = null; 50let animationFrame = null; 51let highlightedMesh = null; 52let activeController = null; 53 54const DEG90 = 90 * THREE.MathUtils.DEG2RAD; 55const MAX_PIXEL_RATIO = 1.5; 56 57// Connects to data-controller="device-model" 58export default class extends Controller { 59 static targets = ["viewer", "coordinate", "spinner", "metadata"]; 60 61 static values = { 62 address: Array, 63 itemCondition: String, 64 }; 65 66 connect() { 67 activeController = this; 68 viewerElement = this.viewerTarget; 69 coordinateElement = this.coordinateTarget; 70 spinnerElement = this.spinnerTarget; 71 configureHighlightAnimation(this.itemConditionValue); 72 const metadata = this.metadataTarget.dataset.metadata; 73 74 if (loaded) { 75 attachViewer(); 76 resizeViewer(); 77 if (metadata !== loadedMetadata) { 78 replaceModel(metadata); 79 } else { 80 zoomToFit(); 81 } 82 spinnerElement.classList.add("hidden"); 83 if (this.addressValue.length === 3) { 84 this.showLocation(this.addressValue); 85 } 86 return; 87 } 88 89 destroyViewer(); 90 loadedMetadata = metadata; 91 loaded = false; 92 boundedLength = 250; 93 coordinateVisible = false; 94 wcs = null; 95 highlightMaterial = new THREE.MeshBasicMaterial({ color: 0xfacc15 }); 96 material = new THREE.MeshBasicMaterial({ color: 0xa1a1aa }); 97 lineMaterial = new THREE.LineBasicMaterial({ color: 0x52525b }); 98 CameraControls.install({ THREE: THREE }); 99 try { 100 assemblyMetadata = JSON.parse(metadata); 101 } catch { 102 assemblyMetadata = {}; 103 } 104 this.loadModels(); 105 } 106 107 disconnect() { 108 if (activeController !== this) return; 109 110 activeController = null; 111 cancelAnimationFrame(animationFrame); 112 animationFrame = null; 113 stopMixer(); 114 detachViewer(); 115 viewerElement = null; 116 coordinateElement = null; 117 spinnerElement = null; 118 } 119 120 loadModels() { 121 if (!loaded) { 122 initModelViewer(); 123 mainObject.updateMatrix(); 124 rotateTo("isometric", cameraControls, "Z"); 125 zoomToFit(); 126 loaded = true; 127 spinnerElement.classList.add("hidden"); 128 if (this.addressValue.length === 3) { 129 this.showLocation(this.addressValue); 130 } 131 startAnimation(); 132 } 133 } 134 135 reloadModels() { 136 spinnerElement.classList.remove("hidden"); 137 replaceModel(this.metadataTarget.dataset.metadata); 138 spinnerElement.classList.add("hidden"); 139 } 140 141 highlightModule(event) { 142 mainObject.children.forEach((mesh) => { 143 if (mesh.type === "Mesh" && mesh.name.split(",").pop() === event.params.index.toString()) { 144 // flashing the mesh location 145 highlightLocation(mesh); 146 } 147 }); 148 renderer.render(scene, currentCamera); 149 } 150 151 resetHighlightModule(event) { 152 mainObject.children.forEach((mesh) => { 153 if (mesh.type === "Mesh" && mesh.name.split(",").pop() === event.params.index.toString()) { 154 // stop flashing the mesh location 155 resetHighlightLocation(mesh); 156 } 157 }); 158 renderer.render(scene, currentCamera); 159 } 160 161 highlightLocation(event) {
162 mainObject.children.forEach((mesh) => { 163 if (mesh.type === "Mesh" && mesh.name === event.params.index.toString()) { 164 // flashing the mesh location 165 highlightLocation(mesh); 166 } 167 }); 168 renderer.render(scene, currentCamera); 169 } 170 171 resetHighlightLocation(event) { 172 mainObject.children.forEach((mesh) => { 173 if (mesh.type === "Mesh" && mesh.name === event.params.index.toString()) { 174 // stop flashing the mesh location 175 resetHighlightLocation(mesh); 176 } 177 }); 178 renderer.render(scene, currentCamera); 179 } 180 181 showLocation(address) { 182 stopMixer(); 183 if (highlightedMesh?.material === highlightMaterial) { 184 highlightedMesh.material = material; 185 } 186 highlightedMesh = null; 187 mainObject.children.forEach((mesh) => { 188 if (mesh.type === "Mesh" && mesh.name === address.toString()) { 189 highlightMaterial.opacity = 1.0; 190 highlightMaterial.transparent = false; 191 mesh.material = highlightMaterial; 192 highlightMaterial.needsUpdate = true; 193 mixer = new THREE.AnimationMixer(mesh); 194 const clipAction = mixer.clipAction(highlightClip); 195 clipAction.setLoop(THREE.LoopRepeat, 10); 196 clipAction.clampWhenFinished = true; 197 clipAction.play(); 198 mixer.addEventListener("finished", stopMixer); 199 highlightedMesh = mesh; 200 return; 201 } 202 }); 203 renderer.render(scene, currentCamera); 204 if (mixer) startAnimation(); 205 } 206 207 visibleToggle() { 208 toggleCoordinate(); 209 } 210 211 toggleViewWireframe() { 212 wireframed = !wireframed; 213 toggleWireframe(mainObject, wireframed); 214 renderer.render(scene, currentCamera); 215 } 216 217 toggleViewTransparent() { 218 if (solid === false) return; 219 220 transparent = !transparent; 221 toggleTransparent(mainObject, transparent); 222 renderer.render(scene, currentCamera); 223 } 224 225 rotateToView(event) { 226 const type = event.params.name; 227 rotateTo(type, cameraControls, "Z"); 228 zoomToFit(); 229 } 230 231 fitToView() { 232 zoomToFit(); 233 } 234 235 capturePicture() { 236 renderer.render(scene, currentCamera); 237 capturePicture(renderer.domElement); 238 } 239} 240 241function initModelViewer() { 242 const canvasElement = viewerElement; 243 const arrowCanvas = coordinateElement; 244 const width = canvasElement.offsetWidth; 245 const height = canvasElement.offsetHeight; 246 247 ({ renderer, labelRenderer, currentCamera } = setupViewer( 248 width, 249 height, 250 boundedLength, 251 "Z", 252 MAX_PIXEL_RATIO, 253 )); 254 canvasElement.appendChild(renderer.domElement); 255 canvasElement.appendChild(labelRenderer.domElement); 256 257 scene = new THREE.Scene(); 258 259 cameraDirLight = setupLighting(scene, currentCamera, boundedLength); 260 261 mainObject = new THREE.Group(); 262 mainObject.matrixAutoUpdate = false; 263 LoadDevice(mainObject, assemblyMetadata.metadata); 264 setUpDirection(mainObject, assemblyMetadata.up_direction); 265 scene.add(mainObject); 266 267 cameraControls = new CameraControls(currentCamera, renderer.domElement); 268 cameraControls.addEventListener("control", startAnimation); 269 cameraControls.addEventListener("transitionstart", startAnimation); 270 cameraControls.update(); 271 272 ({ arrowRenderer, arrowScene, arrowCamera } = setupViewCube(currentCamera)); 273 arrowCanvas.appendChild(arrowRenderer.domElement); 274} 275 276function attachViewer() { 277 viewerElement.appendChild(renderer.domElement); 278 viewerElement.appendChild(labelRenderer.domElement); 279 coordinateElement.appendChild(arrowRenderer.domElement); 280} 281 282function detachViewer() { 283 renderer?.domElement.remove(); 284 labelRenderer?.domElement.remove(); 285 arrowRenderer?.domElement.remove(); 286} 287 288function resizeViewer() { 289 const width = viewerElement.offsetWidth; 290 const height = viewerElement.offsetHeight; 291 const currentSize = renderer.getSize(new THREE.Vector2()); 292 if (currentSize.x !== width || currentSize.y !== height) { 293 const aspect = width / height; 294 currentCamera.left = (-boundedLength / 2) * aspect; 295 currentCamera.right = (boundedLength / 2) * aspect; 296 currentCamera.top = boundedLength / 2; 297 currentCamera.bottom = -boundedLength / 2; 298 currentCamera.updateProjectionMatrix(); 299 renderer.setSize(width, height); 300 labelRenderer.setSize(width, height); 301 } 302 renderer.render(scene, currentCamera); 303 labelRenderer.render(scene, currentCamera); 304} 305 306function replaceModel(metadata) { 307 stopMixer(); 308 highlightedMesh = null; 309 while (labelRenderer.domElement.lastElementChild) { 310 labelRenderer.domElement.lastElementChild.remove(); 311 } 312 if (wcs) { 313 scene.remove(wcs); 314 disposeObject(wcs); 315 wcs = null; 316 } 317 if (mainObject) { 318 scene.remove(mainObject); 319 disposeObject(mainObject); 320 } 321 highlightMaterial?.dispose(); 322 material?.dispose(); 323 lineMaterial?.dispose(); 324 highlightMaterial = new THREE.MeshBasicMaterial({ color: 0xfacc15 }); 325 material = new THREE.MeshBasicMaterial({ color: 0xa1a1aa }); 326 lineMaterial = new THREE.LineBasicMaterial({ color: 0x52525b }); 327 loadedMetadata = metadata; 328 assemblyMetadata = JSON.parse(metadata); 329 mainObject = new THREE.Group(); 330 mainObject.matrixAutoUpdate = false;
331 LoadDevice(mainObject, assemblyMetadata.metadata); 332 setUpDirection(mainObject, assemblyMetadata.up_direction); 333 scene.add(mainObject); 334 mainObject.updateMatrix(); 335 rotateTo("isometric", cameraControls, "Z"); 336 zoomToFit(); 337} 338 339function destroyViewer() { 340 cancelAnimationFrame(animationFrame); 341 animationFrame = null; 342 stopMixer(); 343 cameraControls?.dispose(); 344 cleanup(renderer, scene, currentCamera); 345 cleanup(arrowRenderer, arrowScene, arrowCamera); 346 mainObject = null; 347 assemblyMetadata = null; 348 loadedMetadata = null; 349 renderer = null; 350 scene = null; 351 labelRenderer = null; 352 currentCamera = null; 353 cameraControls = null; 354 arrowRenderer = null; 355 arrowScene = null; 356 arrowCamera = null; 357 highlightMaterial = null; 358 material = null; 359 lineMaterial = null; 360 highlightedMesh = null; 361 loaded = false; 362} 363 364function LoadDevice(targetObject, metadata) { 365 if (transparent === true) { 366 material.opacity = 0.4; 367 material.transparent = true; 368 } else { 369 material.opacity = 1.0; 370 material.transparent = false; 371 } 372 const geometryCache = new Map(); 373 for (const index in metadata) { 374 if (Object.hasOwn(metadata, index)) { 375 const data = metadata[index]; 376 const geometryKey = data.box.join(","); 377 let geometries = geometryCache.get(geometryKey); 378 if (!geometries) { 379 const geometry = new THREE.BoxGeometry(data.box[0], data.box[1], data.box[2]); 380 geometries = { geometry, edges: new THREE.EdgesGeometry(geometry) }; 381 geometryCache.set(geometryKey, geometries); 382 } 383 384 const { geometry, edges } = geometries; 385 const mesh = new THREE.Mesh(geometry, material); 386 targetObject.add(mesh); 387 const xo = data.box[0] / 2.0 + data.coordinate[0]; 388 const yo = data.box[1] / 2.0 + data.coordinate[1]; 389 const zo = data.box[2] / 2.0 + data.coordinate[2]; 390 mesh.position.set(xo, yo, zo); 391 const name = index.slice(1, index.length - 1).replaceAll(" ", ""); 392 mesh.name = name; 393 const wireframe = new THREE.LineSegments(edges, lineMaterial); 394 wireframe.name = name; 395 wireframe.visible = wireframed; 396 targetObject.add(wireframe); 397 wireframe.position.set(xo, yo, zo); 398 } 399 } 400} 401 402function animate() { 403 if (!renderer || !cameraControls) { 404 animationFrame = null; 405 return; 406 } 407 408 const delta = clock.getDelta(); 409 const updated = cameraControls.update(delta); 410 411 if (updated || mixer) { 412 if (mixer) { 413 mixer.update(delta); 414 } 415 renderer.render(scene, currentCamera); 416 } 417 418 if (updated) { 419 labelRenderer.render(scene, currentCamera); 420 arrowRenderer.render(arrowScene, arrowCamera); 421 arrowCamera.position.copy(currentCamera.position); 422 arrowCamera.position.sub(cameraControls.getTarget()); 423 arrowCamera.position.setLength(300); 424 arrowCamera.lookAt(arrowScene.position); 425 cameraDirLight.position.set( 426 currentCamera.position.x, 427 currentCamera.position.y, 428 currentCamera.position.z, 429 ); 430 } 431 432 animationFrame = updated || mixer ? requestAnimationFrame(animate) : null; 433} 434 435function startAnimation() { 436 if (animationFrame === null) { 437 clock.start(); 438 animationFrame = requestAnimationFrame(animate); 439 } 440} 441 442function stopMixer() { 443 if (mixer) { 444 const root = mixer.getRoot(); 445 mixer.removeEventListener("finished", stopMixer); 446 mixer.stopAllAction(); 447 mixer.uncacheRoot(root); 448 mixer = null; 449 } 450} 451 452function configureHighlightAnimation(itemCondition) { 453 const conditionColorValue = itemConditionMaterialColorTokens[itemCondition] 454 ? colorValueFromToken(itemConditionMaterialColorTokens[itemCondition]) 455 : materialColorValue; 456 const highlightColorTrack = new THREE.ColorKeyframeTrack( 457 ".material.color", 458 [0, 2, 3], 459 [highlightColorValue, conditionColorValue, highlightColorValue].flat(), 460 THREE.InterpolateDiscrete, 461 ); 462 highlightClip = new THREE.AnimationClip("Action", 3, [highlightColorTrack]); 463 highlightMaterial?.color.set(0xfacc15); 464} 465 466function colorValueFromToken(colorToken) { 467 const probe = document.createElement("span"); 468 probe.style.color = `var(${colorToken})`; 469 document.body.appendChild(probe); 470 const renderedColor = getComputedStyle(probe).color; 471 probe.remove(); 472 473 const canvas = document.createElement("canvas"); 474 const context = canvas.getContext("2d"); 475 context.fillStyle = renderedColor; 476 context.fillRect(0, 0, 1, 1); 477 return Array.from(context.getImageData(0, 0, 1, 1).data.slice(0, 3), (channel) => 478 channel / 255, 479 ); 480} 481 482function highlightLocation(locationMesh) { 483 if (locationMesh !== undefined) { 484 highlightMaterial.opacity = 1.0; 485 highlightMaterial.transparent = false;
486 locationMesh.material = highlightMaterial; 487 highlightMaterial.needsUpdate = true; 488 } 489} 490 491function resetHighlightLocation(locationMesh) { 492 if (locationMesh !== undefined) { 493 locationMesh.material = material; 494 material.needsUpdate = true; 495 } 496} 497 498function toggleCoordinate() { 499 coordinateVisible = !coordinateVisible; 500 if (!wcs) { 501 const box = new THREE.Box3().setFromObject(mainObject); 502 minDim = Math.min(box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z); 503 wcs = new THREE.AxesHelper(minDim / 5); 504 wcs.setColors(0xdc2626, 0x16a34a, 0x2563eb); 505 wcs.name = "WCS"; 506 const text = document.createElement("div"); 507 const text_hidden = coordinateVisible ? "" : "hidden"; 508 text.className = `coordinateLabel ${text_hidden}`; 509 text.style.fontSize = "14px"; 510 text.style.fontFamily = "monospace"; 511 text.style.color = "#2563eb"; 512 text.textContent = wcs.name; 513 const wcsLabel = new CSS2DObject(text); 514 wcsLabel.position.set(minDim / 8, minDim / 8, minDim / 8); 515 wcs.add(wcsLabel); 516 wcs.visible = coordinateVisible; 517 scene.add(wcs); 518 } 519 scene.traverse((object) => { 520 if (object.type === "AxesHelper") { 521 object.visible = coordinateVisible; 522 } 523 }); 524 for (const label of labelRenderer.domElement.getElementsByClassName("coordinateLabel")) { 525 if (coordinateVisible) { 526 label.classList.remove("hidden"); 527 } else { 528 label.classList.add("hidden"); 529 } 530 } 531 renderer.render(scene, currentCamera); 532 labelRenderer.render(scene, currentCamera); 533} 534 535function zoomToFit() { 536 const box = new THREE.Box3().setFromObject(mainObject); 537 const sphere = new THREE.Sphere(); 538 box.getBoundingSphere(sphere); 539 cameraControls.fitToSphere(sphere, false); 540 renderer.render(scene, currentCamera); 541} 542 543function setUpDirection(targetObject, direction) { 544 const box = new THREE.Box3().setFromObject(targetObject); 545 switch (direction) { 546 case "x": 547 targetObject.rotation.set(0, -DEG90, 0); 548 targetObject.position.setX(box.getSize(new THREE.Vector3()).z); 549 break; 550 case "y": 551 targetObject.rotation.set(DEG90, 0, 0); 552 targetObject.position.setY(box.getSize(new THREE.Vector3()).z); 553 break; 554 default: 555 break; 556 } 557}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.