(function () { "use strict"; function viewerHasLayout(viewer) { const bounds = viewer.getBoundingClientRect(); return viewer.isConnected && bounds.width >= 2 && bounds.height >= 2; } function deferSeat3dUntilVisible(viewer, options) { if (viewer.dataset.seat3dPending === "true") return; viewer.dataset.seat3dPending = "true"; let observer = null; const retry = () => { if (!viewerHasLayout(viewer)) return; observer?.disconnect(); window.removeEventListener("resize", scheduleRetry); window.removeEventListener("cognitive-agent:module-change", scheduleRetry); delete viewer.dataset.seat3dPending; initSeat3d(options); }; const scheduleRetry = () => window.requestAnimationFrame(retry); if (window.ResizeObserver) { observer = new ResizeObserver(retry); observer.observe(viewer); } window.addEventListener("resize", scheduleRetry); window.addEventListener("cognitive-agent:module-change", scheduleRetry); scheduleRetry(); } function initSeat3d(options) { const { canvasId, viewerId, stateEvent, resetEvent, initialAxes = [35, 42, 48, 20], readyClass = "ready", axisMode = "repair", enableAxisDrag = false, labelBindings = null, } = options; const THREE = window.THREE; const canvas = document.getElementById(canvasId); const viewer = document.getElementById(viewerId); if (!canvas || !viewer || viewer.dataset.seat3dInitialized === "true") return; if (!viewerHasLayout(viewer)) { deferSeat3dUntilVisible(viewer, options); return; } viewer.dataset.seat3dInitialized = "true"; const markReady = (fallback = false) => { viewer.classList.add(readyClass); if (fallback) viewer.classList.add("fallback"); }; const buildFallback = () => { const fallback = document.createElement("div"); fallback.className = "v2-repair-seat-fallback"; fallback.setAttribute("aria-hidden", "true"); fallback.innerHTML = `
`; viewer.insertBefore(fallback, canvas.nextSibling); window.addEventListener(stateEvent, (event) => { const detail = event.detail || {}; const axes = new Map((detail.axes || []).map((axis) => [Number(axis.motor), Number(axis.position_pct || 0)])); fallback.style.setProperty("--repair-slide", `${(50 - (axes.get(0) ?? initialAxes[0])) * 0.18}px`); fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? initialAxes[1]) - 50) * 0.1}px`); fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? initialAxes[2]) * 0.24}deg`); fallback.style.setProperty("--repair-leg", `${-5 + (axes.get(3) ?? initialAxes[3]) * 0.58}deg`); fallback.classList.toggle("heating", Number(detail.heatingLevel || 0) > 0); fallback.classList.toggle("ventilating", Boolean(detail.ventilation)); fallback.classList.toggle("active", !["IDLE", "PASSED"].includes(detail.state)); fallback.classList.toggle("failed", detail.state === "FAILED"); }); markReady(true); }; if (!THREE) { buildFallback(); return; } const themeProbe = document.createElement("div"); themeProbe.className = "console-v2"; themeProbe.dataset.demoRole = "admin"; themeProbe.style.display = "none"; document.body.append(themeProbe); const consoleStyles = window.getComputedStyle(themeProbe); const cssThemeColor = (name, fallback) => { const value = consoleStyles?.getPropertyValue(name).trim() || fallback; try { return new THREE.Color(value); } catch (_error) { return new THREE.Color(fallback); } }; const white = new THREE.Color(0xffffff); const black = new THREE.Color(0x000000); const themeAccent = cssThemeColor("--v2-accent", "#315eaa"); const themeStrong = cssThemeColor("--v2-accent-strong", "#234785"); const themeSoft = cssThemeColor("--v2-accent-soft", "#e1e9fa"); themeProbe.remove(); const mixHex = (from, to, amount) => from.clone().lerp(to, amount).getHex(); const scene = new THREE.Scene(); scene.fog = new THREE.Fog(mixHex(themeSoft, white, 0.12), 8, 15); let renderer; try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance", }); } catch (_error) { buildFallback(); return; } renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputEncoding = THREE.sRGBEncoding; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.92; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; const camera = new THREE.PerspectiveCamera(33, 1, 0.05, 40); const target = new THREE.Vector3(0, 1.15, 0); const orbit = { yaw: 0.67, pitch: 0.21, distance: 6.8 }; const pointer = { active: false, x: 0, y: 0, startX: 0, startY: 0, startValue: 0, axis: null, mode: "orbit", moved: false, dragVector: { x: 1, y: 0 }, dragSource: "model", }; scene.add(new THREE.HemisphereLight(0xffffff, mixHex(themeStrong, white, 0.46), 1.35)); const keyLight = new THREE.DirectionalLight(0xffffff, 1.8); keyLight.position.set(-4, 7, 6); keyLight.castShadow = true; keyLight.shadow.mapSize.set(1024, 1024); scene.add(keyLight); const fillLight = new THREE.DirectionalLight(mixHex(themeAccent, white, 0.42), 0.72); fillLight.position.set(5, 3, -4); scene.add(fillLight); const floor = new THREE.Mesh( new THREE.CircleGeometry(4.8, 80), new THREE.MeshStandardMaterial({ color: mixHex(themeSoft, white, 0.18), roughness: 0.94 }), ); floor.rotation.x = -Math.PI / 2; floor.position.y = -0.16; floor.receiveShadow = true; scene.add(floor); const grid = new THREE.GridHelper(8, 20, themeAccent.getHex(), mixHex(themeAccent, white, 0.7)); grid.position.y = -0.153; grid.material.transparent = true; grid.material.opacity = 0.14; scene.add(grid); const seatShell = new THREE.MeshPhysicalMaterial({ color: mixHex(themeStrong, black, 0.06), roughness: 0.54, metalness: 0.02, clearcoat: 0.16, clearcoatRoughness: 0.7, emissive: mixHex(themeStrong, black, 0.64), emissiveIntensity: 0.08, }); const seatInsert = new THREE.MeshPhysicalMaterial({ color: mixHex(themeAccent, white, 0.48), roughness: 0.72, metalness: 0, clearcoat: 0.06, emissive: 0x000000, emissiveIntensity: 0, }); const thermalInsert = seatInsert.clone(); const bolsterMaterial = new THREE.MeshStandardMaterial({ color: themeAccent.getHex(), roughness: 0.76, emissive: 0x000000, emissiveIntensity: 0, }); const structureMaterial = new THREE.MeshStandardMaterial({ color: mixHex(themeStrong, black, 0.34), roughness: 0.62, metalness: 0.35, }); const railMaterial = new THREE.MeshStandardMaterial({ color: 0x7b8798, roughness: 0.42, metalness: 0.72 }); const edgeMaterial = new THREE.LineBasicMaterial({ color: mixHex(themeStrong, black, 0.38), transparent: true, opacity: 0.3, }); const seamMaterial = new THREE.LineBasicMaterial({ color: mixHex(themeAccent, white, 0.42), transparent: true, opacity: 0.72, }); const axisHitTargets = []; function markAxisTarget(object, axis) { if (!object || !Number.isInteger(axis)) return object; object.traverse((child) => { if (!child.isMesh) return; child.userData.calibrationAxis = axis; axisHitTargets.push(child); }); return object; } function addEdges(mesh, geometry, opacity = 0.3) { const material = edgeMaterial.clone(); material.opacity = opacity; mesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 38), material)); } function makeBox(parent, size, position, material) { const geometry = new THREE.BoxGeometry(...size); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(...position); mesh.castShadow = true; mesh.receiveShadow = true; addEdges(mesh, geometry, 0.26); parent.add(mesh); return mesh; } function makePanel(parent, shape, depth, position, rotation, material) { const geometry = new THREE.ExtrudeGeometry(shape, { depth, steps: 1, curveSegments: 20, bevelEnabled: true, bevelSegments: 4, bevelSize: 0.045, bevelThickness: 0.04, }); geometry.translate(0, 0, -depth / 2); geometry.computeVertexNormals(); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(...position); mesh.rotation.set(...rotation); mesh.castShadow = true; mesh.receiveShadow = true; addEdges(mesh, geometry); parent.add(mesh); return mesh; } function cushionShape(inset = 0) { const shape = new THREE.Shape(); const half = 0.69 - inset; shape.moveTo(-0.52 + inset, 0.61 - inset); shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34); shape.lineTo(-half + 0.025, -0.39 + inset); shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset); shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset); shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset); shape.lineTo(half, 0.34); shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset); shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset); return shape; } function backrestShape(inset = 0) { const shape = new THREE.Shape(); const half = 0.7 - inset; shape.moveTo(-0.52 + inset, 0); shape.quadraticCurveTo(-half, 0.08, -half, 0.34); shape.lineTo(-half + 0.035, 1.28 - inset); shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset); shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset); shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset); shape.lineTo(half, 0.34); shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0); shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0); return shape; } function makeSeam(parent, points) { const geometry = new THREE.BufferGeometry().setFromPoints(points.map((point) => new THREE.Vector3(...point))); parent.add(new THREE.Line(geometry, seamMaterial)); } const seatRoot = new THREE.Group(); seatRoot.rotation.y = -0.38; seatRoot.position.set(0, 0.02, 0); scene.add(seatRoot); const leftRail = makeBox(seatRoot, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], railMaterial); const rightRail = makeBox(seatRoot, [0.11, 0.08, 2.65], [0.48, 0.12, 0], railMaterial); markAxisTarget(leftRail, 0); markAxisTarget(rightRail, 0); makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structureMaterial); makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structureMaterial); const slideRoot = new THREE.Group(); seatRoot.add(slideRoot); const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structureMaterial); base.rotation.x = Math.PI / 2; base.scale.set(1.42, 1, 0.55); base.position.set(0, 0.34, -0.02); base.castShadow = true; slideRoot.add(base); markAxisTarget(base, 3); const cushionPivot = new THREE.Group(); cushionPivot.position.set(0, 0.57, 0.06); slideRoot.add(cushionPivot); makePanel(cushionPivot, cushionShape(), 0.22, [0, 0, 0.08], [-Math.PI / 2, 0, 0], seatShell); makePanel(cushionPivot, cushionShape(0.16), 0.055, [0, 0.145, 0.03], [-Math.PI / 2, 0, 0], thermalInsert); [-1, 1].forEach((side) => { const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolsterMaterial); wing.rotation.x = Math.PI / 2; wing.position.set(side * 0.61, 0.17, 0.08); wing.castShadow = true; cushionPivot.add(wing); }); makeSeam(cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]]); makeSeam(cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]]); markAxisTarget(cushionPivot, 3); const backrestPivot = new THREE.Group(); backrestPivot.position.set(0, 0.62, -0.5); backrestPivot.rotation.x = -0.09; slideRoot.add(backrestPivot); makePanel(backrestPivot, backrestShape(), 0.3, [0, 0, 0], [0, 0, 0], seatShell); makePanel(backrestPivot, backrestShape(0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert); [-1, 1].forEach((side) => { const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolsterMaterial); wing.position.set(side * 0.61, 0.78, 0.18); wing.scale.set(1, 1, 0.72); wing.castShadow = true; backrestPivot.add(wing); }); makeSeam(backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]]); makeSeam(backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]]); makeSeam(backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]]); makeBox(backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], railMaterial); makeBox(backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], railMaterial); const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), seatShell); headrest.scale.set(1.35, 1, 0.5); headrest.position.set(0, 1.98, 0.01); headrest.castShadow = true; backrestPivot.add(headrest); const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), seatInsert); headrestInsert.scale.set(1.32, 1, 0.28); headrestInsert.position.set(0, 1.98, 0.18); backrestPivot.add(headrestInsert); markAxisTarget(backrestPivot, 2); const legrestPivot = new THREE.Group(); legrestPivot.position.set(0, 0.64, 0.7); legrestPivot.rotation.x = 0.1; slideRoot.add(legrestPivot); const legShell = makePanel(legrestPivot, cushionShape(0.08), 0.18, [0, 0, 0.36], [-Math.PI / 2, 0, 0], seatShell); legShell.scale.set(0.9, 0.72, 0.54); const legInsert = makePanel(legrestPivot, cushionShape(0.2), 0.045, [0, 0.12, 0.4], [-Math.PI / 2, 0, 0], seatInsert); legInsert.scale.set(0.92, 0.74, 0.56); markAxisTarget(legrestPivot, 1); const labelAnchors = { longitudinal: new THREE.Object3D(), height: new THREE.Object3D(), backrest: new THREE.Object3D(), legrest: new THREE.Object3D(), }; labelAnchors.longitudinal.position.set(0.62, 0.16, 0.78); slideRoot.add(labelAnchors.longitudinal); labelAnchors.height.position.set(-0.72, 0.34, -0.08); slideRoot.add(labelAnchors.height); labelAnchors.backrest.position.set(0.62, 1.16, 0.2); backrestPivot.add(labelAnchors.backrest); labelAnchors.legrest.position.set(-0.48, 0.14, 0.42); legrestPivot.add(labelAnchors.legrest); const statusHalo = new THREE.Mesh( new THREE.RingGeometry(1.12, 1.18, 72), new THREE.MeshBasicMaterial({ color: themeAccent.getHex(), transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false, }), ); statusHalo.position.set(0, 1.35, -0.35); statusHalo.renderOrder = 2; seatRoot.add(statusHalo); const palettes = { normal: { shell: mixHex(themeStrong, black, 0.06), insert: mixHex(themeAccent, white, 0.48), bolster: themeAccent.getHex(), emissive: mixHex(themeStrong, black, 0.7), }, working: { shell: themeAccent.getHex(), insert: mixHex(themeAccent, white, 0.58), bolster: mixHex(themeAccent, themeStrong, 0.38), emissive: mixHex(themeAccent, black, 0.54), }, passed: { shell: themeStrong.getHex(), insert: mixHex(themeAccent, white, 0.52), bolster: themeAccent.getHex(), emissive: mixHex(themeStrong, black, 0.62), }, failed: { shell: 0x7a3f43, insert: 0xc97a78, bolster: 0x85494c, emissive: 0x4a100d }, }; const current = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" }; const desired = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" }; const labelLines = labelBindings?.lineRoot ? document.querySelector(labelBindings.lineRoot) : null; const labelAxisByName = { longitudinal: 0, legrest: 1, backrest: 2, height: 3 }; const labelLayout = labelBindings ? Object.entries(labelBindings.items || {}).map(([name, binding]) => ({ name, axis: labelAxisByName[name], side: binding.side === "left" ? "left" : "right", yOffset: Number(binding.yOffset || 0), anchor: labelAnchors[name], tag: document.querySelector(binding.selector), line: labelLines?.querySelector(`[data-seat-label-line="${name}"]`) || null, })).filter((item) => item.anchor && item.tag) : []; const projected = new THREE.Vector3(); const worldPoint = new THREE.Vector3(); function setLabelHidden(item, hidden) { item.tag.dataset.hidden = String(hidden); if (item.line) item.line.dataset.hidden = String(hidden); } function layoutSeatLabels() { if (!labelLayout.length || !viewerHasLayout(viewer)) return; const width = viewer.clientWidth; const height = viewer.clientHeight; const margin = 10; const gap = 8; const lanes = { left: [], right: [] }; labelLayout.forEach((item) => { item.anchor.getWorldPosition(worldPoint); projected.copy(worldPoint).project(camera); const visible = projected.z >= -1 && projected.z <= 1; if (!visible) { setLabelHidden(item, true); return; } const anchorX = (projected.x * 0.5 + 0.5) * width; const anchorY = (-projected.y * 0.5 + 0.5) * height; const tagWidth = Math.max(54, item.tag.offsetWidth); const tagHeight = Math.max(20, item.tag.offsetHeight); const desiredLeft = item.side === "left" ? anchorX - tagWidth - 44 : anchorX + 44; lanes[item.side].push({ ...item, anchorX, anchorY, tagWidth, tagHeight, left: Math.max(margin, Math.min(width - tagWidth - margin, desiredLeft)), desiredTop: Math.max( margin, Math.min(height - tagHeight - margin, anchorY - tagHeight / 2 + item.yOffset), ), }); setLabelHidden(item, false); }); Object.values(lanes).forEach((lane) => { lane.sort((a, b) => a.desiredTop - b.desiredTop); let cursor = margin; lane.forEach((item) => { item.top = Math.max(item.desiredTop, cursor); cursor = item.top + item.tagHeight + gap; }); const overflow = cursor - gap + margin - height; if (overflow > 0) lane.forEach((item) => { item.top -= overflow; }); cursor = margin; lane.forEach((item) => { item.top = Math.max(cursor, Math.min(height - item.tagHeight - margin, item.top)); cursor = item.top + item.tagHeight + gap; }); lane.forEach((item) => { item.tag.style.transform = `translate3d(${item.left.toFixed(1)}px, ${item.top.toFixed(1)}px, 0)`; if (!item.line) return; const endX = item.side === "left" ? item.left + item.tagWidth : item.left; const endY = item.top + item.tagHeight / 2; item.line.setAttribute("x1", item.anchorX.toFixed(1)); item.line.setAttribute("y1", item.anchorY.toFixed(1)); item.line.setAttribute("x2", endX.toFixed(1)); item.line.setAttribute("y2", endY.toFixed(1)); }); }); } function setActiveAxis(axis) { labelLayout.forEach((item) => { item.tag.classList.toggle("active", item.axis === axis); }); } function updateCamera() { const cosPitch = Math.cos(orbit.pitch); camera.position.set( target.x + Math.sin(orbit.yaw) * cosPitch * orbit.distance, target.y + Math.sin(orbit.pitch) * orbit.distance, target.z + Math.cos(orbit.yaw) * cosPitch * orbit.distance, ); camera.lookAt(target); viewer.dataset.orbitYaw = orbit.yaw.toFixed(3); viewer.dataset.orbitPitch = orbit.pitch.toFixed(3); } function resetOrbit() { const aspect = Math.max(0.55, viewer.clientWidth / Math.max(1, viewer.clientHeight)); orbit.yaw = 0.67; orbit.pitch = 0.21; orbit.distance = 6.8 * Math.max(1, Math.min(1.3, 0.78 / aspect)); updateCamera(); } function resize() { const width = Math.max(1, viewer.clientWidth); const height = Math.max(1, viewer.clientHeight); const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); renderer.setPixelRatio(pixelRatio); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } let viewerWasVisible = false; function synchronizeViewport({ reset = false } = {}) { if (!viewerHasLayout(viewer)) { viewerWasVisible = false; return false; } resize(); if (reset || !viewerWasVisible) resetOrbit(); viewerWasVisible = true; return true; } const scheduleViewportSync = () => { window.requestAnimationFrame(() => window.requestAnimationFrame(() => { synchronizeViewport(); })); }; function onHealingState(event) { const detail = event.detail || {}; (detail.axes || []).forEach((axis) => { const motor = Number(axis.motor); if (pointer.active && pointer.mode === "axis" && axisToModelIndex[pointer.axis] === motor) return; if (motor >= 0 && motor < desired.axes.length) { desired.axes[motor] = Math.max(0, Math.min(100, Number(axis.position_pct || 0))); } }); desired.ventilation = Boolean(detail.ventilation); desired.heating = Math.max(0, Math.min(3, Number(detail.heatingLevel || 0))); desired.active = !["IDLE", "PASSED"].includes(detail.state); desired.state = String(detail.state || "IDLE"); statusHalo.material.color.setHex(detail.state === "FAILED" ? 0xc94d51 : themeAccent.getHex()); } function animate() { window.requestAnimationFrame(animate); if (!viewerHasLayout(viewer) || canvas.dataset.contextLost === "true") { viewerWasVisible = false; return; } if (!viewerWasVisible) synchronizeViewport({ reset: true }); for (let index = 0; index < current.axes.length; index += 1) { current.axes[index] += (desired.axes[index] - current.axes[index]) * 0.055; } current.heating += (desired.heating - current.heating) * 0.05; current.ventilation = desired.ventilation; current.active = desired.active; current.state = desired.state; slideRoot.position.z = THREE.MathUtils.mapLinear(current.axes[0], 0, 100, -0.46, 0.46); if (axisMode === "calibration") { slideRoot.position.y = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.12, 0.16); cushionPivot.rotation.x = 0; } else { slideRoot.position.y = 0; cushionPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.08, 0.16); } backrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[2], 0, 100, 0.16, -0.44); legrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[3], 0, 100, 0.08, -0.68); const paletteName = current.state === "FAILED" ? "failed" : current.state === "PASSED" ? "passed" : current.active ? "working" : "normal"; const palette = palettes[paletteName]; seatShell.color.lerp(new THREE.Color(palette.shell), 0.09); seatInsert.color.lerp(new THREE.Color(palette.insert), 0.09); thermalInsert.color.lerp(new THREE.Color(palette.insert), 0.09); bolsterMaterial.color.lerp(new THREE.Color(palette.bolster), 0.09); const heatRatio = current.heating / 3; seatShell.emissive.lerp(new THREE.Color(heatRatio > 0 ? 0x8e2f18 : palette.emissive), 0.08); seatShell.emissiveIntensity = 0.08 + heatRatio * 0.42; thermalInsert.emissive.lerp(new THREE.Color(current.ventilation ? themeAccent.getHex() : palette.emissive), 0.08); thermalInsert.emissiveIntensity = current.ventilation ? 0.32 : 0.06; bolsterMaterial.emissive.lerp(new THREE.Color(palette.emissive), 0.08); bolsterMaterial.emissiveIntensity = current.active ? 0.12 : 0.04; statusHalo.rotation.z += current.active ? 0.006 : 0.0015; statusHalo.material.opacity = current.state === "FAILED" ? 0.2 + Math.sin(performance.now() * 0.005) * 0.06 : current.active ? 0.12 + Math.sin(performance.now() * 0.005) * 0.04 : 0.06; scene.updateMatrixWorld(true); layoutSeatLabels(); renderer.render(scene, camera); } const raycaster = new THREE.Raycaster(); const rayPointer = new THREE.Vector2(); const axisToModelIndex = { 0: 0, 1: 3, 2: 2, 3: 1 }; const labelNameByAxis = { 0: "longitudinal", 1: "legrest", 2: "backrest", 3: "height" }; const fallbackDragVectors = { 0: { x: 1, y: 0 }, 1: { x: -0.7, y: -0.7 }, 2: { x: 0.8, y: 0.6 }, 3: { x: 0, y: -1 }, }; function applyAxisModelPose(axis, value) { if (axis === 0) { slideRoot.position.z = THREE.MathUtils.mapLinear(value, 0, 100, -0.46, 0.46); } else if (axis === 1) { legrestPivot.rotation.x = THREE.MathUtils.mapLinear(value, 0, 100, 0.08, -0.68); } else if (axis === 2) { backrestPivot.rotation.x = THREE.MathUtils.mapLinear(value, 0, 100, 0.16, -0.44); } else if (axis === 3 && axisMode === "calibration") { slideRoot.position.y = THREE.MathUtils.mapLinear(value, 0, 100, -0.12, 0.16); } } function projectAxisAnchor(anchor) { anchor.getWorldPosition(worldPoint); projected.copy(worldPoint).project(camera); return { x: (projected.x * 0.5 + 0.5) * viewer.clientWidth, y: (-projected.y * 0.5 + 0.5) * viewer.clientHeight, }; } function calculateAxisDragVector(axis, value) { const anchor = labelAnchors[labelNameByAxis[axis]]; if (!anchor) return fallbackDragVectors[axis]; const low = Math.max(0, value - 8); const high = Math.min(100, value + 8); if (high - low < 1) return fallbackDragVectors[axis]; const saved = { slideY: slideRoot.position.y, slideZ: slideRoot.position.z, backrestX: backrestPivot.rotation.x, legrestX: legrestPivot.rotation.x, }; applyAxisModelPose(axis, low); scene.updateMatrixWorld(true); const start = projectAxisAnchor(anchor); applyAxisModelPose(axis, high); scene.updateMatrixWorld(true); const end = projectAxisAnchor(anchor); slideRoot.position.y = saved.slideY; slideRoot.position.z = saved.slideZ; backrestPivot.rotation.x = saved.backrestX; legrestPivot.rotation.x = saved.legrestX; scene.updateMatrixWorld(true); const dx = end.x - start.x; const dy = end.y - start.y; const length = Math.hypot(dx, dy); return length > 0.5 ? { x: dx / length, y: dy / length } : fallbackDragVectors[axis]; } function startAxisDrag(axis, event, source = "model") { if (!enableAxisDrag || viewer.dataset.axisControlEnabled !== "true" || !Number.isInteger(axis)) return false; event.preventDefault(); event.stopPropagation(); pointer.active = true; pointer.x = event.clientX; pointer.y = event.clientY; pointer.startX = event.clientX; pointer.startY = event.clientY; pointer.moved = false; pointer.axis = axis; pointer.mode = "axis"; pointer.dragSource = source; const modelIndex = axisToModelIndex[axis]; pointer.startValue = desired.axes[modelIndex]; pointer.dragVector = calculateAxisDragVector(axis, pointer.startValue); viewer.dataset.dragAxis = String(axis); viewer.dataset.axisDragSource = source; viewer.classList.add("axis-dragging"); viewer.classList.remove("axis-hover"); labelLayout.forEach((item) => { item.tag.classList.toggle("dragging", source === "label" && item.axis === axis); }); setActiveAxis(axis); return true; } function axisAtPoint(clientX, clientY) { if (!enableAxisDrag || viewer.dataset.axisControlEnabled !== "true") return null; const bounds = viewer.getBoundingClientRect(); if (clientX < bounds.left || clientX > bounds.right || clientY < bounds.top || clientY > bounds.bottom) return null; rayPointer.set( ((clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1, -((clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1, ); raycaster.setFromCamera(rayPointer, camera); const hit = raycaster.intersectObjects(axisHitTargets, false)[0]; return Number.isInteger(hit?.object?.userData?.calibrationAxis) ? hit.object.userData.calibrationAxis : null; } function updateAxisHover(event) { if (pointer.active) return; const axis = axisAtPoint(event.clientX, event.clientY); viewer.classList.toggle("axis-hover", axis !== null); if (axis === null) delete viewer.dataset.hoverAxis; else viewer.dataset.hoverAxis = String(axis); setActiveAxis(axis); } viewer.addEventListener("pointermove", updateAxisHover); viewer.addEventListener("pointerleave", () => { if (pointer.active) return; viewer.classList.remove("axis-hover"); delete viewer.dataset.hoverAxis; setActiveAxis(null); }); viewer.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; const axis = axisAtPoint(event.clientX, event.clientY); if (axis !== null && startAxisDrag(axis, event)) return; event.preventDefault(); pointer.active = true; pointer.x = event.clientX; pointer.y = event.clientY; pointer.startX = event.clientX; pointer.startY = event.clientY; pointer.moved = false; pointer.axis = null; pointer.mode = "orbit"; pointer.dragSource = "model"; viewer.classList.add("dragging"); }); labelLayout.forEach((item) => { item.tag.dataset.axis = String(item.axis); item.tag.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; startAxisDrag(item.axis, event, "label"); }); item.tag.addEventListener("pointerenter", () => { if (!pointer.active && viewer.dataset.axisControlEnabled === "true") { setActiveAxis(item.axis); } }); item.tag.addEventListener("pointerleave", () => { if (!pointer.active) setActiveAxis(null); }); item.tag.addEventListener("keydown", (event) => { if (viewer.dataset.axisControlEnabled !== "true") return; const direction = ["ArrowRight", "ArrowUp"].includes(event.key) ? 1 : ["ArrowLeft", "ArrowDown"].includes(event.key) ? -1 : 0; if (!direction) return; event.preventDefault(); const modelIndex = axisToModelIndex[item.axis]; const value = Math.max(0, Math.min(100, desired.axes[modelIndex] + direction * 2.5)); desired.axes[modelIndex] = value; const detail = { axis: item.axis, permille: Math.round(value * 10) }; window.dispatchEvent(new CustomEvent("calibration-seat-axis-preview", { detail })); window.dispatchEvent(new CustomEvent("calibration-seat-axis-commit", { detail })); }); }); window.addEventListener("pointermove", (event) => { if (!pointer.active) return; event.preventDefault(); const totalX = event.clientX - pointer.startX; const totalY = event.clientY - pointer.startY; pointer.moved ||= Math.hypot(totalX, totalY) > 3; if (pointer.mode === "axis") { const delta = ( totalX * pointer.dragVector.x + totalY * pointer.dragVector.y ) * 0.55; const value = Math.max(0, Math.min(100, pointer.startValue + delta)); desired.axes[axisToModelIndex[pointer.axis]] = value; window.dispatchEvent(new CustomEvent("calibration-seat-axis-preview", { detail: { axis: pointer.axis, permille: Math.round(value * 10) }, })); return; } orbit.yaw -= (event.clientX - pointer.x) * 0.008; orbit.pitch = Math.max(-0.08, Math.min(0.72, orbit.pitch + (event.clientY - pointer.y) * 0.006)); pointer.x = event.clientX; pointer.y = event.clientY; updateCamera(); }, { passive: false }); const endOrbit = () => { if (pointer.active && pointer.mode === "axis" && pointer.moved) { const value = desired.axes[axisToModelIndex[pointer.axis]]; window.dispatchEvent(new CustomEvent("calibration-seat-axis-commit", { detail: { axis: pointer.axis, permille: Math.round(value * 10) }, })); } pointer.active = false; pointer.axis = null; pointer.mode = "orbit"; pointer.dragSource = "model"; delete viewer.dataset.dragAxis; delete viewer.dataset.axisDragSource; viewer.classList.remove("dragging"); viewer.classList.remove("axis-dragging"); labelLayout.forEach((item) => item.tag.classList.remove("dragging")); setActiveAxis(null); }; window.addEventListener("pointerup", endOrbit); window.addEventListener("pointercancel", endOrbit); window.addEventListener("blur", endOrbit); viewer.addEventListener("wheel", (event) => { event.preventDefault(); orbit.distance = Math.max(5.1, Math.min(10, orbit.distance + event.deltaY * 0.003)); updateCamera(); }, { passive: false }); viewer.addEventListener("dblclick", resetOrbit); window.addEventListener(stateEvent, onHealingState); window.addEventListener(resetEvent, resetOrbit); if (window.ResizeObserver) { const resizeObserver = new ResizeObserver(() => synchronizeViewport()); resizeObserver.observe(viewer); } window.addEventListener("resize", scheduleViewportSync); window.addEventListener("cognitive-agent:module-change", scheduleViewportSync); canvas.addEventListener("webglcontextlost", (event) => { event.preventDefault(); canvas.dataset.contextLost = "true"; viewer.classList.add("context-lost"); }); canvas.addEventListener("webglcontextrestored", () => { delete canvas.dataset.contextLost; viewer.classList.remove("context-lost"); synchronizeViewport({ reset: true }); }); synchronizeViewport({ reset: true }); animate(); window.requestAnimationFrame(() => markReady()); } function init() { initSeat3d({ canvasId: "repairSeatCanvas", viewerId: "repairSeatViewer", stateEvent: "seat-healing-state", resetEvent: "self-repair-seat-reset", }); initSeat3d({ canvasId: "calibrationSeatCanvas", viewerId: "calibrationSeatViewer", stateEvent: "calibration-seat-state", resetEvent: "self-calibration-seat-reset", initialAxes: [50, 50, 20, 0], readyClass: "is-ready", axisMode: "calibration", enableAxisDrag: true, labelBindings: { lineRoot: "#calibrationSeatLabelLines", items: { backrest: { selector: '[data-seat-axis-label="backrest"]', side: "right", yOffset: -28 }, longitudinal: { selector: '[data-seat-axis-label="longitudinal"]', side: "right", yOffset: 24 }, legrest: { selector: '[data-seat-axis-label="legrest"]', side: "left", yOffset: 24 }, height: { selector: '[data-seat-axis-label="height"]', side: "left", yOffset: -18 }, }, }, }); } if (document.readyState === "complete") { init(); } else { document.addEventListener("DOMContentLoaded", init, { once: true }); } }());