import {inject, Injectable} from '@angular/core'; import * as THREE from 'three'; import {RendererService} from './renderer.service'; import { getDefaultParticleColor, Particle, ParticleData, ParticleInfo, ParticleType, supportsColors } from '../models/particle.model'; import {IntersectionPlaneService, PlaneOrientation} from './intersection-plane.service'; import {deepCopy} from '../../../util/deep-copy.util'; /** * Service responsible for managing particles in the scene */ @Injectable({ providedIn: 'root' }) export class ParticleManagerService { private particles: THREE.Mesh[] = []; private particleData: ParticleData = { particle_name: '', display_name: '', particle_type: ParticleType.TRAIL, lore: '', display_item: 'DIRT', permission: '', package_permission: '', frame_delay: 1, repeat: 1, repeat_delay: 0, random_offset: 0, stationary: true, frames: { 'frame-0': [] } }; private currentFrame: string = 'frame-0'; private frames: string[] = ['frame-0']; private selectedColor: string = '#ff0000'; private selectedParticle: Particle = Particle.DUST; private supports_color: boolean = true; private selectedVelocity: number = 1; private selectedSize: number = 1; private onlyIntersecting: boolean = false; private readonly rendererService = inject(RendererService); private readonly intersectionPlaneService = inject(IntersectionPlaneService); constructor() { this.intersectionPlaneService.planeChanged$.subscribe(() => { if (this.onlyIntersecting) { this.clearParticleVisuals(); this.renderFrameParticles(this.currentFrame); } }); } /** * Adds a particle at the specified position */ addParticle(x: number, y: number, z: number): void { const planeSize = this.intersectionPlaneService.stepSize; const divisions = Math.max(1, Math.floor(planeSize * this.intersectionPlaneService.getGridDensity())); const gridStepPlane = planeSize / divisions; if (this.intersectionPlaneService.getGridSnapEnabled()) { x = Math.round(x / gridStepPlane) * gridStepPlane; y = Math.round(y / gridStepPlane) * gridStepPlane; z = Math.round(z / gridStepPlane) * gridStepPlane; } // Create a visual representation of the particle const particleGeometry = new THREE.SphereGeometry(0.03 * this.selectedSize, 16, 16); const particleMaterial = new THREE.MeshBasicMaterial({color: this.selectedColor}); const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial); particleMesh.position.set(x, y, z); this.rendererService.scene.add(particleMesh); this.particles.push(particleMesh); // Add to particle data const hexColor = this.selectedColor.replace('#', ''); //TODO make this work for more than just type DUST const particleInfo: ParticleInfo = { particle_type: this.selectedParticle, x: x, y: y, z: z, color: hexColor, // color_gradient_end: hexColor2, extra: this.selectedVelocity, size: this.selectedSize }; if (!this.particleData.frames[this.currentFrame]) { this.particleData.frames[this.currentFrame] = []; } this.particleData.frames[this.currentFrame].push(particleInfo); } /** * Clears all particle visuals from the scene */ clearParticleVisuals(): void { for (const particle of this.particles) { this.rendererService.scene.remove(particle); } this.particles = []; } /** * Renders particles for a specific frame */ renderFrameParticles(frameId: string): void { if (!this.particleData.frames[frameId]) return; const filter = this.onlyIntersecting; const orientation = this.intersectionPlaneService.getCurrentOrientation(); const offset16 = this.intersectionPlaneService.getPlanePosition(); const planePos = offset16 / 16; // convert from 1/16th units to world units const epsilon = 0.02; // tolerance for intersection const isOnPlane = (p: ParticleInfo) => { if (!filter) return true; switch (orientation) { case PlaneOrientation.VERTICAL_ABOVE: case PlaneOrientation.VERTICAL_BELOW: // Horizontal plane at y = 0.8 +/- planePos return Math.abs(p.y - (0.8 + (orientation === PlaneOrientation.VERTICAL_BELOW ? planePos : -planePos))) <= epsilon; case PlaneOrientation.HORIZONTAL_FRONT: return Math.abs(p.z - planePos) <= epsilon; case PlaneOrientation.HORIZONTAL_BEHIND: return Math.abs(p.z + planePos) <= epsilon; case PlaneOrientation.HORIZONTAL_RIGHT: return Math.abs(p.x - planePos) <= epsilon; case PlaneOrientation.HORIZONTAL_LEFT: return Math.abs(p.x + planePos) <= epsilon; } }; for (const particleInfo of this.particleData.frames[frameId]) { if (!isOnPlane(particleInfo)) { continue; } const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16); const color = this.getColor(particleInfo); const particleMaterial = new THREE.MeshBasicMaterial({color}); const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial); particleMesh.position.set(particleInfo.x, particleInfo.y, particleInfo.z); this.rendererService.scene.add(particleMesh); this.particles.push(particleMesh); } } /** * Removes a particle from a specific frame */ removeParticle(frameId: string, index: number): void { if (this.particleData.frames[frameId] && this.particleData.frames[frameId].length > index) { this.particleData.frames[frameId].splice(index, 1); // Update visuals if this is the current frame if (frameId === this.currentFrame) { this.clearParticleVisuals(); this.renderFrameParticles(frameId); } } } highlightParticle(frameId: string, index: number): void { if (!(this.particleData.frames[frameId] && this.particleData.frames[frameId].length > index)) { return; } const particleInfo = this.particleData.frames[frameId][index]; const color = this.getColor(particleInfo); const particleMaterial = new THREE.MeshBasicMaterial({color}); const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16); const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial); particleMesh.position.set(particleInfo.x, particleInfo.y, particleInfo.z); this.rendererService.scene.add(particleMesh); this.particles.push(particleMesh); this.animatePulse(particleMesh, 3, () => { this.rendererService.scene.remove(particleMesh); this.clearParticleVisuals(); this.renderFrameParticles(this.currentFrame); }); } private animatePulse(mesh: THREE.Mesh, cycles: number, onComplete: () => void): void { const duration = 300; const maxScale = 0.08 / 0.03; const startTime = performance.now(); const animate = (time: number) => { const elapsed = (time - startTime) % duration; const t = elapsed / (duration / 2); const scaleFactor = t <= 1 ? 1 + (maxScale - 1) * t : maxScale - (maxScale - 1) * (t - 1); mesh.scale.setScalar(scaleFactor); if (time - startTime >= duration * cycles) { onComplete(); } else { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); } private getColor(particleInfo: ParticleInfo) { if (particleInfo.color) { const r = parseInt(particleInfo.color.substring(0, 2), 16) / 255; const g = parseInt(particleInfo.color.substring(2, 4), 16) / 255; const b = parseInt(particleInfo.color.substring(4, 6), 16) / 255; return new THREE.Color(r, g, b); } else { return new THREE.Color(255, 0, 0); } } /** * Sets the selected color for new particles */ setSelectedColor(color: string): void { this.selectedColor = color; } public get supportsColor(): boolean { return this.supports_color; } /** * Gets the selected color */ getSelectedColor(): string { return this.selectedColor; } public get particle(): Particle { return this.selectedParticle; } public set particle(selectedParticle: Particle) { this.selectedParticle = selectedParticle; this.supports_color = supportsColors(selectedParticle); if (!this.supports_color) { const defaultParticleColor = getDefaultParticleColor(selectedParticle); if (defaultParticleColor) { this.selectedColor = defaultParticleColor; } } } public get size(): number { return this.selectedSize; } public set size(selectedSize: number) { this.selectedSize = selectedSize; } public get velocity(): number { return this.selectedVelocity; } public set velocity(velocity: number) { this.selectedVelocity = velocity; } /** * Gets the particle data */ getParticleData(): ParticleData { return this.particleData; } /** * Sets the particle data */ setParticleData(data: ParticleData): void { this.particleData = data; } /** * Gets the current frame */ getCurrentFrame(): string { return this.currentFrame; } /** * Sets the current frame */ setCurrentFrame(frameId: string): void { this.currentFrame = frameId; } /** * Gets all frames */ getFrames(): string[] { return this.frames; } /** * Sets all frames */ setFrames(frames: string[]): void { this.frames = frames; } /** * Generates JSON output of the particle data */ generateJson(): string { const particleData = deepCopy(this.particleData) if (this.particleData.package_permission) { particleData.package_permission = 'apart.set.' + this.particleData.package_permission.toLowerCase().replace(' ', '-'); } else { particleData.package_permission = 'apart.set.none'; } particleData.permission = 'apart.particle.' + this.particleData.permission.toLowerCase().replace(' ', '-'); return JSON.stringify(this.particleData, null, 2); } public get onlyIntersectingParticles(): boolean { return this.onlyIntersecting; } public set onlyIntersectingParticles(value: boolean) { this.onlyIntersecting = value; this.clearParticleVisuals(); this.renderFrameParticles(this.currentFrame); } public loadParticleData(data: string): void { this.particleData = JSON.parse(data); this.setCurrentFrame('frame-0'); this.frames = Object.keys(this.particleData.frames); } }