Skip to content

Commit d9f4db1

Browse files
Chau TranChau Tran
Chau Tran
authored and
Chau Tran
committed
feat(soba): migrate gizmo
1 parent 885f10e commit d9f4db1

File tree

18 files changed

+1178
-28
lines changed

18 files changed

+1178
-28
lines changed

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

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,31 @@ export class NgtSignalStore<TState extends object> {
101101
}
102102

103103
set(state: Partial<TState> | ((previous: TState) => Partial<TState>)) {
104-
this.#state.update((previous) => ({
105-
...previous,
106-
...(typeof state === 'function' ? state(previous) : state),
107-
}));
104+
const updater = (previous: TState) => {
105+
const partial = typeof state === 'function' ? state(previous) : state;
106+
Object.keys(partial).forEach((key) => {
107+
const partialKey = key as keyof TState;
108+
if (partial[partialKey] === undefined && previous[partialKey] != null) {
109+
partial[partialKey] = previous[partialKey];
110+
}
111+
});
112+
return partial;
113+
};
114+
this.#state.update((previous) => ({ ...previous, ...updater(previous) }));
115+
// this.#state.update(previous => ({...previous, ...(typeof state === 'function' ? state(previous) : state)}))
108116
}
109117

110118
patch(state: Partial<TState>) {
111-
this.#state.update((previous) => ({
112-
...state,
113-
...previous,
114-
}));
119+
const updater = (previous: TState) => {
120+
Object.keys(state).forEach((key) => {
121+
const partialKey = key as keyof TState;
122+
if (state[partialKey] === undefined && previous[partialKey] != null) {
123+
state[partialKey] = previous[partialKey];
124+
}
125+
});
126+
return state;
127+
};
128+
this.#state.update((previous) => ({ ...updater(previous), ...previous }));
115129
}
116130
}
117131

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { NgTemplateOutlet } from '@angular/common';
2+
import {
3+
Component,
4+
computed,
5+
ContentChild,
6+
CUSTOM_ELEMENTS_SCHEMA,
7+
Directive,
8+
effect,
9+
EventEmitter,
10+
inject,
11+
InjectionToken,
12+
Input,
13+
Output,
14+
Signal,
15+
TemplateRef,
16+
} from '@angular/core';
17+
import { extend, injectNgtRef, NgtPortal, NgtPortalContent, NgtSignalStore, NgtStore } from 'angular-three';
18+
import { NgtsOrthographicCamera } from 'angular-three-soba/cameras';
19+
import { Group, Matrix4, Object3D, OrthographicCamera, Quaternion, Vector3 } from 'three';
20+
import { OrbitControls } from 'three-stdlib';
21+
22+
type ControlsProto = { update(): void; target: THREE.Vector3 };
23+
24+
const isOrbitControls = (controls: ControlsProto): controls is OrbitControls =>
25+
'minPolarAngle' in (controls as OrbitControls);
26+
27+
export type NgtsGizmoHelperApi = (direction: Vector3) => void;
28+
export const NGTS_GIZMO_HELPER_API = new InjectionToken<Signal<NgtsGizmoHelperApi>>('NgtsGizmoHelper API');
29+
30+
extend({ Group });
31+
32+
export interface NgtsGizmoHelperState {
33+
alignment:
34+
| 'top-left'
35+
| 'top-right'
36+
| 'bottom-right'
37+
| 'bottom-left'
38+
| 'bottom-center'
39+
| 'center-right'
40+
| 'center-left'
41+
| 'center-center'
42+
| 'top-center';
43+
margin: [number, number];
44+
renderPriority: number;
45+
autoClear: boolean;
46+
}
47+
48+
@Directive({ selector: 'ng-template[ngtsGizmoHelperContent]', standalone: true })
49+
export class NgtsGizmoHelperContent {}
50+
51+
@Component({
52+
selector: 'ngts-gizmo-helper',
53+
standalone: true,
54+
template: `
55+
<ngt-portal [renderPriority]="priority()">
56+
<ng-template ngtPortalContent>
57+
<ngts-orthographic-camera
58+
[cameraRef]="virtualCameraRef"
59+
[makeDefault]="true"
60+
[position]="[0, 0, 200]"
61+
/>
62+
<ngt-group [ref]="gizmoRef" [position]="position()" (beforeRender)="onBeforeRender($event.state.delta)">
63+
<ng-container *ngTemplateOutlet="gizmoHelperContent" />
64+
</ngt-group>
65+
</ng-template>
66+
</ngt-portal>
67+
`,
68+
imports: [NgtPortal, NgtPortalContent, NgtsOrthographicCamera, NgTemplateOutlet],
69+
providers: [
70+
{ provide: NGTS_GIZMO_HELPER_API, useFactory: (gizmo: NgtsGizmoHelper) => gizmo.api, deps: [NgtsGizmoHelper] },
71+
],
72+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
73+
})
74+
export class NgtsGizmoHelper extends NgtSignalStore<NgtsGizmoHelperState> {
75+
readonly gizmoRef = injectNgtRef<Group>();
76+
readonly virtualCameraRef = injectNgtRef<OrthographicCamera>();
77+
78+
@Input() set alignment(
79+
alignment:
80+
| 'top-left'
81+
| 'top-right'
82+
| 'bottom-right'
83+
| 'bottom-left'
84+
| 'bottom-center'
85+
| 'center-right'
86+
| 'center-left'
87+
| 'center-center'
88+
| 'top-center'
89+
) {
90+
this.set({ alignment });
91+
}
92+
93+
@Input() set margin(margin: [number, number]) {
94+
this.set({ margin });
95+
}
96+
97+
@Input() set renderPriority(renderPriority: number) {
98+
this.set({ renderPriority });
99+
}
100+
101+
@Input() set autoClear(autoClear: boolean) {
102+
this.set({ autoClear });
103+
}
104+
105+
@Output() updated = new EventEmitter<void>();
106+
107+
@ContentChild(NgtsGizmoHelperContent, { static: true, read: TemplateRef })
108+
gizmoHelperContent!: TemplateRef<unknown>;
109+
110+
readonly #store = inject(NgtStore);
111+
readonly #camera = this.#store.select('camera');
112+
readonly #size = this.#store.select('size');
113+
114+
readonly #alignment = this.select('alignment');
115+
readonly #margin = this.select('margin');
116+
117+
#animating = false;
118+
#radius = 0;
119+
#focusPoint = new Vector3(0, 0, 0);
120+
#q1 = new Quaternion();
121+
#q2 = new Quaternion();
122+
#target = new Vector3();
123+
#targetPosition = new Vector3();
124+
#dummy = new Object3D();
125+
126+
#defaultUp = new Vector3(0, 0, 0);
127+
#turnRate = 2 * Math.PI; // turn rate in angles per sec
128+
#matrix = new Matrix4();
129+
130+
readonly priority = this.select('renderPriority');
131+
132+
readonly position = computed(() => {
133+
const size = this.#size();
134+
const alignment = this.#alignment();
135+
const margin = this.#margin();
136+
137+
const [marginX, marginY] = margin;
138+
const x = alignment.endsWith('-center')
139+
? 0
140+
: alignment.endsWith('-left')
141+
? -size.width / 2 + marginX
142+
: size.width / 2 - marginX;
143+
const y = alignment.startsWith('center-')
144+
? 0
145+
: alignment.startsWith('top-')
146+
? size.height / 2 - marginY
147+
: -size.height / 2 + marginY;
148+
149+
return [x, y, 0];
150+
});
151+
152+
readonly api = computed(() => (direction: THREE.Vector3) => {
153+
const { controls, camera, invalidate } = this.#store.get();
154+
const defaultControls = controls as unknown as ControlsProto;
155+
156+
this.#animating = true;
157+
if (defaultControls) this.#focusPoint = defaultControls.target;
158+
this.#radius = camera.position.distanceTo(this.#target);
159+
// rotate from current camera orientation
160+
this.#q1.copy(camera.quaternion);
161+
// to new current camera orientation
162+
this.#targetPosition.copy(direction).multiplyScalar(this.#radius).add(this.#target);
163+
this.#dummy.lookAt(this.#targetPosition);
164+
this.#q2.copy(this.#dummy.quaternion);
165+
invalidate();
166+
});
167+
168+
constructor() {
169+
super({ alignment: 'bottom-right', margin: [80, 80], renderPriority: 1 });
170+
this.#updateDefaultUp();
171+
console.log(this.virtualCameraRef);
172+
}
173+
174+
onBeforeRender(delta: number) {
175+
if (this.virtualCameraRef.nativeElement && this.gizmoRef.nativeElement) {
176+
const { controls, camera: mainCamera, invalidate } = this.#store.get();
177+
const defaultControls = controls as unknown as ControlsProto;
178+
// Animate step
179+
if (this.#animating) {
180+
if (this.#q1.angleTo(this.#q2) < 0.01) {
181+
this.#animating = false;
182+
// Orbit controls uses UP vector as the orbit axes,
183+
// so we need to reset it after the animation is done
184+
// moving it around for the controls to work correctly
185+
if (isOrbitControls(defaultControls)) {
186+
mainCamera.up.copy(this.#defaultUp);
187+
}
188+
} else {
189+
const step = delta * this.#turnRate;
190+
// animate position by doing a slerp and then scaling the position on the unit sphere
191+
this.#q1.rotateTowards(this.#q2, step);
192+
// animate orientation
193+
mainCamera.position
194+
.set(0, 0, 1)
195+
.applyQuaternion(this.#q1)
196+
.multiplyScalar(this.#radius)
197+
.add(this.#focusPoint);
198+
mainCamera.up.set(0, 1, 0).applyQuaternion(this.#q1).normalize();
199+
mainCamera.quaternion.copy(this.#q1);
200+
if (this.updated.observed) this.updated.emit();
201+
else if (defaultControls) {
202+
defaultControls.update();
203+
}
204+
invalidate();
205+
}
206+
}
207+
208+
// Sync Gizmo with main camera orientation
209+
this.#matrix.copy(mainCamera.matrix).invert();
210+
this.gizmoRef.nativeElement.quaternion.setFromRotationMatrix(this.#matrix);
211+
}
212+
}
213+
214+
#updateDefaultUp() {
215+
effect(() => {
216+
const camera = this.#camera();
217+
if (!camera) return;
218+
this.#defaultUp.copy(camera.up);
219+
});
220+
}
221+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as THREE from 'three';
2+
3+
export const colors = { bg: '#f0f0f0', hover: '#999', text: 'black', stroke: 'black' };
4+
export const defaultFaces = ['Right', 'Left', 'Top', 'Bottom', 'Front', 'Back'];
5+
const makePositionVector = (xyz: number[]) => new THREE.Vector3(...xyz).multiplyScalar(0.38);
6+
7+
export const corners: THREE.Vector3[] = [
8+
[1, 1, 1],
9+
[1, 1, -1],
10+
[1, -1, 1],
11+
[1, -1, -1],
12+
[-1, 1, 1],
13+
[-1, 1, -1],
14+
[-1, -1, 1],
15+
[-1, -1, -1],
16+
].map(makePositionVector);
17+
18+
export const cornerDimensions = [0.25, 0.25, 0.25] as [number, number, number];
19+
20+
export const edges: THREE.Vector3[] = [
21+
[1, 1, 0],
22+
[1, 0, 1],
23+
[1, 0, -1],
24+
[1, -1, 0],
25+
[0, 1, 1],
26+
[0, 1, -1],
27+
[0, -1, 1],
28+
[0, -1, -1],
29+
[-1, 1, 0],
30+
[-1, 0, 1],
31+
[-1, 0, -1],
32+
[-1, -1, 0],
33+
].map(makePositionVector);
34+
35+
export const edgeDimensions = edges.map(
36+
(edge) => edge.toArray().map((axis: number): number => (axis == 0 ? 0.5 : 0.25)) as [number, number, number]
37+
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, inject, Input, signal } from '@angular/core';
2+
import { extend, NgtArgs, NgtSignalStore, NgtThreeEvent } from 'angular-three';
3+
import { BoxGeometry, Mesh, MeshBasicMaterial, Vector3 } from 'three';
4+
import { NGTS_GIZMO_HELPER_API } from '../gizmo-helper';
5+
import { colors } from './constants';
6+
import { NgtsGizmoViewcubeInputs } from './gizmo-viewcube-inputs';
7+
8+
extend({ Mesh, BoxGeometry, MeshBasicMaterial });
9+
10+
@Component({
11+
selector: 'ngts-gizmo-viewcube-edge-cube',
12+
standalone: true,
13+
template: `
14+
<ngt-mesh
15+
[scale]="1.01"
16+
[position]="edgePosition()"
17+
(pointermove)="onPointerMove($event)"
18+
(pointerout)="onPointerOut($event)"
19+
(click)="onClick($event)"
20+
>
21+
<ngt-box-geometry *args="edgeDimensions()" />
22+
<ngt-mesh-basic-material
23+
[color]="hover() ? viewcubeInputs.viewcubeHoverColor() : 'white'"
24+
[transparent]="true"
25+
[opacity]="0.6"
26+
[visible]="hover()"
27+
/>
28+
</ngt-mesh>
29+
`,
30+
imports: [NgtArgs],
31+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
32+
})
33+
export class NgtsGizmoViewcubeEdgeCube extends NgtSignalStore<{
34+
position: THREE.Vector3;
35+
dimensions: [number, number, number];
36+
}> {
37+
readonly #gizmoHelperApi = inject(NGTS_GIZMO_HELPER_API);
38+
readonly #cdr = inject(ChangeDetectorRef);
39+
40+
protected readonly viewcubeInputs = inject(NgtsGizmoViewcubeInputs);
41+
42+
hover = signal(false);
43+
44+
@Input({ required: true }) set dimensions(dimensions: [number, number, number]) {
45+
this.set({ dimensions });
46+
}
47+
48+
@Input({ required: true }) set position(position: Vector3) {
49+
this.set({ position });
50+
}
51+
52+
readonly edgePosition = this.select('position');
53+
readonly edgeDimensions = this.select('dimensions');
54+
55+
constructor() {
56+
super();
57+
this.viewcubeInputs.patch({ hoverColor: colors.hover });
58+
}
59+
60+
onPointerMove(event: NgtThreeEvent<PointerEvent>) {
61+
event.stopPropagation();
62+
this.hover.set(true);
63+
this.#cdr.detectChanges();
64+
}
65+
66+
onPointerOut(event: NgtThreeEvent<PointerEvent>) {
67+
event.stopPropagation();
68+
this.hover.set(false);
69+
this.#cdr.detectChanges();
70+
}
71+
72+
onClick(event: NgtThreeEvent<MouseEvent>) {
73+
if (this.viewcubeInputs.get('clickEmitter')?.observed) {
74+
this.viewcubeInputs.get('clickEmitter').emit(event);
75+
} else {
76+
event.stopPropagation();
77+
this.#gizmoHelperApi()(this.get('position'));
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)