// Interactive 3D wall-unit showcase — cooling/heating mode switch. // Built entirely from Three.js primitives (no external .glb/.gltf file): // we don't have a licensed 3D asset of an AC unit to ship, and sourcing a // random one from the web is the same integrity problem the project's real // project photos were screened for earlier — brand marks, unclear license, // unverifiable origin. A procedural low-poly model sidesteps all of that, // stays tiny (no asset download), and is fully theme-able in code. const D3_MODES = { cooling: { light: 0x2b85f0, glow: 0x2b85f0, particle: 0x8fc0f4, ledEmissive: 0x2b85f0 }, heating: { light: 0xff7a1a, glow: 0xff7a1a, particle: 0xffb37a, ledEmissive: 0xff7a1a }, }; const D3_PARTICLE_COUNT = 260; function makeGlowTexture() { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); const grad = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); grad.addColorStop(0, 'rgba(255,255,255,0.9)'); grad.addColorStop(0.4, 'rgba(255,255,255,0.35)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, size, size); return new THREE.CanvasTexture(canvas); } function buildUnit() { const group = new THREE.Group(); const bodyMat = new THREE.MeshStandardMaterial({ color: 0xf5f8fc, metalness: 0.15, roughness: 0.35 }); const darkMat = new THREE.MeshStandardMaterial({ color: 0x101823, metalness: 0.2, roughness: 0.5 }); const trimMat = new THREE.MeshStandardMaterial({ color: 0xc7d5e6, metalness: 0.3, roughness: 0.4 }); const body = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.9, 0.7), bodyMat); body.castShadow = false; group.add(body); const frontPanel = new THREE.Mesh(new THREE.BoxGeometry(3.0, 0.72, 0.06), bodyMat); frontPanel.position.set(0, 0, 0.36); group.add(frontPanel); const vents = new THREE.Mesh(new THREE.BoxGeometry(2.7, 0.42, 0.05), darkMat); vents.position.set(0, -0.06, 0.4); group.add(vents); for (let i = -3; i <= 3; i++) { const line = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.015, 0.06), trimMat); line.position.set(0, -0.06 + i * 0.05, 0.42); group.add(line); } const led = new THREE.Mesh( new THREE.SphereGeometry(0.035, 12, 12), new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0x2b85f0, emissiveIntensity: 1.4 }) ); led.position.set(1.35, 0.28, 0.39); group.add(led); const flap = new THREE.Mesh(new THREE.BoxGeometry(2.7, 0.32, 0.04), trimMat); flap.position.set(0, -0.42, 0.32); flap.rotation.x = -0.55; group.add(flap); return { group, led, flap }; } function HeatPump3D({ t }) { const mountRef = React.useRef(null); const [loading, setLoading] = React.useState(true); const [mode, setMode] = React.useState('cooling'); const modeRef = React.useRef('cooling'); React.useEffect(() => { modeRef.current = mode; }, [mode]); React.useEffect(() => { const mount = mountRef.current; if (!mount || typeof THREE === 'undefined') return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(38, mount.clientWidth / mount.clientHeight, 0.1, 50); camera.position.set(3.4, 1.6, 4.2); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(mount.clientWidth, mount.clientHeight); mount.appendChild(renderer.domElement); const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 2.6; controls.maxDistance = 8; controls.maxPolarAngle = Math.PI * 0.62; controls.autoRotate = true; controls.autoRotateSpeed = 0.9; controls.enablePan = false; controls.target.set(0, -0.1, 0); const stopAutoRotate = () => { controls.autoRotate = false; }; controls.addEventListener('start', stopAutoRotate); const ambient = new THREE.AmbientLight(0xffffff, 0.55); scene.add(ambient); const key = new THREE.DirectionalLight(0xffffff, 0.7); key.position.set(2, 3, 2); scene.add(key); const modeLight = new THREE.PointLight(D3_MODES.cooling.light, 2.2, 8, 2); modeLight.position.set(0, 0.4, 1.4); scene.add(modeLight); const { group: unit, led, flap } = buildUnit(); scene.add(unit); const glowTexture = makeGlowTexture(); const glowMat = new THREE.SpriteMaterial({ map: glowTexture, color: D3_MODES.cooling.glow, transparent: true, opacity: 0.35, depthWrite: false }); const glow = new THREE.Sprite(glowMat); glow.scale.set(4.2, 4.2, 1); glow.position.set(0, -0.3, -0.2); scene.add(glow); const pCount = D3_PARTICLE_COUNT; const positions = new Float32Array(pCount * 3); const speeds = new Float32Array(pCount); for (let i = 0; i < pCount; i++) { positions[i * 3] = (Math.random() - 0.5) * 2.4; positions[i * 3 + 1] = 0.1 - Math.random() * 1.6; positions[i * 3 + 2] = 0.3 + Math.random() * 1.6; speeds[i] = 0.4 + Math.random() * 0.6; } const particleGeo = new THREE.BufferGeometry(); particleGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const particleMat = new THREE.PointsMaterial({ color: D3_MODES.cooling.particle, size: 0.045, transparent: true, opacity: 0.85, depthWrite: false, map: glowTexture, blending: THREE.AdditiveBlending, }); const particles = new THREE.Points(particleGeo, particleMat); scene.add(particles); const currentLightColor = new THREE.Color(D3_MODES.cooling.light); const currentParticleColor = new THREE.Color(D3_MODES.cooling.particle); const currentGlowColor = new THREE.Color(D3_MODES.cooling.glow); const targetLightColor = new THREE.Color(D3_MODES.cooling.light); const targetParticleColor = new THREE.Color(D3_MODES.cooling.particle); const targetGlowColor = new THREE.Color(D3_MODES.cooling.glow); let raf = null; let visible = true; const clock = new THREE.Clock(); function animate() { raf = requestAnimationFrame(animate); if (!visible) return; const dt = Math.min(clock.getDelta(), 0.05); const m = D3_MODES[modeRef.current]; targetLightColor.set(m.light); targetParticleColor.set(m.particle); targetGlowColor.set(m.glow); currentLightColor.lerp(targetLightColor, 0.06); currentParticleColor.lerp(targetParticleColor, 0.06); currentGlowColor.lerp(targetGlowColor, 0.06); modeLight.color.copy(currentLightColor); particleMat.color.copy(currentParticleColor); glowMat.color.copy(currentGlowColor); const dir = modeRef.current === 'heating' ? -1 : 1; const posAttr = particleGeo.attributes.position; for (let i = 0; i < pCount; i++) { let y = posAttr.getY(i) + dir * speeds[i] * dt * 0.6; let z = posAttr.getZ(i) + dt * speeds[i] * 0.5; if (dir === 1 ? y < -1.7 : y > 0.15) { y = dir === 1 ? 0.1 : -1.7; } if (z > 2.1) z = 0.3; posAttr.setY(i, y); posAttr.setZ(i, z); } posAttr.needsUpdate = true; flap.rotation.x = -0.55 + Math.sin(clock.elapsedTime * 1.4) * 0.05; led.material.emissive.copy(currentLightColor); controls.update(); renderer.render(scene, camera); } animate(); const io = new IntersectionObserver((entries) => { entries.forEach((e) => { visible = e.isIntersecting; }); }, { threshold: 0.05 }); io.observe(mount); const ro = new ResizeObserver(() => { if (!mount.clientWidth || !mount.clientHeight) return; camera.aspect = mount.clientWidth / mount.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(mount.clientWidth, mount.clientHeight); }); ro.observe(mount); const loadTimer = setTimeout(() => setLoading(false), 260); return () => { clearTimeout(loadTimer); cancelAnimationFrame(raf); io.disconnect(); ro.disconnect(); controls.removeEventListener('start', stopAutoRotate); controls.dispose(); renderer.dispose(); particleGeo.dispose(); particleMat.dispose(); glowTexture.dispose(); glowMat.dispose(); if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement); }; }, []); return (
{t.d3Tag}

{t.d3H2}

{t.d3P}

{loading && (
{t.d3Loading}
)}
{t.d3Hint}
); } Object.assign(window, { HeatPump3D });