Skip to content

Commit 5f3334a

Browse files
committed
feat: finish canvas
1 parent 16a6611 commit 5f3334a

File tree

5 files changed

+310
-3
lines changed

5 files changed

+310
-3
lines changed

libs/angular-three/src/lib/canvas.ts

Lines changed: 257 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,262 @@
1-
import { Component } from '@angular/core';
1+
import {
2+
ChangeDetectorRef,
3+
Component,
4+
ComponentRef,
5+
createEnvironmentInjector,
6+
ElementRef,
7+
EnvironmentInjector,
8+
EventEmitter,
9+
HostBinding,
10+
inject,
11+
Input,
12+
OnDestroy,
13+
OnInit,
14+
Output,
15+
Type,
16+
ViewChild,
17+
ViewContainerRef,
18+
} from '@angular/core';
19+
import { injectNgxResize, NgxResizeResult, provideNgxResizeOptions } from 'ngx-resize';
20+
import { filter } from 'rxjs';
21+
import { provideNgtRenderer } from './renderer/provider';
22+
import { NgtRxStore } from './stores/rx-store';
23+
import { NgtStore, rootStateMap } from './stores/store';
24+
import type { NgtCanvasInputs, NgtDomEvent, NgtDpr, NgtState } from './types';
25+
import { is } from './utils/is';
26+
import { createPointerEvents } from './web/events';
27+
import { injectNgtLoader } from './loader';
28+
29+
@Component({
30+
selector: 'ngt-canvas-container',
31+
standalone: true,
32+
template: '<ng-content />',
33+
styles: [
34+
`
35+
:host {
36+
display: block;
37+
width: 100%;
38+
height: 100%;
39+
}
40+
`,
41+
],
42+
providers: [provideNgxResizeOptions({ emitInZone: false })],
43+
})
44+
export class NgtCanvasContainer {
45+
@Output() canvasResize = injectNgxResize();
46+
}
247

348
@Component({
449
selector: 'ngt-canvas',
550
standalone: true,
6-
template: `text`,
51+
template: `
52+
<ngt-canvas-container (canvasResize)="onResize($event)">
53+
<canvas #glCanvas style="display: block;"></canvas>
54+
<ng-container #glAnchor />
55+
</ngt-canvas-container>
56+
`,
57+
imports: [NgtCanvasContainer],
58+
providers: [NgtStore],
59+
styles: [
60+
`
61+
:host {
62+
display: block;
63+
position: relative;
64+
width: 100%;
65+
height: 100%;
66+
overflow: hidden;
67+
}
68+
`,
69+
],
770
})
8-
export class NgtCanvas {}
71+
export class NgtCanvas extends NgtRxStore<NgtCanvasInputs> implements OnInit, OnDestroy {
72+
private readonly cdr = inject(ChangeDetectorRef);
73+
private readonly envInjector = inject(EnvironmentInjector);
74+
private readonly host = inject(ElementRef) as ElementRef<HTMLElement>;
75+
private readonly store = inject(NgtStore);
76+
77+
override initialize() {
78+
super.initialize();
79+
this.set({
80+
shadows: false,
81+
linear: false,
82+
flat: false,
83+
legacy: false,
84+
orthographic: false,
85+
frameloop: 'always',
86+
dpr: [1, 2],
87+
events: createPointerEvents,
88+
});
89+
}
90+
91+
@HostBinding('class.ngt-canvas') readonly hostClass = true;
92+
@HostBinding('style.pointerEvents') get pointerEvents() {
93+
return this.get('eventSource') !== this.host.nativeElement ? 'none' : 'auto';
94+
}
95+
96+
@Input() scene!: Type<any>;
97+
@Input() compoundPrefixes: string[] = [];
98+
99+
@Input() set linear(linear: boolean) {
100+
this.set({ linear });
101+
}
102+
103+
@Input() set legacy(legacy: boolean) {
104+
this.set({ legacy });
105+
}
106+
107+
@Input() set flat(flat: boolean) {
108+
this.set({ flat });
109+
}
110+
111+
@Input() set orthographic(orthographic: boolean) {
112+
this.set({ orthographic });
113+
}
114+
115+
@Input() set frameloop(frameloop: NgtCanvasInputs['frameloop']) {
116+
this.set({ frameloop });
117+
}
118+
119+
@Input() set dpr(dpr: NgtDpr) {
120+
this.set({ dpr });
121+
}
122+
123+
@Input() set raycaster(raycaster: Partial<THREE.Raycaster>) {
124+
this.set({ raycaster });
125+
}
126+
127+
@Input() set shadows(shadows: boolean | Partial<THREE.WebGLShadowMap>) {
128+
this.set({
129+
shadows: typeof shadows === 'object' ? (shadows as Partial<THREE.WebGLShadowMap>) : shadows,
130+
});
131+
}
132+
133+
@Input() set camera(camera: NgtCanvasInputs['camera']) {
134+
this.set({ camera });
135+
}
136+
137+
@Input() set gl(gl: NgtCanvasInputs['gl']) {
138+
this.set({ gl });
139+
}
140+
141+
@Input() set eventSource(eventSource: NgtCanvasInputs['eventSource']) {
142+
this.set({ eventSource });
143+
}
144+
145+
@Input() set eventPrefix(eventPrefix: NgtCanvasInputs['eventPrefix']) {
146+
this.set({ eventPrefix });
147+
}
148+
149+
@Input() set lookAt(lookAt: NgtCanvasInputs['lookAt']) {
150+
this.set({ lookAt });
151+
}
152+
153+
@Input() set performance(performance: NgtCanvasInputs['performance']) {
154+
this.set({ performance });
155+
}
156+
157+
@Output() created = new EventEmitter<NgtState>();
158+
@Output() pointerMissed = new EventEmitter<MouseEvent>();
159+
160+
@ViewChild('glCanvas', { static: true }) glCanvas!: ElementRef<HTMLCanvasElement>;
161+
@ViewChild('glAnchor', { static: true, read: ViewContainerRef }) glAnchor!: ViewContainerRef;
162+
163+
private glRef?: ComponentRef<unknown>;
164+
private glEnvInjector?: EnvironmentInjector;
165+
166+
ngOnInit() {
167+
if (!this.get('eventSource')) {
168+
// set default event source to the host element
169+
this.eventSource = this.host.nativeElement;
170+
}
171+
172+
if (this.pointerMissed.observed) {
173+
this.store.set({
174+
onPointerMissed: (event: MouseEvent) => {
175+
this.pointerMissed.emit(event);
176+
this.cdr.detectChanges();
177+
},
178+
});
179+
}
180+
181+
// setup NgtStore
182+
this.store.init();
183+
184+
// set rootStateMap
185+
rootStateMap.set(this.glCanvas.nativeElement, this.store);
186+
187+
// subscribe to store to listen for ready state
188+
this.hold(this.store.select('ready').pipe(filter((ready) => ready)), () => {
189+
this.storeReady();
190+
});
191+
}
192+
193+
onResize({ width, height }: NgxResizeResult) {
194+
if (width > 0 && height > 0) {
195+
if (!this.store.isInit) this.store.init();
196+
this.store.configure(this.get(), this.glCanvas.nativeElement);
197+
}
198+
}
199+
200+
private storeReady() {
201+
// canvas is ready, let's activate the loop
202+
this.store.set((state) => ({ internal: { ...state.internal, active: true } }));
203+
204+
const inputs = this.get();
205+
const state = this.store.get();
206+
207+
// connect to event source
208+
state.events.connect?.(is.ref(inputs.eventSource) ? inputs.eventSource.nativeElement : inputs.eventSource);
209+
210+
// setup compute function for events
211+
if (inputs.eventPrefix) {
212+
state.setEvents({
213+
compute: (event, store) => {
214+
const innerState = store.get();
215+
const x = event[(inputs.eventPrefix + 'X') as keyof NgtDomEvent] as number;
216+
const y = event[(inputs.eventPrefix + 'Y') as keyof NgtDomEvent] as number;
217+
innerState.pointer.set((x / innerState.size.width) * 2 - 1, -(y / innerState.size.height) * 2 + 1);
218+
innerState.raycaster.setFromCamera(innerState.pointer, innerState.camera);
219+
},
220+
});
221+
}
222+
223+
// emit created event if observed
224+
if (this.created.observed) {
225+
this.created.emit(this.store.get());
226+
this.cdr.detectChanges();
227+
}
228+
229+
// render
230+
if (this.glRef) {
231+
this.glRef.destroy();
232+
}
233+
234+
requestAnimationFrame(() => {
235+
this.glEnvInjector = createEnvironmentInjector(
236+
[
237+
provideNgtRenderer({
238+
store: this.store,
239+
changeDetectorRef: this.cdr,
240+
compoundPrefixes: this.compoundPrefixes,
241+
}),
242+
],
243+
this.envInjector
244+
);
245+
this.glRef = this.glAnchor.createComponent(this.scene, { environmentInjector: this.glEnvInjector });
246+
this.glRef.changeDetectorRef.detectChanges();
247+
this.glRef.changeDetectorRef.detach();
248+
this.cdr.detectChanges();
249+
});
250+
}
251+
252+
override ngOnDestroy() {
253+
if (this.glRef) {
254+
this.glRef.destroy();
255+
}
256+
if (this.glEnvInjector) {
257+
this.glEnvInjector.destroy();
258+
}
259+
injectNgtLoader.destroy();
260+
super.ngOnDestroy();
261+
}
262+
}

libs/angular-three/src/lib/loader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,5 @@ function injectLoader<TReturnType, TUrl extends string | string[] | Record<strin
114114
(injectLoader as NgtLoader).preLoad = (loaderConstructorFactory, inputs, extensions) => {
115115
injectLoader(loaderConstructorFactory, inputs, extensions).pipe(take(1)).subscribe();
116116
};
117+
118+
export const injectNgtLoader = injectLoader as NgtLoader;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { InjectionToken } from '@angular/core';
2+
3+
export const NGT_COMPOUND_PREFIXES = new InjectionToken<string[]>('NgtCompoundPrefixes');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ChangeDetectorRef, RendererFactory2 } from '@angular/core';
2+
import { NgtStore } from '../stores/store';
3+
import { NGT_COMPOUND_PREFIXES } from './di';
4+
import { NgtRendererFactory } from './renderer';
5+
6+
export type NgtRendererProviderOptions = {
7+
store: NgtStore;
8+
changeDetectorRef: ChangeDetectorRef;
9+
compoundPrefixes?: string[];
10+
};
11+
12+
export function provideNgtRenderer({ store, changeDetectorRef, compoundPrefixes = [] }: NgtRendererProviderOptions) {
13+
if (!compoundPrefixes.includes('ngts')) {
14+
compoundPrefixes.push('ngts');
15+
}
16+
17+
if (!compoundPrefixes.includes('ngtp')) {
18+
compoundPrefixes.push('ngtp');
19+
}
20+
21+
return [
22+
{ provide: RendererFactory2, useClass: NgtRendererFactory },
23+
{ provide: NgtStore, useValue: store },
24+
{ provide: ChangeDetectorRef, useValue: changeDetectorRef },
25+
{ provide: NGT_COMPOUND_PREFIXES, useValue: compoundPrefixes },
26+
];
27+
}

libs/angular-three/src/lib/stores/rx-store.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Injectable } from '@angular/core';
22
import { RxState } from '@rx-angular/state';
33
import { MonoTypeOperatorFunction, Observable, startWith, tap } from 'rxjs';
4+
import { NgtAnyRecord } from '../types';
5+
import { is } from '../utils/is';
46

57
export const startWithUndefined = <T>(): MonoTypeOperatorFunction<T> => startWith<T>(undefined! as T);
68

@@ -78,6 +80,25 @@ export class NgtRxStore<
7880
this.set({ __ngt_dummy__: '__ngt_dummy__' } as TRxState);
7981
// call initialize that might be setup by derived Stores
8082
this.initialize();
83+
// override set so our consumers don't have to handle undefined for state that already have default values
84+
const originalSet = this.set.bind(this);
85+
Object.defineProperty(this, 'set', {
86+
get: () => {
87+
// Parameters type does not do well with overloads. So we use any[] here
88+
return (...args: any[]) => {
89+
const firstArg = args[0];
90+
if (is.obj(firstArg)) {
91+
const modArgs = Object.entries(firstArg).reduce((modded, [key, value]) => {
92+
modded[key] = value === undefined ? this.get(key as keyof TRxState) : value;
93+
return modded;
94+
}, {} as NgtAnyRecord);
95+
return originalSet(modArgs as Partial<TRxState>);
96+
}
97+
// @ts-ignore
98+
return originalSet(...args);
99+
};
100+
},
101+
});
81102
}
82103

83104
protected initialize() {

0 commit comments

Comments
 (0)