Skip to content

Commit faf6867

Browse files
committed
feat(soba): add transform controls
1 parent 4fff379 commit faf6867

File tree

3 files changed

+320
-0
lines changed

3 files changed

+320
-0
lines changed

libs/soba/controls/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './lib/camera-controls';
22
export * from './lib/orbit-controls';
33
export * from './lib/pivot-controls/pivot-controls';
44
export * from './lib/scroll-controls';
5+
export * from './lib/transform-controls';
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
computed,
5+
CUSTOM_ELEMENTS_SCHEMA,
6+
effect,
7+
ElementRef,
8+
input,
9+
output,
10+
Signal,
11+
viewChild,
12+
} from '@angular/core';
13+
import {
14+
extend,
15+
getLocalState,
16+
injectStore,
17+
NgtArgs,
18+
NgtGroup,
19+
NgtObject3DNode,
20+
omit,
21+
pick,
22+
resolveRef,
23+
} from 'angular-three';
24+
import { Camera, Group, Object3D, Event as ThreeEvent } from 'three';
25+
import { TransformControls } from 'three-stdlib';
26+
27+
interface ControlsProto {
28+
enabled: boolean;
29+
}
30+
31+
export type NgtsTransformControlsObject = NgtObject3DNode<TransformControls, typeof TransformControls>;
32+
33+
export type NgtsTransformControlsOptions = Partial<NgtsTransformControlsObject> &
34+
Partial<NgtGroup> & {
35+
enabled?: boolean;
36+
axis?: string | null;
37+
domElement?: HTMLElement;
38+
mode?: 'translate' | 'rotate' | 'scale';
39+
translationSnap?: number | null;
40+
rotationSnap?: number | null;
41+
scaleSnap?: number | null;
42+
space?: 'world' | 'local';
43+
size?: number;
44+
showX?: boolean;
45+
showY?: boolean;
46+
showZ?: boolean;
47+
camera?: Camera;
48+
makeDefault?: boolean;
49+
};
50+
51+
@Component({
52+
selector: 'ngts-transform-controls',
53+
standalone: true,
54+
template: `
55+
<ngt-primitive *args="[controls()]" [parameters]="controlsOptions()" />
56+
57+
<ngt-group #group [parameters]="parameters()">
58+
<ng-content />
59+
</ngt-group>
60+
`,
61+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
62+
changeDetection: ChangeDetectionStrategy.OnPush,
63+
imports: [NgtArgs],
64+
})
65+
export class NgtsTransformControls {
66+
object = input<Object3D | ElementRef<Object3D> | undefined | null>(null);
67+
options = input({} as Partial<NgtsTransformControlsOptions>);
68+
parameters = omit(this.options, [
69+
'enabled',
70+
'axis',
71+
'mode',
72+
'translationSnap',
73+
'rotationSnap',
74+
'scaleSnap',
75+
'space',
76+
'size',
77+
'showX',
78+
'showY',
79+
'showZ',
80+
'domElement',
81+
'makeDefault',
82+
'camera',
83+
]);
84+
85+
protected controlsOptions = pick(this.options, [
86+
'enabled',
87+
'axis',
88+
'mode',
89+
'translationSnap',
90+
'rotationSnap',
91+
'scaleSnap',
92+
'space',
93+
'size',
94+
'showX',
95+
'showY',
96+
'showZ',
97+
]);
98+
99+
private camera = pick(this.options, 'camera');
100+
private domElement = pick(this.options, 'domElement');
101+
private makeDefault = pick(this.options, 'makeDefault');
102+
103+
change = output<ThreeEvent>();
104+
mouseDown = output<ThreeEvent>();
105+
mouseUp = output<ThreeEvent>();
106+
objectChange = output<ThreeEvent>();
107+
108+
private groupRef = viewChild.required<ElementRef<Group>>('group');
109+
110+
private store = injectStore();
111+
private glDomElement = this.store.select('gl', 'domElement');
112+
private defaultCamera = this.store.select('camera');
113+
private events = this.store.select('events');
114+
private defaultControls = this.store.select('controls') as unknown as Signal<ControlsProto>;
115+
private invalidate = this.store.select('invalidate');
116+
117+
controls = computed(() => {
118+
let camera = this.camera();
119+
if (!camera) {
120+
camera = this.defaultCamera();
121+
}
122+
123+
let domElement = this.domElement();
124+
if (!domElement) {
125+
domElement = this.events().connected || this.glDomElement();
126+
}
127+
128+
return new TransformControls(camera, domElement);
129+
});
130+
131+
constructor() {
132+
extend({ Group });
133+
effect((onCleanup) => {
134+
const cleanup = this.attachControlsEffect();
135+
onCleanup(() => cleanup?.());
136+
});
137+
effect((onCleanup) => {
138+
const cleanup = this.disableDefaultControlsEffect();
139+
onCleanup(() => cleanup?.());
140+
});
141+
effect((onCleanup) => {
142+
const cleanup = this.setupControlsEventsEffect();
143+
onCleanup(() => cleanup?.());
144+
});
145+
effect((onCleanup) => {
146+
const cleanup = this.updateDefaultControlsEffect();
147+
onCleanup(() => cleanup?.());
148+
});
149+
}
150+
151+
private attachControlsEffect() {
152+
const group = this.groupRef().nativeElement;
153+
if (!group) return;
154+
155+
const localState = getLocalState(group);
156+
if (!localState) return;
157+
158+
const [object, controls] = [resolveRef(this.object()), this.controls(), localState.objects()];
159+
if (object) {
160+
controls.attach(object);
161+
} else {
162+
controls.attach(group);
163+
}
164+
165+
return () => controls.detach();
166+
}
167+
168+
private disableDefaultControlsEffect() {
169+
const defaultControls = this.defaultControls();
170+
if (!defaultControls) return;
171+
172+
const controls = this.controls();
173+
const callback = (event: any) => {
174+
defaultControls.enabled = !event.value;
175+
};
176+
177+
// @ts-expect-error - three-stdlib types are not up-to-date
178+
controls.addEventListener('dragging-changed', callback);
179+
return () => {
180+
// @ts-expect-error - three-stdlib types are not up-to-date
181+
controls.removeEventListener('dragging-changed', callback);
182+
};
183+
}
184+
185+
private setupControlsEventsEffect() {
186+
const [controls, invalidate] = [this.controls(), this.invalidate()];
187+
const onChange = (event: ThreeEvent) => {
188+
invalidate();
189+
if (!this.change['destroyed']) {
190+
this.change.emit(event);
191+
}
192+
};
193+
const onMouseDown = this.mouseDown.emit.bind(this.mouseDown);
194+
const onMouseUp = this.mouseUp.emit.bind(this.mouseUp);
195+
const onObjectChange = this.objectChange.emit.bind(this.objectChange);
196+
197+
// @ts-expect-error - three-stdlib types are not up-to-date
198+
controls.addEventListener('change', onChange);
199+
// @ts-expect-error - three-stdlib types are not up-to-date
200+
controls.addEventListener('mouseDown', onMouseDown);
201+
// @ts-expect-error - three-stdlib types are not up-to-date
202+
controls.addEventListener('mouseUp', onMouseUp);
203+
// @ts-expect-error - three-stdlib types are not up-to-date
204+
controls.addEventListener('objectChange', onObjectChange);
205+
206+
return () => {
207+
// @ts-expect-error - three-stdlib types are not up-to-date
208+
controls.removeEventListener('change', onChange);
209+
// @ts-expect-error - three-stdlib types are not up-to-date
210+
controls.removeEventListener('mouseDown', onMouseDown);
211+
// @ts-expect-error - three-stdlib types are not up-to-date
212+
controls.removeEventListener('mouseUp', onMouseUp);
213+
// @ts-expect-error - three-stdlib types are not up-to-date
214+
controls.removeEventListener('objectChange', onObjectChange);
215+
};
216+
}
217+
218+
private updateDefaultControlsEffect() {
219+
const [controls, makeDefault] = [this.controls(), this.makeDefault()];
220+
if (!makeDefault) return;
221+
222+
const oldControls = this.store.snapshot.controls;
223+
this.store.update({ controls });
224+
return () => this.store.update(() => ({ controls: oldControls }));
225+
}
226+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, signal, viewChild } from '@angular/core';
2+
import { Meta } from '@storybook/angular';
3+
import { NgtMesh } from 'angular-three';
4+
import { NgtsOrbitControls, NgtsTransformControls } from 'angular-three-soba/controls';
5+
import { makeDecorators, makeStoryFunction } from '../setup-canvas';
6+
7+
@Component({
8+
standalone: true,
9+
template: `
10+
<ngt-mesh #meshOne [position]="[-1, 0, 0]" (click)="selected.set(meshOne)">
11+
<ngt-box-geometry />
12+
<ngt-mesh-basic-material [wireframe]="selected() === meshOne" color="orange" />
13+
</ngt-mesh>
14+
<ngt-mesh #meshTwo [position]="[0, 0, 0]" (click)="selected.set(meshTwo)">
15+
<ngt-box-geometry />
16+
<ngt-mesh-basic-material [wireframe]="selected() === meshTwo" color="green" />
17+
</ngt-mesh>
18+
19+
@if (selected(); as selected) {
20+
<ngts-transform-controls [object]="$any(selected)" />
21+
}
22+
`,
23+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
24+
changeDetection: ChangeDetectionStrategy.OnPush,
25+
imports: [NgtsTransformControls],
26+
host: {
27+
'(document:keydown)': 'onKeyDown($event)',
28+
},
29+
})
30+
class WithSelectionStory {
31+
selected = signal<NgtMesh | null>(null);
32+
33+
onKeyDown(event: KeyboardEvent) {
34+
if (event.key === 'Escape') {
35+
this.selected.set(null);
36+
}
37+
}
38+
}
39+
40+
@Component({
41+
standalone: true,
42+
imports: [NgtsTransformControls, NgtsOrbitControls],
43+
template: `
44+
<ngts-transform-controls>
45+
<ngt-mesh>
46+
<ngt-box-geometry />
47+
<ngt-mesh-basic-material [wireframe]="true" />
48+
</ngt-mesh>
49+
</ngts-transform-controls>
50+
51+
<ngts-orbit-controls [options]="{ makeDefault: true }" />
52+
`,
53+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
54+
changeDetection: ChangeDetectionStrategy.OnPush,
55+
})
56+
class LockControlsStory {}
57+
58+
@Component({
59+
standalone: true,
60+
template: `
61+
<ngts-transform-controls>
62+
<ngt-mesh>
63+
<ngt-box-geometry />
64+
<ngt-mesh-basic-material [wireframe]="true" />
65+
</ngt-mesh>
66+
</ngts-transform-controls>
67+
`,
68+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
69+
changeDetection: ChangeDetectionStrategy.OnPush,
70+
imports: [NgtsTransformControls],
71+
host: {
72+
'(document:keydown)': 'onKeyDown($event)',
73+
},
74+
})
75+
class DefaultTransformControlsStory {
76+
private transformControls = viewChild.required(NgtsTransformControls);
77+
78+
onKeyDown(event: KeyboardEvent) {
79+
const transformControls = this.transformControls().controls();
80+
if (event.key === 'Escape') {
81+
transformControls.reset();
82+
}
83+
}
84+
}
85+
86+
export default {
87+
title: 'Controls/TransformControls',
88+
decorators: makeDecorators(),
89+
} as Meta;
90+
91+
export const Default = makeStoryFunction(DefaultTransformControlsStory);
92+
export const LockControls = makeStoryFunction(LockControlsStory, { controls: false });
93+
export const WithSelection = makeStoryFunction(WithSelectionStory);

0 commit comments

Comments
 (0)