Skip to content

Commit ee23208

Browse files
feat(angular-3d): add scene-graph store and injection tokens
- Add SceneGraphStore for centralized Object3D registry - Add OBJECT_ID token for component identification - Add GEOMETRY_SIGNAL token for geometry sharing - Add MATERIAL_SIGNAL token for material sharing - Implement signal-based state management pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 262f1a8 commit ee23208

File tree

5 files changed

+265
-0
lines changed

5 files changed

+265
-0
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* SceneGraphStore - Central registry for Three.js Object3D instances
3+
*
4+
* Uses Angular signals for reactive state management.
5+
* Pattern follows ComponentRegistryService signal patterns.
6+
*/
7+
8+
import { Injectable, signal, computed } from '@angular/core';
9+
import type {
10+
Object3D,
11+
Scene,
12+
PerspectiveCamera,
13+
WebGLRenderer,
14+
Material,
15+
BufferGeometry,
16+
} from 'three';
17+
18+
// ============================================================================
19+
// Interfaces
20+
// ============================================================================
21+
22+
export type Object3DType =
23+
| 'mesh'
24+
| 'light'
25+
| 'camera'
26+
| 'group'
27+
| 'particles'
28+
| 'fog';
29+
30+
export interface ObjectEntry {
31+
readonly object: Object3D;
32+
readonly type: Object3DType;
33+
readonly parentId: string | null;
34+
}
35+
36+
export interface TransformProps {
37+
position?: [number, number, number];
38+
rotation?: [number, number, number];
39+
scale?: [number, number, number];
40+
}
41+
42+
export interface MaterialProps {
43+
color?: number | string;
44+
wireframe?: boolean;
45+
opacity?: number;
46+
transparent?: boolean;
47+
}
48+
49+
// ============================================================================
50+
// SceneGraphStore
51+
// ============================================================================
52+
53+
@Injectable({ providedIn: 'root' })
54+
export class SceneGraphStore {
55+
// Core Three.js objects (provided by Scene3dComponent)
56+
private readonly _scene = signal<Scene | null>(null);
57+
private readonly _camera = signal<PerspectiveCamera | null>(null);
58+
private readonly _renderer = signal<WebGLRenderer | null>(null);
59+
60+
// Object registry
61+
private readonly _registry = signal<Map<string, ObjectEntry>>(new Map());
62+
63+
// ============================================================================
64+
// Public Computed Signals
65+
// ============================================================================
66+
67+
public readonly scene = this._scene.asReadonly();
68+
public readonly camera = this._camera.asReadonly();
69+
public readonly renderer = this._renderer.asReadonly();
70+
71+
public readonly isReady = computed(
72+
() => !!this._scene() && !!this._camera() && !!this._renderer()
73+
);
74+
75+
public readonly objectCount = computed(() => this._registry().size);
76+
77+
public readonly meshes = computed(() =>
78+
[...this._registry()]
79+
.filter(([_, e]) => e.type === 'mesh')
80+
.map(([_, e]) => e.object)
81+
);
82+
83+
public readonly lights = computed(() =>
84+
[...this._registry()]
85+
.filter(([_, e]) => e.type === 'light')
86+
.map(([_, e]) => e.object)
87+
);
88+
89+
// ============================================================================
90+
// Scene Initialization
91+
// ============================================================================
92+
93+
public initScene(
94+
scene: Scene,
95+
camera: PerspectiveCamera,
96+
renderer: WebGLRenderer
97+
): void {
98+
this._scene.set(scene);
99+
this._camera.set(camera);
100+
this._renderer.set(renderer);
101+
}
102+
103+
// ============================================================================
104+
// Object Registration
105+
// ============================================================================
106+
107+
public register(
108+
id: string,
109+
object: Object3D,
110+
type: Object3DType,
111+
parentId?: string
112+
): void {
113+
// Add to parent or scene
114+
const parent = parentId
115+
? this._registry().get(parentId)?.object
116+
: this._scene();
117+
if (parent) {
118+
parent.add(object);
119+
}
120+
121+
// Update registry
122+
this._registry.update((registry) => {
123+
const newRegistry = new Map(registry);
124+
newRegistry.set(id, { object, type, parentId: parentId ?? null });
125+
return newRegistry;
126+
});
127+
}
128+
129+
public update(
130+
id: string,
131+
transform?: TransformProps,
132+
material?: MaterialProps
133+
): void {
134+
const entry = this._registry().get(id);
135+
if (!entry) return;
136+
137+
const obj = entry.object;
138+
139+
// Apply transform updates
140+
if (transform?.position) {
141+
obj.position.set(...transform.position);
142+
}
143+
if (transform?.rotation) {
144+
obj.rotation.set(...transform.rotation);
145+
}
146+
if (transform?.scale) {
147+
obj.scale.set(...transform.scale);
148+
}
149+
150+
// Apply material updates (if mesh with material)
151+
if (material && 'material' in obj && obj.material) {
152+
const mat = obj.material as Material;
153+
if (material.color !== undefined && 'color' in mat && mat.color) {
154+
(mat.color as { set: (value: number | string) => void }).set(
155+
material.color
156+
);
157+
}
158+
if (material.wireframe !== undefined && 'wireframe' in mat) {
159+
(mat as { wireframe: boolean }).wireframe = material.wireframe;
160+
}
161+
if (material.opacity !== undefined) {
162+
mat.opacity = material.opacity;
163+
}
164+
if (material.transparent !== undefined) {
165+
mat.transparent = material.transparent;
166+
}
167+
mat.needsUpdate = true;
168+
}
169+
}
170+
171+
public remove(id: string): void {
172+
const entry = this._registry().get(id);
173+
if (!entry) return;
174+
175+
const { object, parentId } = entry;
176+
177+
// Remove from parent
178+
const parent = parentId
179+
? this._registry().get(parentId)?.object
180+
: this._scene();
181+
parent?.remove(object);
182+
183+
// Dispose resources
184+
this.disposeObject(object);
185+
186+
// Remove from registry
187+
this._registry.update((registry) => {
188+
const newRegistry = new Map(registry);
189+
newRegistry.delete(id);
190+
return newRegistry;
191+
});
192+
}
193+
194+
public getObject<T extends Object3D>(id: string): T | null {
195+
return (this._registry().get(id)?.object as T) ?? null;
196+
}
197+
198+
public queryByType(type: Object3DType): Object3D[] {
199+
return [...this._registry()]
200+
.filter(([_, entry]) => entry.type === type)
201+
.map(([_, entry]) => entry.object);
202+
}
203+
204+
public hasObject(id: string): boolean {
205+
return this._registry().has(id);
206+
}
207+
208+
// ============================================================================
209+
// Disposal
210+
// ============================================================================
211+
212+
private disposeObject(object: Object3D): void {
213+
// Dispose geometry
214+
if ('geometry' in object && object.geometry) {
215+
(object.geometry as BufferGeometry).dispose?.();
216+
}
217+
218+
// Dispose material(s)
219+
if ('material' in object && object.material) {
220+
const materials = Array.isArray(object.material)
221+
? object.material
222+
: [object.material];
223+
materials.forEach((mat: Material) => mat.dispose?.());
224+
}
225+
226+
// Recursively dispose children
227+
object.children.forEach((child) => this.disposeObject(child));
228+
}
229+
230+
public clear(): void {
231+
// Dispose all objects
232+
this._registry().forEach((entry) => this.disposeObject(entry.object));
233+
this._registry.set(new Map());
234+
}
235+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { InjectionToken, WritableSignal } from '@angular/core';
2+
import type { BufferGeometry } from 'three';
3+
4+
/**
5+
* Writable signal for geometry sharing between directives.
6+
* Geometry directives write to this, MeshDirective reads from it.
7+
*/
8+
export const GEOMETRY_SIGNAL = new InjectionToken<
9+
WritableSignal<BufferGeometry | null>
10+
>('GEOMETRY_SIGNAL');
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { OBJECT_ID } from './object-id.token';
2+
export { GEOMETRY_SIGNAL } from './geometry.token';
3+
export { MATERIAL_SIGNAL } from './material.token';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { InjectionToken, WritableSignal } from '@angular/core';
2+
import type { Material } from 'three';
3+
4+
/**
5+
* Writable signal for material sharing between directives.
6+
* Material directives write to this, MeshDirective reads from it.
7+
*/
8+
export const MATERIAL_SIGNAL = new InjectionToken<
9+
WritableSignal<Material | null>
10+
>('MATERIAL_SIGNAL');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { InjectionToken } from '@angular/core';
2+
3+
/**
4+
* Unique object ID for Three.js object registration.
5+
* Each component provides its own ID via this token.
6+
*/
7+
export const OBJECT_ID = new InjectionToken<string>('OBJECT_ID');

0 commit comments

Comments
 (0)