Compare commits

...

8 Commits

13 changed files with 176 additions and 110 deletions

View File

@ -1,15 +1,15 @@
<div class="card-div"> <div class="card-div">
<mat-card class="particle-card"> <mat-card class="particle-card">
<mat-card-header> <mat-card-header>
<mat-card-title>Particle Properties</mat-card-title> <mat-card-title>Particle input style</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="particle-properties"> <div class="particle-properties">
<div class="property-row"> <div class="property-row">
<div class="color-picker"> <mat-form-field appearance="outline">
<input type="color" [(ngModel)]="selectedColor"> <mat-label>Current color: {{ selectedColor }}</mat-label>
<span>Current color: {{ selectedColor }}</span> <input type="color" class="color-input" matInput [(ngModel)]="selectedColor">
</div> </mat-form-field>
<mat-form-field appearance="outline" class="type-field"> <mat-form-field appearance="outline" class="type-field">
<mat-label>Select Particle Type</mat-label> <mat-label>Select Particle Type</mat-label>
<input type="text" <input type="text"

View File

@ -6,10 +6,10 @@
.particle-properties { .particle-properties {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px;
} }
.property-row { .property-row {
margin-top: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 15px;
@ -22,11 +22,8 @@
max-width: 40ch; max-width: 40ch;
} }
.color-picker { .color-input {
display: flex; height: 1em;
flex: 1;
align-items: center;
gap: 10px;
} }
.color-picker input[type="color"] { .color-picker input[type="color"] {

View File

@ -1,23 +0,0 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ParticleComponent} from './particle.component';
describe('ParticleComponent', () => {
let component: ParticleComponent;
let fixture: ComponentFixture<ParticleComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ParticleComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ParticleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -7,11 +7,22 @@
<div class="form-row"> <div class="form-row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Particle Name</mat-label> <mat-label>Particle Name</mat-label>
<input matInput [(ngModel)]="particleData.particle_name" placeholder="Enter particle name"> <input required matInput [(ngModel)]="particleData.particle_name" placeholder="Enter particle name">
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Display Name</mat-label> <mat-label>Display Name</mat-label>
<input matInput [(ngModel)]="particleData.display_name" placeholder="Enter display name"> <input required matInput [(ngModel)]="particleData.display_name" placeholder="Enter display name">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Permission name</mat-label>
<input required matInput [(ngModel)]="particleData.permission" placeholder="Enter permission">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Package name</mat-label>
<input matInput [(ngModel)]="particleData.package_permission" placeholder="Enter package permission">
</mat-form-field> </mat-form-field>
</div> </div>
@ -26,25 +37,14 @@
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Display Item</mat-label> <mat-label>Display Item</mat-label>
<input matInput [(ngModel)]="particleData.display_item" placeholder="Enter display item"> <input required matInput [(ngModel)]="particleData.display_item" placeholder="Enter display item">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="form-row"> <div class="form-row">
<mat-form-field appearance="outline" class="lore-double"> <mat-form-field appearance="outline" class="lore-double">
<mat-label>Lore</mat-label> <mat-label>Lore</mat-label>
<textarea matInput [(ngModel)]="particleData.lore" placeholder="Enter lore"></textarea> <textarea required matInput [(ngModel)]="particleData.lore" placeholder="Enter lore"></textarea>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Permission name</mat-label>
<input matInput [(ngModel)]="particleData.permission" placeholder="Enter permission">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Package name</mat-label>
<input matInput [(ngModel)]="particleData.package_permission" placeholder="Enter package permission">
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -1,4 +1,4 @@
import {Component} from '@angular/core'; import {Component, inject} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {MatCheckbox} from "@angular/material/checkbox"; import {MatCheckbox} from "@angular/material/checkbox";
import {MatFormField, MatInput, MatLabel} from "@angular/material/input"; import {MatFormField, MatInput, MatLabel} from "@angular/material/input";
@ -24,17 +24,14 @@ import {ParticleManagerService} from '../../services/particle-manager.service';
MatSelect, MatSelect,
ReactiveFormsModule, ReactiveFormsModule,
FormsModule FormsModule
], ],
templateUrl: './properties.component.html', templateUrl: './properties.component.html',
styleUrl: './properties.component.scss' styleUrl: './properties.component.scss'
}) })
export class PropertiesComponent { export class PropertiesComponent {
public particleTypes = Object.values(ParticleType); public particleTypes = Object.values(ParticleType);
constructor( private readonly particleManagerService = inject(ParticleManagerService);
private particleManagerService: ParticleManagerService,
) {
}
public get particleData(): ParticleData { public get particleData(): ParticleData {
return this.particleManagerService.getParticleData(); return this.particleManagerService.getParticleData();

View File

@ -8,12 +8,22 @@
</div> </div>
<div class="button-row"> <div class="button-row">
<button mat-mini-fab color="primary" (click)="resetCamera()" <button mat-mini-fab color="primary" (click)="resetCamera()"
matTooltip="Reset camera"> matTooltip="Reset camera">
<mat-icon>location_searching</mat-icon> <mat-icon>location_searching</mat-icon>
</button> </button>
<button mat-mini-fab color="primary" (click)="toggleShowParticlesWhenIntersectingPlane()"
[matTooltip]="onlyIntersecting ? 'Show all particles' : 'Show only intersecting particles'">
<mat-icon>{{ onlyIntersecting ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="toggleShowCharacter()"
[matTooltip]="showCharacter ? 'Hide character' : 'Show character'">
<mat-icon>{{ showCharacter ? 'person' : 'person_off' }}</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="togglePlaneLock()" <button mat-mini-fab color="primary" (click)="togglePlaneLock()"
[matTooltip]="isPlaneLocked ? 'Unlock Plane' : 'Lock Plane'"> [matTooltip]="isPlaneLocked ? 'Unlock plane' : 'Lock plane'">
<mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon> <mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon>
</button> </button>
</div> </div>
@ -21,33 +31,33 @@
@if (isPlaneLocked) { @if (isPlaneLocked) {
<div class="plane-orientation-buttons"> <div class="plane-orientation-buttons">
<button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_ABOVE)" <button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_ABOVE)"
[class.active]="currentPlaneOrientation === planeOrientations.VERTICAL_ABOVE" [class.active]="currentPlaneOrientation === planeOrientations.VERTICAL_ABOVE"
matTooltip="Vertical Above"> matTooltip="Vertical Above">
<mat-icon>arrow_upward</mat-icon> <mat-icon>arrow_upward</mat-icon>
</button> </button>
<button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_BELOW)" <button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_BELOW)"
[class.active]="currentPlaneOrientation === planeOrientations.VERTICAL_BELOW" [class.active]="currentPlaneOrientation === planeOrientations.VERTICAL_BELOW"
matTooltip="Vertical Below"> matTooltip="Vertical Below">
<mat-icon>arrow_downward</mat-icon> <mat-icon>arrow_downward</mat-icon>
</button> </button>
<button mat-mini-fab color="primary" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_FRONT)" <button mat-mini-fab color="primary" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_FRONT)"
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_FRONT" [class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_FRONT"
matTooltip="Horizontal Front"> matTooltip="Horizontal Front">
<mat-icon>arrow_forward</mat-icon> <mat-icon>arrow_forward</mat-icon>
</button> </button>
<button mat-mini-fab color="primary" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_BEHIND)" <button mat-mini-fab color="primary" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_BEHIND)"
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_BEHIND" [class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_BEHIND"
matTooltip="Horizontal Behind"> matTooltip="Horizontal Behind">
<mat-icon>arrow_back</mat-icon> <mat-icon>arrow_back</mat-icon>
</button> </button>
<button mat-mini-fab color="accent" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_RIGHT)" <button mat-mini-fab color="accent" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_RIGHT)"
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_RIGHT" [class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_RIGHT"
matTooltip="Horizontal Right"> matTooltip="Horizontal Right">
<mat-icon>arrow_right</mat-icon> <mat-icon>arrow_right</mat-icon>
</button> </button>
<button mat-mini-fab color="accent" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_LEFT)" <button mat-mini-fab color="accent" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_LEFT)"
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_LEFT" [class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_LEFT"
matTooltip="Horizontal Left"> matTooltip="Horizontal Left">
<mat-icon>arrow_left</mat-icon> <mat-icon>arrow_left</mat-icon>
</button> </button>
</div> </div>

View File

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RenderContainerComponent } from './render-container.component';
describe('RenderContainerComponent', () => {
let component: RenderContainerComponent;
let fixture: ComponentFixture<RenderContainerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RenderContainerComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RenderContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,4 +1,4 @@
import {AfterViewInit, Component, ElementRef, OnDestroy, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, inject, OnDestroy, ViewChild} from '@angular/core';
import {MatMiniFabButton} from '@angular/material/button'; import {MatMiniFabButton} from '@angular/material/button';
import {IntersectionPlaneService, PlaneOrientation} from '../../services/intersection-plane.service'; import {IntersectionPlaneService, PlaneOrientation} from '../../services/intersection-plane.service';
@ -9,6 +9,7 @@ import {PlayerModelService} from '../../services/player-model.service';
import {InputHandlerService} from '../../services/input-handler.service'; import {InputHandlerService} from '../../services/input-handler.service';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {ParticleManagerService} from '../../services/particle-manager.service';
@Component({ @Component({
selector: 'app-render-container', selector: 'app-render-container',
@ -20,20 +21,18 @@ import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
MatInput, MatInput,
MatFormField, MatFormField,
MatLabel MatLabel
], ],
templateUrl: './render-container.component.html', templateUrl: './render-container.component.html',
styleUrl: './render-container.component.scss' styleUrl: './render-container.component.scss'
}) })
export class RenderContainerComponent implements AfterViewInit, OnDestroy { export class RenderContainerComponent implements AfterViewInit, OnDestroy {
@ViewChild('rendererContainer') rendererContainer!: ElementRef; @ViewChild('rendererContainer') rendererContainer!: ElementRef;
constructor( private readonly intersectionPlaneService = inject(IntersectionPlaneService);
private intersectionPlaneService: IntersectionPlaneService, private readonly playerModelService = inject(PlayerModelService);
private playerModelService: PlayerModelService, private readonly inputHandlerService = inject(InputHandlerService);
private inputHandlerService: InputHandlerService, private readonly rendererService = inject(RendererService);
private rendererService: RendererService, private readonly particleManagerService = inject(ParticleManagerService);
) {
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.initializeScene(); this.initializeScene();
@ -99,6 +98,14 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
this.rendererService.resetCamera(); this.rendererService.resetCamera();
} }
public toggleShowParticlesWhenIntersectingPlane(): void {
this.particleManagerService.onlyIntersectingParticles = !this.particleManagerService.onlyIntersectingParticles;
}
public toggleShowCharacter(): void {
this.playerModelService.showCharacter = !this.playerModelService.showCharacter;
}
/** /**
* Get the current plane orientation * Get the current plane orientation
*/ */
@ -106,6 +113,20 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
return this.intersectionPlaneService.getCurrentOrientation(); return this.intersectionPlaneService.getCurrentOrientation();
} }
/**
* Retrieves the value indicating whether only intersecting particles are being considered.
*/
public get onlyIntersecting(): boolean {
return this.particleManagerService.onlyIntersectingParticles;
}
/**
* Retrieves the value indicating whether the character is being rendered.
*/
public get showCharacter(): boolean {
return this.playerModelService.showCharacter;
}
/** /**
* Set the plane orientation * Set the plane orientation
*/ */

View File

@ -1,6 +1,7 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import {RendererService} from './renderer.service'; import {RendererService} from './renderer.service';
import {Subject} from 'rxjs';
/** /**
* Represents the possible orientations of the intersection plane * Represents the possible orientations of the intersection plane
@ -27,6 +28,10 @@ export class IntersectionPlaneService {
private planeLocked: boolean = false; private planeLocked: boolean = false;
private opacity: number = 0.05; private opacity: number = 0.05;
// Emits whenever plane position, orientation, or lock-affecting orientation updates change visuals
public readonly planeChanged$ = new Subject<void>();
private lastPlaneSignature: string | null = null;
constructor(private rendererService: RendererService) { constructor(private rendererService: RendererService) {
} }
@ -144,6 +149,13 @@ export class IntersectionPlaneService {
this.intersectionPlane.position.x = -position; this.intersectionPlane.position.x = -position;
break; break;
} }
// Notify listeners only if signature changed to avoid spamming during animation frames
const signature = `${this.currentOrientation}|${this.planePosition}`;
if (signature !== this.lastPlaneSignature) {
this.lastPlaneSignature = signature;
this.planeChanged$.next();
}
} }
/** /**

View File

@ -1,7 +1,9 @@
import {Injectable} from '@angular/core'; import {inject, Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import {RendererService} from './renderer.service'; import {RendererService} from './renderer.service';
import {Particle, ParticleData, ParticleInfo, ParticleType} from '../models/particle.model'; import {Particle, ParticleData, ParticleInfo, ParticleType} 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 * Service responsible for managing particles in the scene
@ -33,8 +35,18 @@ export class ParticleManagerService {
private selectedColor: string = '#ff0000'; private selectedColor: string = '#ff0000';
private selectedParticle: Particle = Particle.DUST; private selectedParticle: Particle = Particle.DUST;
private selectedSize: number = 1; private selectedSize: number = 1;
private onlyIntersecting: boolean = false;
constructor(private rendererService: RendererService) { private readonly rendererService = inject(RendererService);
private readonly intersectionPlaneService = inject(IntersectionPlaneService);
constructor() {
this.intersectionPlaneService.planeChanged$.subscribe(() => {
if (this.onlyIntersecting) {
this.clearParticleVisuals();
this.renderFrameParticles(this.currentFrame);
}
});
} }
/** /**
@ -88,7 +100,34 @@ export class ParticleManagerService {
renderFrameParticles(frameId: string): void { renderFrameParticles(frameId: string): void {
if (!this.particleData.frames[frameId]) return; 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]) { 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 particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16);
const color = this.getColor(particleInfo); const color = this.getColor(particleInfo);
@ -243,6 +282,23 @@ export class ParticleManagerService {
* Generates JSON output of the particle data * Generates JSON output of the particle data
*/ */
generateJson(): string { 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); 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);
}
} }

View File

@ -1,4 +1,4 @@
import {Injectable} from '@angular/core'; import {inject, Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import {RendererService} from './renderer.service'; import {RendererService} from './renderer.service';
@ -6,13 +6,13 @@ import {RendererService} from './renderer.service';
providedIn: 'root' providedIn: 'root'
}) })
export class PlayerModelService { export class PlayerModelService {
private readonly rendererService = inject(RendererService);
private playerModel!: THREE.Group; private playerModel!: THREE.Group;
private skinTexture!: THREE.Texture; private skinTexture!: THREE.Texture;
private characterVisible: boolean = true;
private textureLoaded = false; private textureLoaded = false;
constructor(private rendererService: RendererService) {
}
/** /**
* Loads a Minecraft skin texture from a URL * Loads a Minecraft skin texture from a URL
* @param textureUrl The URL of the skin texture to load * @param textureUrl The URL of the skin texture to load
@ -60,6 +60,15 @@ export class PlayerModelService {
return this.playerModel; return this.playerModel;
} }
public get showCharacter(): boolean {
return this.characterVisible;
}
public set showCharacter(showCharacter: boolean) {
this.playerModel.visible = showCharacter;
this.characterVisible = showCharacter;
}
/** /**
* Creates a simple colored player model (without textures) * Creates a simple colored player model (without textures)
*/ */

View File

@ -21,7 +21,7 @@ export class RendererService {
this.themeService.theme$.subscribe(theme => { this.themeService.theme$.subscribe(theme => {
this.currentTheme = theme; this.currentTheme = theme;
if (this.scene) { if (this.scene) {
this.setBackgroundColor(theme); this.setBackgroundColor();
} }
}) })
} }
@ -32,7 +32,7 @@ export class RendererService {
initializeRenderer(container: ElementRef): void { initializeRenderer(container: ElementRef): void {
// Create scene // Create scene
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.setBackgroundColor(this.currentTheme); this.setBackgroundColor();
// Get container dimensions // Get container dimensions
const containerWidth = container.nativeElement.clientWidth; const containerWidth = container.nativeElement.clientWidth;
@ -66,7 +66,7 @@ export class RendererService {
this.addLights(); this.addLights();
} }
private setBackgroundColor(theme: THEME_MODE) { private setBackgroundColor() {
this.scene.background = new THREE.Color(this.currentTheme === THEME_MODE.DARK ? 0x242526 : 0xFBFBFE); this.scene.background = new THREE.Color(this.currentTheme === THEME_MODE.DARK ? 0x242526 : 0xFBFBFE);
} }

View File

@ -0,0 +1,10 @@
export function deepCopy<T>(obj: T): T {
// Structured cloning
if (typeof globalThis?.structuredClone === 'function') {
// @ts-ignore
return globalThis.structuredClone(obj) as T;
}
// Normal objects
return JSON.parse(JSON.stringify(obj)) as T;
}