import * as THREE from 'three'; function makeSmokeTexture() { const canvas = document.createElement('canvas'); canvas.width = 192; canvas.height = 192; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, 192, 192); const blobs = [ [96, 102, 76, 0.52], [68, 94, 50, 0.34], [126, 82, 55, 0.31], [91, 67, 44, 0.23], [120, 121, 48, 0.26], [57, 121, 40, 0.22] ]; blobs.forEach(([x, y, radius, alpha]) => { const g = ctx.createRadialGradient(x, y, 3, x, y, radius); g.addColorStop(0, `rgba(255,255,255,${alpha})`); g.addColorStop(0.44, `rgba(235,238,242,${alpha * 0.62})`); g.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, 192, 192); }); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; } function findMesh(root, name) { let found = null; if (!root) return null; root.traverse((node) => { if (!found && node.isMesh && node.name === name) found = node; }); return found; } class SmokeEffect { constructor(viewer, config) { this.viewer = viewer; this.config = Object.assign({ targetMesh: '', color: '#8d9399', count: 36, speed: 0.4, size: 0.8, opacity: 0.35, turbulence: 0.55, enabled: true, randomTiming: true }, config || {}); this.group = new THREE.Group(); this.group.name = '__hubbellSmoke'; this.group.userData.__hubbellRuntimeEffect = true; this.viewer.scene.add(this.group); this.texture = makeSmokeTexture(); this.particles = []; this.timeOffset = (this.config.randomTiming === false ? 0 : Math.random() * 1300) + (Number(this.config.timeOffset) || 0); this.lastNow = 0; this.enabled = this.config.enabled !== false; this.rebuild(); this.setEnabled(this.enabled); } setEnabled(on) { this.enabled = !!on; this.group.visible = this.enabled; } isEnabled() { return !!this.enabled; } _clearParticles() { this.particles.forEach((p) => { try { this.group.remove(p.sprite); } catch (e) {} try { p.sprite.material.dispose(); } catch (e) {} }); this.particles = []; } _resetParticle(p, initial = false) { p.life = initial ? Math.random() : 0; p.angle = Math.random() * Math.PI * 2; p.radial = Math.random(); p.seed = Math.random() * 50; p.spin = (Math.random() - 0.5) * 0.55; p.driftX = (Math.random() - 0.5) * 0.42; p.driftZ = (Math.random() - 0.5) * 0.42; p.scale = 0.72 + Math.random() * 0.75; } rebuild() { this._clearParticles(); const target = findMesh(this.viewer.modelRoot, this.config.targetMesh) || this.viewer.modelRoot; const box = new THREE.Box3().setFromObject(target); if (box.isEmpty()) return; const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const dim = Math.max(size.x, size.y, size.z, 0.1); this.origin = new THREE.Vector3(center.x, box.max.y + dim * 0.006, center.z); this.radius = Math.max(dim * 0.018, Math.min(Math.max(size.x, size.z) * 0.16, dim * 0.18)); this.height = Math.max(dim * 0.32, 0.28); this.baseSize = Math.max(dim * 0.055 * Number(this.config.size || 0.8), 0.045); const count = Math.max(8, Math.min(64, Math.round(Number(this.config.count) || 36))); for (let i = 0; i < count; i++) { const material = new THREE.SpriteMaterial({ map: this.texture, color: new THREE.Color(this.config.color || '#8d9399'), transparent: true, opacity: 0, depthWrite: false, depthTest: true, toneMapped: false, blending: THREE.NormalBlending }); const sprite = new THREE.Sprite(material); sprite.renderOrder = 17; this.group.add(sprite); const particle = { sprite }; this._resetParticle(particle, true); this.particles.push(particle); } } update(now) { if (!this.enabled || !this.origin) return; const elapsed = this.lastNow ? Math.min(50, now - this.lastNow) : 16; this.lastNow = now; const dt = elapsed / 1000; const speed = Math.max(0.05, Number(this.config.speed) || 0.4); const opacity = THREE.MathUtils.clamp(Number(this.config.opacity ?? 0.35), 0, 1); const turbulence = THREE.MathUtils.clamp(Number(this.config.turbulence ?? 0.55), 0, 1.5); const color = new THREE.Color(this.config.color || '#8d9399'); const tNow = now + this.timeOffset; this.particles.forEach((p, index) => { p.life += dt * speed * (0.17 + (index % 5) * 0.012); if (p.life >= 1) this._resetParticle(p, false); const life = THREE.MathUtils.clamp(p.life, 0, 1); const fadeIn = THREE.MathUtils.smoothstep(life, 0, 0.13); const fadeOut = 1 - THREE.MathUtils.smoothstep(life, 0.58, 1); const swirl = Math.sin(tNow * 0.00115 + p.seed + life * 5.2) * this.radius * 0.34 * turbulence; const spread = this.radius * (0.35 + life * 1.25) * p.radial; const x = this.origin.x + Math.cos(p.angle + life * turbulence) * spread + p.driftX * this.radius * life + swirl; const z = this.origin.z + Math.sin(p.angle + life * turbulence) * spread + p.driftZ * this.radius * life - swirl * 0.55; const y = this.origin.y + life * this.height; const size = this.baseSize * p.scale * (0.72 + life * 2.3); p.sprite.position.set(x, y, z); p.sprite.scale.set(size, size, 1); p.sprite.material.rotation += p.spin * dt; p.sprite.material.opacity = opacity * fadeIn * fadeOut * (0.64 + 0.25 * Math.sin(tNow * 0.0017 + p.seed)); p.sprite.material.color.copy(color); }); } dispose() { this._clearParticles(); this.viewer.scene.remove(this.group); this.texture.dispose(); } } export function createSmokeEffect(viewer, config) { if (!viewer || !viewer.scene || !viewer.modelRoot) return null; return new SmokeEffect(viewer, config); }