Skip to content

Commit

Permalink
feat: add gizmo
Browse files Browse the repository at this point in the history
  • Loading branch information
nartc committed Feb 6, 2023
1 parent 66ae208 commit e0f362f
Show file tree
Hide file tree
Showing 13 changed files with 1,081 additions and 9 deletions.
3 changes: 3 additions & 0 deletions libs/angular-three-soba/abstractions/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export * from './lib/billboard/billboard';
export * from './lib/gizmo-helper/gizmo-helper';
export * from './lib/gizmo-helper/gizmo-viewcube/gizmo-viewcube';
export * from './lib/gizmo-helper/gizmo-viewport/gizmo-viewport';
export * from './lib/text-3d/text-3d';
export * from './lib/text/text';
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { NgTemplateOutlet } from '@angular/common';
import {
Component,
ContentChild,
CUSTOM_ELEMENTS_SCHEMA,
Directive,
EventEmitter,
inject,
InjectionToken,
Input,
OnInit,
Output,
TemplateRef,
} from '@angular/core';
import { selectSlice } from '@rx-angular/state';
import { extend, injectNgtRef, NgtPortal, NgtPortalContent, NgtRxStore, NgtStore } from 'angular-three';
import { NgtsOrthographicCamera } from 'angular-three-soba/cameras';
import { combineLatest, map } from 'rxjs';
import { Group, Matrix4, Object3D, OrthographicCamera, Quaternion, Vector3 } from 'three';
import { OrbitControls } from 'three-stdlib';

type ControlsProto = { update(): void; target: THREE.Vector3 };

const isOrbitControls = (controls: ControlsProto): controls is OrbitControls => {
return 'minPolarAngle' in (controls as OrbitControls);
};

export interface NgtsGizmoHelperApi {
tweenCamera: (direction: Vector3) => void;
}

export const NGTS_GIZMO_HELPER_API = new InjectionToken<NgtsGizmoHelperApi>('NgtsGizmoHelper API');

function gizmoHelperApiFactory(gizmo: NgtsGizmoHelper) {
const store = inject(NgtStore);

return {
tweenCamera: (direction: Vector3) => {
const { controls, camera, invalidate } = store.get();
const defaultControls = controls as unknown as ControlsProto;

gizmo.animating = true;
if (defaultControls) gizmo.focusPoint = defaultControls.target;
gizmo.radius = camera.position.distanceTo(gizmo.target);
// rotate from current camera orientation
gizmo.q1.copy(camera.quaternion);
// to new current camera orientation
gizmo.targetPosition.copy(direction).multiplyScalar(gizmo.radius).add(gizmo.target);
gizmo.dummy.lookAt(gizmo.targetPosition);
gizmo.q2.copy(gizmo.dummy.quaternion);
invalidate();
},
};
}

extend({ Group });

@Directive({
selector: 'ng-template[ngtsGizmoHelperContent]',
standalone: true,
})
export class NgtsGizmoHelperContent {}

@Component({
selector: 'ngts-gizmo-helper',
standalone: true,
template: `
<ngt-portal [renderPriority]="get('renderPriority')">
<ng-template ngtPortalContent>
<ngts-orthographic-camera
[cameraRef]="virtualCameraRef"
[makeDefault]="true"
[position]="[0, 0, 200]"
/>
<ngt-group
[ref]="gizmoRef"
[position]="get('gizmoPosition')"
(beforeRender)="onBeforeRender($any($event).state.delta)"
>
<ng-container *ngTemplateOutlet="gizmoHelperContent" />
</ngt-group>
</ng-template>
</ngt-portal>
`,
imports: [NgtPortal, NgtPortalContent, NgtsOrthographicCamera, NgTemplateOutlet],
providers: [{ provide: NGTS_GIZMO_HELPER_API, useFactory: gizmoHelperApiFactory, deps: [NgtsGizmoHelper] }],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class NgtsGizmoHelper extends NgtRxStore implements OnInit {
private readonly store = inject(NgtStore);

readonly gizmoRef = injectNgtRef<Group>();
readonly virtualCameraRef = injectNgtRef<OrthographicCamera>();

animating = false;
radius = 0;
focusPoint = new Vector3(0, 0, 0);
q1 = new Quaternion();
q2 = new Quaternion();

target = new Vector3();
targetPosition = new Vector3();
dummy = new Object3D();

private defaultUp = new Vector3(0, 0, 0);
private turnRate = 2 * Math.PI; // turn rate in angles per sec
private matrix = new Matrix4();

@Input() set alignment(
alignment:
| 'top-left'
| 'top-right'
| 'bottom-right'
| 'bottom-left'
| 'bottom-center'
| 'center-right'
| 'center-left'
| 'center-center'
| 'top-center'
) {
this.set({ alignment });
}

@Input() set margin(margin: [number, number]) {
this.set({ margin });
}

@Input() set renderPriority(renderPriority: number) {
this.set({ renderPriority });
}

@Input() set autoClear(autoClear: boolean) {
this.set({ autoClear });
}

@Output() updated = new EventEmitter<void>();

@ContentChild(NgtsGizmoHelperContent, { static: true, read: TemplateRef })
gizmoHelperContent!: TemplateRef<unknown>;

override initialize(): void {
super.initialize();
this.set({ alignment: 'bottom-right', margin: [80, 80], renderPriority: 1 });
}

ngOnInit() {
this.updateDefaultUp();
this.setGizmoPosition();
}

onBeforeRender(delta: number) {
if (this.virtualCameraRef.nativeElement && this.gizmoRef.nativeElement) {
const { controls, camera: mainCamera, invalidate } = this.store.get();
const defaultControls = controls as unknown as ControlsProto;
// Animate step
if (this.animating) {
if (this.q1.angleTo(this.q2) < 0.01) {
this.animating = false;
// Orbit controls uses UP vector as the orbit axes,
// so we need to reset it after the animation is done
// moving it around for the controls to work correctly
if (isOrbitControls(defaultControls)) {
mainCamera.up.copy(this.defaultUp);
}
} else {
const step = delta * this.turnRate;
// animate position by doing a slerp and then scaling the position on the unit sphere
this.q1.rotateTowards(this.q2, step);
// animate orientation
mainCamera.position
.set(0, 0, 1)
.applyQuaternion(this.q1)
.multiplyScalar(this.radius)
.add(this.focusPoint);
mainCamera.up.set(0, 1, 0).applyQuaternion(this.q1).normalize();
mainCamera.quaternion.copy(this.q1);
if (this.updated.observed) this.updated.emit();
else if (defaultControls) {
defaultControls.update();
}
invalidate();
}
}

// Sync Gizmo with main camera orientation
this.matrix.copy(mainCamera.matrix).invert();
this.gizmoRef.nativeElement.quaternion.setFromRotationMatrix(this.matrix);
}
}

private setGizmoPosition() {
this.connect(
'gizmoPosition',
combineLatest([this.store.select('size'), this.select(selectSlice(['alignment', 'margin']))]).pipe(
map(([size, { alignment, margin }]) => {
const [marginX, marginY] = margin;
const x = alignment.endsWith('-center')
? 0
: alignment.endsWith('-left')
? -size.width / 2 + marginX
: size.width / 2 - marginX;
const y = alignment.startsWith('center-')
? 0
: alignment.startsWith('top-')
? size.height / 2 - marginY
: -size.height / 2 + marginY;
return [x, y, 0];
})
)
);
}

private updateDefaultUp() {
this.hold(this.store.select('camera'), (camera) => {
this.defaultUp.copy(camera.up);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as THREE from 'three';

export const colors = { bg: '#f0f0f0', hover: '#999', text: 'black', stroke: 'black' };
export const defaultFaces = ['Right', 'Left', 'Top', 'Bottom', 'Front', 'Back'];
const makePositionVector = (xyz: number[]) => new THREE.Vector3(...xyz).multiplyScalar(0.38);

export const corners: THREE.Vector3[] = [
[1, 1, 1],
[1, 1, -1],
[1, -1, 1],
[1, -1, -1],
[-1, 1, 1],
[-1, 1, -1],
[-1, -1, 1],
[-1, -1, -1],
].map(makePositionVector);

export const cornerDimensions = [0.25, 0.25, 0.25] as [number, number, number];

export const edges: THREE.Vector3[] = [
[1, 1, 0],
[1, 0, 1],
[1, 0, -1],
[1, -1, 0],
[0, 1, 1],
[0, 1, -1],
[0, -1, 1],
[0, -1, -1],
[-1, 1, 0],
[-1, 0, 1],
[-1, 0, -1],
[-1, -1, 0],
].map(makePositionVector);

export const edgeDimensions = edges.map(
(edge) => edge.toArray().map((axis: number): number => (axis == 0 ? 0.5 : 0.25)) as [number, number, number]
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, Input } from '@angular/core';
import { extend, NgtArgs, NgtThreeEvent } from 'angular-three';
import { BoxGeometry, Mesh, MeshBasicMaterial, Vector3 } from 'three';
import { NGTS_GIZMO_HELPER_API } from '../gizmo-helper';
import { colors } from './constants';
import { NgtsGizmoViewcubeInputs } from './gizmo-viewcube-inputs';

extend({ Mesh, BoxGeometry, MeshBasicMaterial });

@Component({
selector: 'ngts-gizmo-viewcube-edge-cube[dimensions][position]',
standalone: true,
template: `
<ngt-mesh
[scale]="1.01"
[position]="get('position')"
(pointermove)="onPointerMove($any($event))"
(pointerout)="onPointerOut($any($event))"
(click)="onClick($any($event))"
>
<ngt-box-geometry *args="get('dimensions')" />
<ngt-mesh-basic-material
[color]="hover ? get('hoverColor') : 'white'"
[transparent]="true"
[opacity]="0.6"
[visible]="hover"
/>
</ngt-mesh>
`,
imports: [NgtArgs],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class NgtsGizmoViewcubeEdgeCube extends NgtsGizmoViewcubeInputs {
readonly gizmoHelperApi = inject(NGTS_GIZMO_HELPER_API);

hover = false;

@Input() set dimensions(dimensions: [number, number, number]) {
this.set({ dimensions });
}

@Input() set position(position: Vector3) {
this.set({ position });
}

override initialize(): void {
super.initialize();
this.set({ hoverColor: colors.hover });
}

onPointerMove(event: NgtThreeEvent<PointerEvent>) {
event.stopPropagation();
this.hover = true;
}

onPointerOut(event: NgtThreeEvent<PointerEvent>) {
event.stopPropagation();
this.hover = false;
}

onClick(event: NgtThreeEvent<MouseEvent>) {
if (this.get('clickEmitter')?.observed) {
this.get('clickEmitter').emit(event);
} else {
event.stopPropagation();
this.gizmoHelperApi.tweenCamera(this.get('position'));
}
}
}
Loading

0 comments on commit e0f362f

Please sign in to comment.