|
| 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 | +} |
0 commit comments