@aura3d/controls 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ControlState.d.ts +14 -0
- package/dist/ControlState.d.ts.map +1 -0
- package/dist/ControlState.js +11 -0
- package/dist/ControlState.js.map +1 -0
- package/dist/DragControls.d.ts +8 -0
- package/dist/DragControls.d.ts.map +1 -0
- package/dist/DragControls.js +14 -0
- package/dist/DragControls.js.map +1 -0
- package/dist/FirstPersonControls.d.ts +5 -0
- package/dist/FirstPersonControls.d.ts.map +1 -0
- package/dist/FirstPersonControls.js +8 -0
- package/dist/FirstPersonControls.js.map +1 -0
- package/dist/FlyControls.d.ts +8 -0
- package/dist/FlyControls.d.ts.map +1 -0
- package/dist/FlyControls.js +12 -0
- package/dist/FlyControls.js.map +1 -0
- package/dist/InteractionControls.d.ts +83 -0
- package/dist/InteractionControls.d.ts.map +1 -0
- package/dist/InteractionControls.js +163 -0
- package/dist/InteractionControls.js.map +1 -0
- package/dist/MapControls.d.ts +6 -0
- package/dist/MapControls.d.ts.map +1 -0
- package/dist/MapControls.js +9 -0
- package/dist/MapControls.js.map +1 -0
- package/dist/NativeControlTypes.d.ts +49 -0
- package/dist/NativeControlTypes.d.ts.map +1 -0
- package/dist/NativeControlTypes.js +58 -0
- package/dist/NativeControlTypes.js.map +1 -0
- package/dist/OrbitControls.d.ts +11 -0
- package/dist/OrbitControls.d.ts.map +1 -0
- package/dist/OrbitControls.js +25 -0
- package/dist/OrbitControls.js.map +1 -0
- package/dist/Picking.d.ts +41 -0
- package/dist/Picking.d.ts.map +1 -0
- package/dist/Picking.js +149 -0
- package/dist/Picking.js.map +1 -0
- package/dist/PickingAnnotations.d.ts +134 -0
- package/dist/PickingAnnotations.d.ts.map +1 -0
- package/dist/PickingAnnotations.js +221 -0
- package/dist/PickingAnnotations.js.map +1 -0
- package/dist/PointerLockControls.d.ts +9 -0
- package/dist/PointerLockControls.d.ts.map +1 -0
- package/dist/PointerLockControls.js +14 -0
- package/dist/PointerLockControls.js.map +1 -0
- package/dist/SelectionManager.d.ts +8 -0
- package/dist/SelectionManager.d.ts.map +1 -0
- package/dist/SelectionManager.js +15 -0
- package/dist/SelectionManager.js.map +1 -0
- package/dist/TrackballControls.d.ts +18 -0
- package/dist/TrackballControls.d.ts.map +1 -0
- package/dist/TrackballControls.js +99 -0
- package/dist/TrackballControls.js.map +1 -0
- package/dist/TransformControls.d.ts +11 -0
- package/dist/TransformControls.d.ts.map +1 -0
- package/dist/TransformControls.js +19 -0
- package/dist/TransformControls.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/package.json +16 -0
- package/src/ControlState.ts +24 -0
- package/src/DragControls.ts +17 -0
- package/src/FirstPersonControls.ts +8 -0
- package/src/FlyControls.ts +14 -0
- package/src/InteractionControls.ts +224 -0
- package/src/MapControls.ts +10 -0
- package/src/NativeControlTypes.ts +93 -0
- package/src/OrbitControls.ts +25 -0
- package/src/Picking.ts +234 -0
- package/src/PickingAnnotations.ts +391 -0
- package/src/PointerLockControls.ts +15 -0
- package/src/SelectionManager.ts +18 -0
- package/src/TrackballControls.ts +116 -0
- package/src/TransformControls.ts +19 -0
- package/src/index.ts +58 -0
package/src/Picking.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ControlVector3,
|
|
3
|
+
traverseControlObject,
|
|
4
|
+
type ControlObject3DLike,
|
|
5
|
+
type ControlPickMetadata,
|
|
6
|
+
type Vector3Like
|
|
7
|
+
} from "./NativeControlTypes";
|
|
8
|
+
|
|
9
|
+
export interface ThreeCompatPickResult {
|
|
10
|
+
readonly object: ControlObject3DLike;
|
|
11
|
+
readonly distance: number;
|
|
12
|
+
readonly distanceAlongRay: number;
|
|
13
|
+
readonly perpendicularDistance: number;
|
|
14
|
+
readonly point: ControlVector3;
|
|
15
|
+
readonly radius: number;
|
|
16
|
+
readonly metadata?: ControlPickMetadata;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PickingOptions {
|
|
20
|
+
readonly near?: number;
|
|
21
|
+
readonly far?: number;
|
|
22
|
+
readonly tolerance?: number;
|
|
23
|
+
readonly includeInvisible?: boolean;
|
|
24
|
+
readonly includeNonRenderableMetadata?: boolean;
|
|
25
|
+
readonly metadata?: (object: ControlObject3DLike) => ControlPickMetadata | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PickingDiagnostics {
|
|
29
|
+
readonly testedObjects: number;
|
|
30
|
+
readonly candidateObjects: number;
|
|
31
|
+
readonly hitCount: number;
|
|
32
|
+
readonly skippedInvisible: number;
|
|
33
|
+
readonly skippedNonPickable: number;
|
|
34
|
+
readonly skippedDisabled: number;
|
|
35
|
+
readonly skippedOutOfRange: number;
|
|
36
|
+
readonly skippedMissedRadius: number;
|
|
37
|
+
readonly nearestMissPerpendicularDistance: number | null;
|
|
38
|
+
readonly nearestHitLabel: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PickingReport {
|
|
42
|
+
readonly hit: ThreeCompatPickResult | null;
|
|
43
|
+
readonly hits: readonly ThreeCompatPickResult[];
|
|
44
|
+
readonly diagnostics: PickingDiagnostics;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Picking {
|
|
48
|
+
pick(
|
|
49
|
+
root: ControlObject3DLike,
|
|
50
|
+
origin: Vector3Like = new ControlVector3(),
|
|
51
|
+
direction: Vector3Like = new ControlVector3(0, 0, -1),
|
|
52
|
+
options: PickingOptions = {}
|
|
53
|
+
): ThreeCompatPickResult | null {
|
|
54
|
+
return this.report(root, origin, direction, options).hit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pickAll(
|
|
58
|
+
root: ControlObject3DLike,
|
|
59
|
+
origin: Vector3Like = new ControlVector3(),
|
|
60
|
+
direction: Vector3Like = new ControlVector3(0, 0, -1),
|
|
61
|
+
options: PickingOptions = {}
|
|
62
|
+
): readonly ThreeCompatPickResult[] {
|
|
63
|
+
return this.report(root, origin, direction, options).hits;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
report(
|
|
67
|
+
root: ControlObject3DLike,
|
|
68
|
+
origin: Vector3Like = new ControlVector3(),
|
|
69
|
+
direction: Vector3Like = new ControlVector3(0, 0, -1),
|
|
70
|
+
options: PickingOptions = {}
|
|
71
|
+
): PickingReport {
|
|
72
|
+
const normalizedDirection = normalizedRayDirection(direction);
|
|
73
|
+
const near = finiteRange(options.near ?? 0, "near", 0);
|
|
74
|
+
const far = finiteRange(options.far ?? Number.POSITIVE_INFINITY, "far", near);
|
|
75
|
+
const tolerance = finiteRange(options.tolerance ?? 0, "tolerance", 0);
|
|
76
|
+
const hits: ThreeCompatPickResult[] = [];
|
|
77
|
+
const counters = {
|
|
78
|
+
testedObjects: 0,
|
|
79
|
+
candidateObjects: 0,
|
|
80
|
+
skippedInvisible: 0,
|
|
81
|
+
skippedNonPickable: 0,
|
|
82
|
+
skippedDisabled: 0,
|
|
83
|
+
skippedOutOfRange: 0,
|
|
84
|
+
skippedMissedRadius: 0
|
|
85
|
+
};
|
|
86
|
+
let nearestMissPerpendicularDistance = Number.POSITIVE_INFINITY;
|
|
87
|
+
|
|
88
|
+
traverseControlObject(root, (object) => {
|
|
89
|
+
counters.testedObjects += 1;
|
|
90
|
+
if (object.visible === false && options.includeInvisible !== true) {
|
|
91
|
+
counters.skippedInvisible += 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const metadata = resolvePickMetadata(object, options);
|
|
95
|
+
if (metadata?.selectable === false) {
|
|
96
|
+
counters.skippedDisabled += 1;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!isPickableObject(object, metadata, options)) {
|
|
100
|
+
counters.skippedNonPickable += 1;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
counters.candidateObjects += 1;
|
|
104
|
+
const toObject = new ControlVector3(
|
|
105
|
+
object.position.x - origin.x,
|
|
106
|
+
object.position.y - origin.y,
|
|
107
|
+
object.position.z - origin.z
|
|
108
|
+
);
|
|
109
|
+
const distanceAlongRay = dot(toObject, normalizedDirection);
|
|
110
|
+
if (distanceAlongRay < near || distanceAlongRay > far) {
|
|
111
|
+
counters.skippedOutOfRange += 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const closestPoint = pointOnRay(origin, normalizedDirection, distanceAlongRay);
|
|
115
|
+
const perpendicularDistance = new ControlVector3(
|
|
116
|
+
object.position.x - closestPoint.x,
|
|
117
|
+
object.position.y - closestPoint.y,
|
|
118
|
+
object.position.z - closestPoint.z
|
|
119
|
+
).length();
|
|
120
|
+
const radius = resolvePickRadius(object, metadata) + tolerance;
|
|
121
|
+
if (perpendicularDistance > radius) {
|
|
122
|
+
counters.skippedMissedRadius += 1;
|
|
123
|
+
nearestMissPerpendicularDistance = Math.min(nearestMissPerpendicularDistance, perpendicularDistance);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const rayEntryDistance = Math.max(near, distanceAlongRay - Math.sqrt(Math.max(0, radius * radius - perpendicularDistance * perpendicularDistance)));
|
|
127
|
+
hits.push({
|
|
128
|
+
object,
|
|
129
|
+
distance: rayEntryDistance,
|
|
130
|
+
distanceAlongRay,
|
|
131
|
+
perpendicularDistance,
|
|
132
|
+
point: pointOnRay(origin, normalizedDirection, rayEntryDistance),
|
|
133
|
+
radius,
|
|
134
|
+
metadata
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
hits.sort(compareHits);
|
|
139
|
+
const hit = hits[0] ?? null;
|
|
140
|
+
return {
|
|
141
|
+
hit,
|
|
142
|
+
hits,
|
|
143
|
+
diagnostics: {
|
|
144
|
+
...counters,
|
|
145
|
+
hitCount: hits.length,
|
|
146
|
+
nearestMissPerpendicularDistance: Number.isFinite(nearestMissPerpendicularDistance) ? nearestMissPerpendicularDistance : null,
|
|
147
|
+
nearestHitLabel: hit ? pickLabel(hit.object, hit.metadata) : null
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizedRayDirection(direction: Vector3Like): ControlVector3 {
|
|
154
|
+
const normalized = new ControlVector3(direction.x, direction.y, direction.z);
|
|
155
|
+
const length = normalized.length();
|
|
156
|
+
if (!Number.isFinite(length) || length <= 1e-8) {
|
|
157
|
+
throw new RangeError("Picking ray direction must be finite and non-zero.");
|
|
158
|
+
}
|
|
159
|
+
return normalized.set(normalized.x / length, normalized.y / length, normalized.z / length);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function finiteRange(value: number, label: string, minimum: number): number {
|
|
163
|
+
if (value === Number.POSITIVE_INFINITY && label === "far") return value;
|
|
164
|
+
if (!Number.isFinite(value) || value < minimum) {
|
|
165
|
+
throw new RangeError(`Picking ${label} must be finite and >= ${minimum}.`);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolvePickMetadata(object: ControlObject3DLike, options: PickingOptions): ControlPickMetadata | undefined {
|
|
171
|
+
return options.metadata?.(object)
|
|
172
|
+
?? object.picking
|
|
173
|
+
?? readUserDataMetadata(object.userData);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readUserDataMetadata(userData: Record<string, unknown> | undefined): ControlPickMetadata | undefined {
|
|
177
|
+
const value = userData?.a3dPicking ?? userData?.picking;
|
|
178
|
+
if (!value || typeof value !== "object") return undefined;
|
|
179
|
+
return value as ControlPickMetadata;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isPickableObject(
|
|
183
|
+
object: ControlObject3DLike,
|
|
184
|
+
metadata: ControlPickMetadata | undefined,
|
|
185
|
+
options: PickingOptions
|
|
186
|
+
): boolean {
|
|
187
|
+
if (metadata && options.includeNonRenderableMetadata !== false) return true;
|
|
188
|
+
return object.type === "Mesh" || object.type === "Sprite" || object.type === "Points" || object.type === "LineSegments";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolvePickRadius(object: ControlObject3DLike, metadata: ControlPickMetadata | undefined): number {
|
|
192
|
+
const explicit = metadata?.pickRadius ?? object.pickRadius ?? readUserDataRadius(object.userData);
|
|
193
|
+
if (explicit !== undefined) return positiveFinite(explicit, "pickRadius");
|
|
194
|
+
const scale = object.scale;
|
|
195
|
+
if (!scale) return 0.5;
|
|
196
|
+
const radius = Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z)) * 0.5;
|
|
197
|
+
return Number.isFinite(radius) && radius > 0 ? radius : 0.5;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readUserDataRadius(userData: Record<string, unknown> | undefined): number | undefined {
|
|
201
|
+
const value = userData?.a3dPickRadius;
|
|
202
|
+
return typeof value === "number" ? value : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function positiveFinite(value: number, label: string): number {
|
|
206
|
+
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`Picking ${label} must be finite and positive.`);
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function dot(left: Vector3Like, right: Vector3Like): number {
|
|
211
|
+
return left.x * right.x + left.y * right.y + left.z * right.z;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pointOnRay(origin: Vector3Like, direction: Vector3Like, distance: number): ControlVector3 {
|
|
215
|
+
return new ControlVector3(
|
|
216
|
+
origin.x + direction.x * distance,
|
|
217
|
+
origin.y + direction.y * distance,
|
|
218
|
+
origin.z + direction.z * distance
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function compareHits(left: ThreeCompatPickResult, right: ThreeCompatPickResult): number {
|
|
223
|
+
const distanceDelta = left.distance - right.distance;
|
|
224
|
+
if (Math.abs(distanceDelta) > 1e-8) return distanceDelta;
|
|
225
|
+
return pickPriority(right) - pickPriority(left);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function pickPriority(hit: ThreeCompatPickResult): number {
|
|
229
|
+
return hit.metadata?.priority ?? hit.object.pickPriority ?? 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pickLabel(object: ControlObject3DLike, metadata: ControlPickMetadata | undefined): string | null {
|
|
233
|
+
return metadata?.label ?? metadata?.id ?? object.name ?? null;
|
|
234
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { Picking, type PickingOptions, type PickingReport, type ThreeCompatPickResult } from "./Picking";
|
|
2
|
+
import { ControlVector3, type ControlObject3DLike, type ControlPickMetadata, type Vector3Like } from "./NativeControlTypes";
|
|
3
|
+
|
|
4
|
+
export type PickingAnnotationKind = "hotspot" | "label" | "district" | "building" | "robot" | "entity";
|
|
5
|
+
export type PickingAnnotationSource = "imported-gltf" | "procedural" | "screen-space";
|
|
6
|
+
export type PickingAnnotationHitPolicy = "nearest" | "priority";
|
|
7
|
+
|
|
8
|
+
export interface PickingAnnotation<TPayload = unknown> {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly kind: PickingAnnotationKind;
|
|
12
|
+
readonly position: Vector3Like | readonly [number, number, number];
|
|
13
|
+
readonly radius: number;
|
|
14
|
+
readonly priority?: number;
|
|
15
|
+
readonly group?: string;
|
|
16
|
+
readonly routeId?: string;
|
|
17
|
+
readonly source?: PickingAnnotationSource;
|
|
18
|
+
readonly selectable?: boolean;
|
|
19
|
+
readonly highlightable?: boolean;
|
|
20
|
+
readonly targetId?: string;
|
|
21
|
+
readonly payload?: TPayload;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PickingAnnotationRoot<TPayload = unknown> extends ControlObject3DLike {
|
|
25
|
+
readonly type: "PickingAnnotationRoot";
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly children: readonly PickingAnnotationObject<TPayload>[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PickingAnnotationObject<TPayload = unknown> extends ControlObject3DLike {
|
|
31
|
+
readonly type: "PickingAnnotation";
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly picking: ControlPickMetadata;
|
|
34
|
+
readonly userData: {
|
|
35
|
+
readonly a3dPicking: ControlPickMetadata;
|
|
36
|
+
readonly a3dPickingAnnotation: PickingAnnotation<TPayload>;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PickingAnnotationReport<TPayload = unknown> extends PickingReport {
|
|
41
|
+
readonly annotation: PickingAnnotation<TPayload> | null;
|
|
42
|
+
readonly annotations: readonly PickingAnnotation<TPayload>[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PickingAnnotationOptions extends PickingOptions {
|
|
46
|
+
readonly hitPolicy?: PickingAnnotationHitPolicy;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ImportedGlbHotspotDescriptor<TPayload = unknown> {
|
|
50
|
+
readonly id?: string;
|
|
51
|
+
readonly nodeName?: string;
|
|
52
|
+
readonly label?: string;
|
|
53
|
+
readonly focusId?: string;
|
|
54
|
+
readonly position: Vector3Like | readonly [number, number, number];
|
|
55
|
+
readonly radius?: number;
|
|
56
|
+
readonly priority?: number;
|
|
57
|
+
readonly selectable?: boolean;
|
|
58
|
+
readonly highlightable?: boolean;
|
|
59
|
+
readonly payload?: TPayload;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DistrictPickingDescriptor<TPayload = unknown> {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly label?: string;
|
|
65
|
+
readonly center: Vector3Like | readonly [number, number, number];
|
|
66
|
+
readonly radius: number;
|
|
67
|
+
readonly priority?: number;
|
|
68
|
+
readonly routeId?: string;
|
|
69
|
+
readonly payload?: TPayload;
|
|
70
|
+
readonly buildings?: readonly BuildingPickingDescriptor<TPayload>[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BuildingPickingDescriptor<TPayload = unknown> {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly label?: string;
|
|
76
|
+
readonly position: Vector3Like | readonly [number, number, number];
|
|
77
|
+
readonly radius: number;
|
|
78
|
+
readonly priority?: number;
|
|
79
|
+
readonly payload?: TPayload;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EntityPickingDescriptor<TPayload = unknown> {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly label?: string;
|
|
85
|
+
readonly kind?: "robot" | "entity";
|
|
86
|
+
readonly position: Vector3Like | readonly [number, number, number];
|
|
87
|
+
readonly radius: number;
|
|
88
|
+
readonly priority?: number;
|
|
89
|
+
readonly group?: string;
|
|
90
|
+
readonly routeId?: string;
|
|
91
|
+
readonly payload?: TPayload;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ScreenPickingAnnotation<TPayload = unknown> {
|
|
95
|
+
readonly id: string;
|
|
96
|
+
readonly label: string;
|
|
97
|
+
readonly kind: PickingAnnotationKind;
|
|
98
|
+
readonly x: number;
|
|
99
|
+
readonly y: number;
|
|
100
|
+
readonly radius: number;
|
|
101
|
+
readonly priority?: number;
|
|
102
|
+
readonly group?: string;
|
|
103
|
+
readonly routeId?: string;
|
|
104
|
+
readonly source?: PickingAnnotationSource;
|
|
105
|
+
readonly selectable?: boolean;
|
|
106
|
+
readonly highlightable?: boolean;
|
|
107
|
+
readonly targetId?: string;
|
|
108
|
+
readonly payload?: TPayload;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ScreenPickingHit<TPayload = unknown> {
|
|
112
|
+
readonly annotation: ScreenPickingAnnotation<TPayload>;
|
|
113
|
+
readonly distance: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ScreenPickingReport<TPayload = unknown> {
|
|
117
|
+
readonly hit: ScreenPickingHit<TPayload> | null;
|
|
118
|
+
readonly hits: readonly ScreenPickingHit<TPayload>[];
|
|
119
|
+
readonly diagnostics: {
|
|
120
|
+
readonly testedAnnotations: number;
|
|
121
|
+
readonly candidateAnnotations: number;
|
|
122
|
+
readonly hitCount: number;
|
|
123
|
+
readonly skippedDisabled: number;
|
|
124
|
+
readonly skippedOutOfRadius: number;
|
|
125
|
+
readonly nearestHitLabel: string | null;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ScreenPickingOptions {
|
|
130
|
+
readonly tolerance?: number;
|
|
131
|
+
readonly hitPolicy?: PickingAnnotationHitPolicy;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createPickingAnnotationRoot<TPayload = unknown>(
|
|
135
|
+
name: string,
|
|
136
|
+
annotations: readonly PickingAnnotation<TPayload>[]
|
|
137
|
+
): PickingAnnotationRoot<TPayload> {
|
|
138
|
+
return {
|
|
139
|
+
type: "PickingAnnotationRoot",
|
|
140
|
+
name,
|
|
141
|
+
position: new ControlVector3(),
|
|
142
|
+
children: annotations.map(createPickingAnnotationObject)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createPickingAnnotationObject<TPayload = unknown>(
|
|
147
|
+
annotation: PickingAnnotation<TPayload>
|
|
148
|
+
): PickingAnnotationObject<TPayload> {
|
|
149
|
+
const radius = positiveFinite(annotation.radius, "annotation radius");
|
|
150
|
+
const metadata: ControlPickMetadata = {
|
|
151
|
+
id: annotation.id,
|
|
152
|
+
label: annotation.label,
|
|
153
|
+
kind: annotation.kind,
|
|
154
|
+
group: annotation.group,
|
|
155
|
+
selectable: annotation.selectable,
|
|
156
|
+
highlightable: annotation.highlightable,
|
|
157
|
+
pickRadius: radius,
|
|
158
|
+
priority: annotation.priority,
|
|
159
|
+
routeId: annotation.routeId,
|
|
160
|
+
source: annotation.source,
|
|
161
|
+
targetId: annotation.targetId,
|
|
162
|
+
payload: annotation.payload
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
type: "PickingAnnotation",
|
|
166
|
+
name: annotation.label,
|
|
167
|
+
position: toVector3(annotation.position),
|
|
168
|
+
scale: new ControlVector3(radius * 2, radius * 2, radius * 2),
|
|
169
|
+
visible: annotation.selectable === false ? false : true,
|
|
170
|
+
pickRadius: radius,
|
|
171
|
+
pickPriority: annotation.priority,
|
|
172
|
+
picking: metadata,
|
|
173
|
+
userData: {
|
|
174
|
+
a3dPicking: metadata,
|
|
175
|
+
a3dPickingAnnotation: annotation
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function pickAnnotation<TPayload = unknown>(
|
|
181
|
+
root: ControlObject3DLike,
|
|
182
|
+
origin: Vector3Like = new ControlVector3(),
|
|
183
|
+
direction: Vector3Like = new ControlVector3(0, 0, -1),
|
|
184
|
+
options: PickingAnnotationOptions = {}
|
|
185
|
+
): PickingAnnotationReport<TPayload> {
|
|
186
|
+
const report = new Picking().report(root, origin, direction, options);
|
|
187
|
+
const hits = options.hitPolicy === "priority"
|
|
188
|
+
? [...report.hits].sort(compareAnnotationHitsByPriority)
|
|
189
|
+
: report.hits;
|
|
190
|
+
const hit = hits[0] ?? null;
|
|
191
|
+
return {
|
|
192
|
+
...report,
|
|
193
|
+
hit,
|
|
194
|
+
hits,
|
|
195
|
+
annotation: hit ? annotationFromPickHit<TPayload>(hit) : null,
|
|
196
|
+
annotations: hits.map((candidate) => annotationFromPickHit<TPayload>(candidate)).filter((annotation): annotation is PickingAnnotation<TPayload> => Boolean(annotation))
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function annotationFromPickHit<TPayload = unknown>(
|
|
201
|
+
hit: ThreeCompatPickResult | null | undefined
|
|
202
|
+
): PickingAnnotation<TPayload> | null {
|
|
203
|
+
const value = hit?.object.userData?.a3dPickingAnnotation;
|
|
204
|
+
return isPickingAnnotation(value) ? value as PickingAnnotation<TPayload> : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function createImportedGlbHotspotAnnotations<TPayload = unknown>(
|
|
208
|
+
assetId: string,
|
|
209
|
+
hotspots: readonly ImportedGlbHotspotDescriptor<TPayload>[],
|
|
210
|
+
routeId?: string
|
|
211
|
+
): readonly PickingAnnotation<TPayload>[] {
|
|
212
|
+
return hotspots.map((hotspot, index) => {
|
|
213
|
+
const targetId = hotspot.focusId ?? hotspot.nodeName ?? hotspot.id ?? `hotspot-${index}`;
|
|
214
|
+
return {
|
|
215
|
+
id: hotspot.id ?? `${assetId}:${targetId}`,
|
|
216
|
+
label: hotspot.label ?? readableLabel(targetId),
|
|
217
|
+
kind: "hotspot",
|
|
218
|
+
position: hotspot.position,
|
|
219
|
+
radius: hotspot.radius ?? 0.18,
|
|
220
|
+
priority: hotspot.priority ?? 100,
|
|
221
|
+
group: assetId,
|
|
222
|
+
routeId,
|
|
223
|
+
source: "imported-gltf",
|
|
224
|
+
selectable: hotspot.selectable,
|
|
225
|
+
highlightable: hotspot.highlightable ?? true,
|
|
226
|
+
targetId,
|
|
227
|
+
payload: hotspot.payload
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function createDistrictPickingAnnotations<TPayload = unknown>(
|
|
233
|
+
districts: readonly DistrictPickingDescriptor<TPayload>[]
|
|
234
|
+
): readonly PickingAnnotation<TPayload>[] {
|
|
235
|
+
const annotations: PickingAnnotation<TPayload>[] = [];
|
|
236
|
+
for (const district of districts) {
|
|
237
|
+
annotations.push({
|
|
238
|
+
id: district.id,
|
|
239
|
+
label: district.label ?? readableLabel(district.id),
|
|
240
|
+
kind: "district",
|
|
241
|
+
position: district.center,
|
|
242
|
+
radius: district.radius,
|
|
243
|
+
priority: district.priority ?? 10,
|
|
244
|
+
group: "district",
|
|
245
|
+
routeId: district.routeId,
|
|
246
|
+
source: "procedural",
|
|
247
|
+
highlightable: true,
|
|
248
|
+
payload: district.payload
|
|
249
|
+
});
|
|
250
|
+
for (const building of district.buildings ?? []) {
|
|
251
|
+
annotations.push({
|
|
252
|
+
id: building.id,
|
|
253
|
+
label: building.label ?? readableLabel(building.id),
|
|
254
|
+
kind: "building",
|
|
255
|
+
position: building.position,
|
|
256
|
+
radius: building.radius,
|
|
257
|
+
priority: building.priority ?? 40,
|
|
258
|
+
group: district.id,
|
|
259
|
+
routeId: district.routeId,
|
|
260
|
+
source: "procedural",
|
|
261
|
+
highlightable: true,
|
|
262
|
+
payload: building.payload
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return annotations;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createRobotPickingAnnotations<TPayload = unknown>(
|
|
270
|
+
robots: readonly EntityPickingDescriptor<TPayload>[]
|
|
271
|
+
): readonly PickingAnnotation<TPayload>[] {
|
|
272
|
+
return createEntityPickingAnnotations(robots.map((robot) => ({ ...robot, kind: "robot" as const })), {
|
|
273
|
+
defaultGroup: "robot",
|
|
274
|
+
defaultPriority: 70
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createEntityPickingAnnotations<TPayload = unknown>(
|
|
279
|
+
entities: readonly EntityPickingDescriptor<TPayload>[],
|
|
280
|
+
options: { readonly defaultGroup?: string; readonly defaultPriority?: number } = {}
|
|
281
|
+
): readonly PickingAnnotation<TPayload>[] {
|
|
282
|
+
return entities.map((entity) => ({
|
|
283
|
+
id: entity.id,
|
|
284
|
+
label: entity.label ?? readableLabel(entity.id),
|
|
285
|
+
kind: entity.kind ?? "entity",
|
|
286
|
+
position: entity.position,
|
|
287
|
+
radius: entity.radius,
|
|
288
|
+
priority: entity.priority ?? options.defaultPriority ?? 50,
|
|
289
|
+
group: entity.group ?? options.defaultGroup,
|
|
290
|
+
routeId: entity.routeId,
|
|
291
|
+
source: "procedural",
|
|
292
|
+
highlightable: true,
|
|
293
|
+
targetId: entity.id,
|
|
294
|
+
payload: entity.payload
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function pickScreenSpaceAnnotation<TPayload = unknown>(
|
|
299
|
+
pointer: { readonly x: number; readonly y: number },
|
|
300
|
+
annotations: readonly ScreenPickingAnnotation<TPayload>[],
|
|
301
|
+
options: ScreenPickingOptions = {}
|
|
302
|
+
): ScreenPickingReport<TPayload> {
|
|
303
|
+
validateScreenPoint(pointer, "pointer");
|
|
304
|
+
const tolerance = options.tolerance ?? 0;
|
|
305
|
+
if (!Number.isFinite(tolerance) || tolerance < 0) throw new RangeError("Screen picking tolerance must be finite and non-negative.");
|
|
306
|
+
const hits: ScreenPickingHit<TPayload>[] = [];
|
|
307
|
+
let skippedDisabled = 0;
|
|
308
|
+
let skippedOutOfRadius = 0;
|
|
309
|
+
|
|
310
|
+
for (const annotation of annotations) {
|
|
311
|
+
if (annotation.selectable === false) {
|
|
312
|
+
skippedDisabled += 1;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
validateScreenPoint(annotation, annotation.id);
|
|
316
|
+
const radius = positiveFinite(annotation.radius, `${annotation.id} screen radius`) + tolerance;
|
|
317
|
+
const distance = Math.hypot(pointer.x - annotation.x, pointer.y - annotation.y);
|
|
318
|
+
if (distance > radius) {
|
|
319
|
+
skippedOutOfRadius += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
hits.push({ annotation, distance });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
hits.sort(options.hitPolicy === "priority" ? compareScreenHitsByPriority : compareScreenHitsByDistance);
|
|
326
|
+
const hit = hits[0] ?? null;
|
|
327
|
+
return {
|
|
328
|
+
hit,
|
|
329
|
+
hits,
|
|
330
|
+
diagnostics: {
|
|
331
|
+
testedAnnotations: annotations.length,
|
|
332
|
+
candidateAnnotations: annotations.length - skippedDisabled,
|
|
333
|
+
hitCount: hits.length,
|
|
334
|
+
skippedDisabled,
|
|
335
|
+
skippedOutOfRadius,
|
|
336
|
+
nearestHitLabel: hit?.annotation.label ?? null
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function toVector3(value: Vector3Like | readonly [number, number, number]): ControlVector3 {
|
|
342
|
+
const tuple = value as readonly number[];
|
|
343
|
+
const vector = value as Vector3Like;
|
|
344
|
+
const x = Array.isArray(value) ? tuple[0] : vector.x;
|
|
345
|
+
const y = Array.isArray(value) ? tuple[1] : vector.y;
|
|
346
|
+
const z = Array.isArray(value) ? tuple[2] : vector.z;
|
|
347
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
348
|
+
throw new RangeError("Picking annotation position must contain three finite values.");
|
|
349
|
+
}
|
|
350
|
+
return new ControlVector3(x, y, z);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function positiveFinite(value: number, label: string): number {
|
|
354
|
+
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`Picking ${label} must be finite and positive.`);
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function readableLabel(value: string): string {
|
|
359
|
+
return value.replace(/[_:-]+/g, " ").replace(/\s+/g, " ").trim() || "Picking target";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function compareAnnotationHitsByPriority(left: ThreeCompatPickResult, right: ThreeCompatPickResult): number {
|
|
363
|
+
const priorityDelta = (right.metadata?.priority ?? 0) - (left.metadata?.priority ?? 0);
|
|
364
|
+
if (Math.abs(priorityDelta) > 1e-8) return priorityDelta;
|
|
365
|
+
return left.distance - right.distance;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isPickingAnnotation(value: unknown): value is PickingAnnotation {
|
|
369
|
+
return Boolean(value)
|
|
370
|
+
&& typeof value === "object"
|
|
371
|
+
&& typeof (value as { readonly id?: unknown }).id === "string"
|
|
372
|
+
&& typeof (value as { readonly label?: unknown }).label === "string";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function validateScreenPoint(point: { readonly x: number; readonly y: number }, label: string): void {
|
|
376
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
377
|
+
throw new RangeError(`Screen picking ${label} must contain finite x/y values.`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function compareScreenHitsByDistance(left: ScreenPickingHit, right: ScreenPickingHit): number {
|
|
382
|
+
const distanceDelta = left.distance - right.distance;
|
|
383
|
+
if (Math.abs(distanceDelta) > 1e-8) return distanceDelta;
|
|
384
|
+
return (right.annotation.priority ?? 0) - (left.annotation.priority ?? 0);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function compareScreenHitsByPriority(left: ScreenPickingHit, right: ScreenPickingHit): number {
|
|
388
|
+
const priorityDelta = (right.annotation.priority ?? 0) - (left.annotation.priority ?? 0);
|
|
389
|
+
if (Math.abs(priorityDelta) > 1e-8) return priorityDelta;
|
|
390
|
+
return left.distance - right.distance;
|
|
391
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createDefaultControlState, type ThreeCompatControlState } from "./ControlState";
|
|
2
|
+
|
|
3
|
+
export class PointerLockControls {
|
|
4
|
+
readonly state: ThreeCompatControlState = createDefaultControlState();
|
|
5
|
+
locked = false;
|
|
6
|
+
|
|
7
|
+
lock(): void { this.locked = true; }
|
|
8
|
+
unlock(): void { this.locked = false; }
|
|
9
|
+
|
|
10
|
+
look(deltaX: number, deltaY: number): void {
|
|
11
|
+
if (!this.locked) return;
|
|
12
|
+
this.state.rotation.y += deltaX;
|
|
13
|
+
this.state.rotation.x += deltaY;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ControlObject3DLike } from "./NativeControlTypes";
|
|
2
|
+
|
|
3
|
+
export class SelectionManager {
|
|
4
|
+
readonly selected = new Set<ControlObject3DLike>();
|
|
5
|
+
|
|
6
|
+
select(object: ControlObject3DLike, additive = false): void {
|
|
7
|
+
if (!additive) this.clear();
|
|
8
|
+
this.selected.add(object);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
deselect(object: ControlObject3DLike): void {
|
|
12
|
+
this.selected.delete(object);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
clear(): void {
|
|
16
|
+
this.selected.clear();
|
|
17
|
+
}
|
|
18
|
+
}
|