@galfus/engine 0.0.0-stage → 0.31.0-alpha
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/README.md +190 -2
- package/package.json +59 -4
- package/src/anchors/cache.ts +101 -0
- package/src/anchors/component.ts +123 -0
- package/src/anchors/hierarchy.ts +192 -0
- package/src/anchors/index.ts +8 -0
- package/src/anchors/solver.ts +247 -0
- package/src/anchors/strategy.ts +142 -0
- package/src/animation/blending.ts +163 -0
- package/src/animation/clip.ts +132 -0
- package/src/animation/coordinator.ts +568 -0
- package/src/animation/index.ts +7 -0
- package/src/animation/native-targets.ts +37 -0
- package/src/animation/player.ts +287 -0
- package/src/audio/index.ts +4 -0
- package/src/audio/manager.ts +371 -0
- package/src/collision/components.ts +211 -0
- package/src/collision/index.ts +16 -0
- package/src/collision/query2d.ts +167 -0
- package/src/collision/query3d.ts +170 -0
- package/src/ecs/component.ts +130 -0
- package/src/ecs/entity.ts +75 -0
- package/src/ecs/index.ts +9 -0
- package/src/ecs/intent.ts +162 -0
- package/src/ecs/metadata.ts +44 -0
- package/src/ecs/system.ts +224 -0
- package/src/ecs/world.ts +291 -0
- package/src/index.ts +19 -0
- package/src/interaction/focus.ts +327 -0
- package/src/interaction/index.ts +9 -0
- package/src/interaction/listener.ts +58 -0
- package/src/interaction/snapshot2d.ts +210 -0
- package/src/interaction/snapshot3d.ts +214 -0
- package/src/interaction/system.ts +544 -0
- package/src/interaction/types.ts +27 -0
- package/src/math/common.ts +67 -0
- package/src/math/index.ts +17 -0
- package/src/math/mat2d.ts +320 -0
- package/src/math/mat4.ts +576 -0
- package/src/math/quat.ts +438 -0
- package/src/math/vec2.ts +161 -0
- package/src/math/vec3.ts +242 -0
- package/src/math/vec4.ts +171 -0
- package/src/multiverse/coordinator.ts +540 -0
- package/src/multiverse/index.ts +13 -0
- package/src/multiverse/presentation-graph.ts +306 -0
- package/src/multiverse/presentation-links.ts +134 -0
- package/src/multiverse/realm.ts +49 -0
- package/src/platform/index.ts +2 -0
- package/src/platform/platform.ts +149 -0
- package/src/platform/window.ts +181 -0
- package/src/realm2d/components.ts +254 -0
- package/src/realm2d/index.ts +12 -0
- package/src/realm2d/presets/index.ts +36 -0
- package/src/realm2d/realm.ts +522 -0
- package/src/realm2d/sync.ts +284 -0
- package/src/realm2d/utils/index.ts +32 -0
- package/src/realm3d/components.ts +341 -0
- package/src/realm3d/geometry.ts +224 -0
- package/src/realm3d/index.ts +12 -0
- package/src/realm3d/presets/index.ts +67 -0
- package/src/realm3d/realm.ts +588 -0
- package/src/realm3d/resource-ids.ts +62 -0
- package/src/realm3d/skinning.ts +128 -0
- package/src/realm3d/sync.ts +442 -0
- package/src/realm3d/utils/index.ts +26 -0
- package/src/resources/index.ts +4 -0
- package/src/resources/manager.ts +92 -0
- package/src/runtime.ts +588 -0
- package/src/spatial/dirty_registry.ts +217 -0
- package/src/spatial/flags.ts +14 -0
- package/src/spatial/index.ts +6 -0
- package/src/spatial/metrics.ts +124 -0
- package/src/text/editor.ts +224 -0
- package/src/text/encoding.ts +38 -0
- package/src/text/index.ts +13 -0
- package/src/text/input.ts +160 -0
- package/src/text/keyboard.ts +50 -0
- package/src/text/svg-decorations.ts +147 -0
- package/src/types.ts +85 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import type { PointerButton, PointerEvent } from '@galfus/transport-types';
|
|
2
|
+
import type { Component } from '../ecs/component';
|
|
3
|
+
import type { MultiverseCoordinator } from '../multiverse/coordinator';
|
|
4
|
+
import type { PresentationConnection, WindowPresentation } from '../multiverse/presentation-graph';
|
|
5
|
+
import { Realm2D } from '../realm2d/realm';
|
|
6
|
+
import { Realm3D } from '../realm3d/realm';
|
|
7
|
+
import type { Model2DComponent, Svg2DComponent } from '../realm2d/components';
|
|
8
|
+
import type { Model3DComponent } from '../realm3d/components';
|
|
9
|
+
import {
|
|
10
|
+
type Collider2DComponent,
|
|
11
|
+
type Collider3DComponent,
|
|
12
|
+
getColliderEntries
|
|
13
|
+
} from '../collision/components';
|
|
14
|
+
import { vec3 } from '../math';
|
|
15
|
+
import { InteractiveListener } from './listener';
|
|
16
|
+
import {
|
|
17
|
+
type CameraSnapshot2D,
|
|
18
|
+
captureCameras2D,
|
|
19
|
+
pointerToWorld2D,
|
|
20
|
+
TargetSnapshot2D
|
|
21
|
+
} from './snapshot2d';
|
|
22
|
+
import {
|
|
23
|
+
type CameraSnapshot3D,
|
|
24
|
+
captureCameras3D,
|
|
25
|
+
pointerToRay3D,
|
|
26
|
+
TargetSnapshot3D
|
|
27
|
+
} from './snapshot3d';
|
|
28
|
+
import type { InteractiveEventType, InteractiveTarget } from './types';
|
|
29
|
+
|
|
30
|
+
interface TargetRecord {
|
|
31
|
+
listener: InteractiveListener;
|
|
32
|
+
realm: Realm2D | Realm3D;
|
|
33
|
+
entityId: number;
|
|
34
|
+
model: Model2DComponent | Model3DComponent;
|
|
35
|
+
component: Component;
|
|
36
|
+
snapshot: TargetSnapshot2D | TargetSnapshot3D;
|
|
37
|
+
}
|
|
38
|
+
interface PointerState {
|
|
39
|
+
event: PointerEvent;
|
|
40
|
+
hover?: InteractiveListener;
|
|
41
|
+
pressed: Map<PointerButton | number, InteractiveListener>;
|
|
42
|
+
positions: Map<InteractiveListener, [number, number]>;
|
|
43
|
+
}
|
|
44
|
+
interface RealmState {
|
|
45
|
+
realm: Realm2D | Realm3D;
|
|
46
|
+
cameras2D: CameraSnapshot2D[];
|
|
47
|
+
cameras3D: CameraSnapshot3D[];
|
|
48
|
+
records: Set<TargetRecord>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Host-side pointer dispatch against the most recently submitted presentation. */
|
|
52
|
+
export class InteractiveSystem {
|
|
53
|
+
private readonly records = new Map<InteractiveListener, TargetRecord>();
|
|
54
|
+
private readonly realms = new Map<number, RealmState>();
|
|
55
|
+
private readonly pointers = new Map<number, Map<number, PointerState>>();
|
|
56
|
+
private readonly worldPoint: [number, number] = [0, 0];
|
|
57
|
+
private readonly rayOrigin = vec3.create();
|
|
58
|
+
private readonly rayDirection = vec3.create();
|
|
59
|
+
|
|
60
|
+
constructor(private readonly multiverse: MultiverseCoordinator) {}
|
|
61
|
+
|
|
62
|
+
/** @internal */
|
|
63
|
+
usesRealm(realmId: number): boolean {
|
|
64
|
+
return this.realms.has(realmId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Returns the first directly presented invocation order for a registered, visible target. */
|
|
68
|
+
presentationOrder(listener: InteractiveListener, windowId: number): number | undefined {
|
|
69
|
+
const record = this.records.get(listener);
|
|
70
|
+
const view = this.multiverse.presentationGraph.getWindow(windowId);
|
|
71
|
+
const state = this.realms.get(listener.realmId);
|
|
72
|
+
if (!record?.snapshot.valid || !view || !state || record.realm.isDisposed) return undefined;
|
|
73
|
+
if (
|
|
74
|
+
record.realm.world.getComponent(record.entityId, record.model.type) !== record.model ||
|
|
75
|
+
record.realm.world.getComponent(record.entityId, record.component.type) !== record.component
|
|
76
|
+
)
|
|
77
|
+
return undefined;
|
|
78
|
+
let order = 0;
|
|
79
|
+
const visited = new Set<number>();
|
|
80
|
+
const traverse = (targetId: number, depth: number): number | undefined => {
|
|
81
|
+
if (depth > 3 || visited.has(targetId)) return undefined;
|
|
82
|
+
visited.add(targetId);
|
|
83
|
+
for (const id of view.getInvocations(targetId)) {
|
|
84
|
+
const invocation = view.getInvocation(id);
|
|
85
|
+
if (invocation?.realmId === listener.realmId) {
|
|
86
|
+
const cameras = record.realm instanceof Realm2D ? state.cameras2D : state.cameras3D;
|
|
87
|
+
const slot = invocation.cameraSlot;
|
|
88
|
+
for (const camera of cameras)
|
|
89
|
+
if (camera.slot === (slot ?? 0) || (slot !== undefined && camera.id === slot))
|
|
90
|
+
return order;
|
|
91
|
+
}
|
|
92
|
+
order++;
|
|
93
|
+
if (depth < 3) {
|
|
94
|
+
for (const conn of view.connections) {
|
|
95
|
+
if (conn.realmId === invocation?.realmId && !visited.has(conn.sourceTargetId)) {
|
|
96
|
+
const isCut = view.softCutEdges.some(
|
|
97
|
+
([src, dst]) => src === conn.sourceTargetId && dst === targetId
|
|
98
|
+
);
|
|
99
|
+
if (!isCut) {
|
|
100
|
+
const found = traverse(conn.sourceTargetId, depth + 1);
|
|
101
|
+
if (found !== undefined) return found;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
};
|
|
109
|
+
for (const targetId of view.rootTargetIds) {
|
|
110
|
+
const found = traverse(targetId, 1);
|
|
111
|
+
if (found !== undefined) return found;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @internal Current hit for an already processed pointer event. */
|
|
117
|
+
hovered(windowId: number, pointerId: number): InteractiveListener | undefined {
|
|
118
|
+
return this.pointers.get(windowId)?.get(pointerId)?.hover;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interactive(realmId: number, input: InteractiveTarget): InteractiveListener {
|
|
122
|
+
const realm = this.multiverse.getRealm(realmId);
|
|
123
|
+
if (!(realm instanceof Realm2D || realm instanceof Realm3D) || realm.isDisposed)
|
|
124
|
+
throw new Error(`Interactive realm ${realmId} is not registered.`);
|
|
125
|
+
let entityId: number | undefined;
|
|
126
|
+
let component: Component | undefined;
|
|
127
|
+
let resolvedColliderId: number | undefined;
|
|
128
|
+
if (input.type === 'collider') {
|
|
129
|
+
entityId = input.entityId;
|
|
130
|
+
const colliderType = realm instanceof Realm2D ? 'Collider2D' : 'Collider3D';
|
|
131
|
+
const collider = realm.world.getComponent<Collider2DComponent | Collider3DComponent>(
|
|
132
|
+
entityId,
|
|
133
|
+
colliderType
|
|
134
|
+
);
|
|
135
|
+
if (!collider) throw new Error(`Interactive entity requires a ${colliderType} component.`);
|
|
136
|
+
let count = 0;
|
|
137
|
+
let matches = false;
|
|
138
|
+
for (const entry of getColliderEntries(collider as Collider2DComponent)) {
|
|
139
|
+
count++;
|
|
140
|
+
resolvedColliderId = entry.colliderId;
|
|
141
|
+
if (entry.colliderId === input.colliderId) matches = true;
|
|
142
|
+
}
|
|
143
|
+
if (input.colliderId === undefined && count !== 1)
|
|
144
|
+
throw new Error('Specify colliderId when an entity does not have exactly one collider.');
|
|
145
|
+
if (input.colliderId !== undefined && !matches)
|
|
146
|
+
throw new Error('Interactive collider does not exist.');
|
|
147
|
+
component = collider;
|
|
148
|
+
} else {
|
|
149
|
+
if (!(realm instanceof Realm2D)) throw new Error('Interactive SVG requires a Realm2D.');
|
|
150
|
+
for (const id of realm.world.query('Svg2D')) {
|
|
151
|
+
const svg = realm.world.getComponent<Svg2DComponent>(id, 'Svg2D');
|
|
152
|
+
if (svg?.svgId === input.svgId) {
|
|
153
|
+
entityId = id;
|
|
154
|
+
component = svg;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (entityId === undefined || !component) throw new Error('Interactive SVG does not exist.');
|
|
159
|
+
}
|
|
160
|
+
const modelType = realm instanceof Realm2D ? 'Model2D' : 'Model3D';
|
|
161
|
+
const model = realm.world.getComponent<Model2DComponent | Model3DComponent>(
|
|
162
|
+
entityId!,
|
|
163
|
+
modelType
|
|
164
|
+
);
|
|
165
|
+
if (!model) throw new Error(`Interactive target requires a ${modelType} component.`);
|
|
166
|
+
const target: InteractiveTarget = Object.freeze(
|
|
167
|
+
input.type === 'collider'
|
|
168
|
+
? { ...input, colliderId: input.colliderId ?? resolvedColliderId }
|
|
169
|
+
: { ...input }
|
|
170
|
+
);
|
|
171
|
+
const listener = new InteractiveListener(realmId, target, (value) => this.remove(value));
|
|
172
|
+
const previous = this.realms.get(realmId);
|
|
173
|
+
if (previous && previous.realm !== realm) {
|
|
174
|
+
for (const old of previous.records) old.listener.dispose();
|
|
175
|
+
}
|
|
176
|
+
const record = {
|
|
177
|
+
listener,
|
|
178
|
+
realm,
|
|
179
|
+
entityId: entityId!,
|
|
180
|
+
model,
|
|
181
|
+
component: component!,
|
|
182
|
+
snapshot: realm instanceof Realm2D ? new TargetSnapshot2D() : new TargetSnapshot3D()
|
|
183
|
+
};
|
|
184
|
+
this.records.set(listener, record);
|
|
185
|
+
let state = this.realms.get(realmId);
|
|
186
|
+
if (!state)
|
|
187
|
+
this.realms.set(
|
|
188
|
+
realmId,
|
|
189
|
+
(state = { realm, cameras2D: [], cameras3D: [], records: new Set() })
|
|
190
|
+
);
|
|
191
|
+
state.records.add(record);
|
|
192
|
+
return listener;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private remove(listener: InteractiveListener): void {
|
|
196
|
+
const record = this.records.get(listener);
|
|
197
|
+
if (!record) return;
|
|
198
|
+
this.records.delete(listener);
|
|
199
|
+
const realmState = this.realms.get(record.realm.realmId);
|
|
200
|
+
realmState?.records.delete(record);
|
|
201
|
+
for (const pointers of this.pointers.values())
|
|
202
|
+
for (const pointer of pointers.values()) {
|
|
203
|
+
pointer.positions.delete(listener);
|
|
204
|
+
if (pointer.hover === listener) pointer.hover = undefined;
|
|
205
|
+
for (const [button, pressed] of pointer.pressed)
|
|
206
|
+
if (pressed === listener) pointer.pressed.delete(button);
|
|
207
|
+
}
|
|
208
|
+
if (!realmState?.records.size) this.realms.delete(record.realm.realmId);
|
|
209
|
+
if (!this.records.size) this.pointers.clear();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Captures state only after a successful dispatch. */
|
|
213
|
+
afterDispatch(): void {
|
|
214
|
+
if (!this.records.size) return;
|
|
215
|
+
let removed: TargetRecord[] | undefined;
|
|
216
|
+
for (const state of this.realms.values())
|
|
217
|
+
if (!state.realm.isDisposed) {
|
|
218
|
+
if (state.realm instanceof Realm2D) captureCameras2D(state.realm, state.cameras2D);
|
|
219
|
+
else captureCameras3D(state.realm, state.cameras3D);
|
|
220
|
+
}
|
|
221
|
+
for (const record of this.records.values()) {
|
|
222
|
+
const { realm, entityId, component, model, snapshot, listener } = record;
|
|
223
|
+
if (
|
|
224
|
+
realm.isDisposed ||
|
|
225
|
+
realm.world.getComponent(entityId, model.type) !== model ||
|
|
226
|
+
realm.world.getComponent(entityId, component.type) !== component
|
|
227
|
+
) {
|
|
228
|
+
snapshot.valid = false;
|
|
229
|
+
(removed ??= []).push(record);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (snapshot instanceof TargetSnapshot2D && realm instanceof Realm2D)
|
|
233
|
+
snapshot.capture(realm, entityId, listener.target);
|
|
234
|
+
else if (
|
|
235
|
+
snapshot instanceof TargetSnapshot3D &&
|
|
236
|
+
realm instanceof Realm3D &&
|
|
237
|
+
listener.target.type === 'collider'
|
|
238
|
+
)
|
|
239
|
+
snapshot.capture(realm, entityId, listener.target.colliderId);
|
|
240
|
+
if (listener.target.type === 'collider' && !snapshot.exists) (removed ??= []).push(record);
|
|
241
|
+
}
|
|
242
|
+
if (removed) {
|
|
243
|
+
for (const record of removed) {
|
|
244
|
+
for (const pointers of this.pointers.values()) {
|
|
245
|
+
for (const pointer of pointers.values()) {
|
|
246
|
+
let pressed = false;
|
|
247
|
+
for (const [button, listener] of pointer.pressed) {
|
|
248
|
+
if (listener === record.listener) {
|
|
249
|
+
pointer.pressed.delete(button);
|
|
250
|
+
pressed = true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (pressed) this.emit(record.listener, 'cancel', pointer.event);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const pointers of this.pointers.values())
|
|
259
|
+
for (const pointer of pointers.values()) this.updateHover(pointer);
|
|
260
|
+
if (removed) for (const record of removed) record.listener.dispose();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private pick(
|
|
264
|
+
event: PointerEvent
|
|
265
|
+
): { listener: InteractiveListener; localPos?: [number, number] } | undefined {
|
|
266
|
+
const view = this.multiverse.presentationGraph.getWindow(event.windowId);
|
|
267
|
+
if (!view) return;
|
|
268
|
+
const visited = new Set<number>();
|
|
269
|
+
const localPos: [number, number] = [0, 0];
|
|
270
|
+
for (let r = view.rootTargetIds.length - 1; r >= 0; r--) {
|
|
271
|
+
const rootId = view.rootTargetIds[r]!;
|
|
272
|
+
visited.add(rootId);
|
|
273
|
+
const hit = this.pickInTarget(view, rootId, event.position, 1, visited, localPos);
|
|
274
|
+
visited.delete(rootId);
|
|
275
|
+
if (hit) {
|
|
276
|
+
return {
|
|
277
|
+
listener: hit.listener,
|
|
278
|
+
localPos: hit.transformed ? [localPos[0], localPos[1]] : undefined
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private pickInTarget(
|
|
286
|
+
view: WindowPresentation,
|
|
287
|
+
targetId: number,
|
|
288
|
+
position: [number, number],
|
|
289
|
+
depth: number,
|
|
290
|
+
visitedTargets: Set<number>,
|
|
291
|
+
outLocalPos: [number, number]
|
|
292
|
+
): { listener: InteractiveListener; transformed: boolean } | undefined {
|
|
293
|
+
const ids = view.getInvocations(targetId);
|
|
294
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
295
|
+
const invocation = view.getInvocation(ids[i]!)!;
|
|
296
|
+
const realm = this.realms.get(invocation.realmId);
|
|
297
|
+
if (!realm || realm.realm.isDisposed) continue;
|
|
298
|
+
const slot = invocation.cameraSlot;
|
|
299
|
+
const cameras = realm.realm instanceof Realm2D ? realm.cameras2D : realm.cameras3D;
|
|
300
|
+
let camera: CameraSnapshot2D | CameraSnapshot3D | undefined;
|
|
301
|
+
for (const item of cameras)
|
|
302
|
+
if (item.slot === (slot ?? 0) || (slot !== undefined && item.id === slot)) {
|
|
303
|
+
camera = item;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
if (!camera) continue;
|
|
307
|
+
let best: TargetRecord | undefined;
|
|
308
|
+
let closest = Infinity;
|
|
309
|
+
let maxDistance: number | undefined;
|
|
310
|
+
const bestUv: [number, number] = [0.5, 0.5];
|
|
311
|
+
const currentUv: [number, number] = [0.5, 0.5];
|
|
312
|
+
|
|
313
|
+
if (realm.realm instanceof Realm2D) {
|
|
314
|
+
if (
|
|
315
|
+
!pointerToWorld2D(
|
|
316
|
+
this.worldPoint,
|
|
317
|
+
position[0],
|
|
318
|
+
position[1],
|
|
319
|
+
invocation.resolvedViewport,
|
|
320
|
+
camera as CameraSnapshot2D
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
continue;
|
|
324
|
+
} else {
|
|
325
|
+
maxDistance = pointerToRay3D(
|
|
326
|
+
this.rayOrigin,
|
|
327
|
+
this.rayDirection,
|
|
328
|
+
position[0],
|
|
329
|
+
position[1],
|
|
330
|
+
invocation.resolvedViewport,
|
|
331
|
+
camera as CameraSnapshot3D
|
|
332
|
+
);
|
|
333
|
+
if (maxDistance === undefined) continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const record of realm.records) {
|
|
337
|
+
if (
|
|
338
|
+
record.realm !== realm.realm ||
|
|
339
|
+
record.realm.world.getComponent(record.entityId, record.model.type) !== record.model ||
|
|
340
|
+
record.realm.world.getComponent(record.entityId, record.component.type) !==
|
|
341
|
+
record.component
|
|
342
|
+
)
|
|
343
|
+
continue;
|
|
344
|
+
|
|
345
|
+
if (record.snapshot instanceof TargetSnapshot3D && maxDistance !== undefined) {
|
|
346
|
+
const distance = record.snapshot.distance(
|
|
347
|
+
this.rayOrigin,
|
|
348
|
+
this.rayDirection,
|
|
349
|
+
maxDistance,
|
|
350
|
+
currentUv
|
|
351
|
+
);
|
|
352
|
+
if (distance !== undefined && distance < closest) {
|
|
353
|
+
closest = distance;
|
|
354
|
+
best = record;
|
|
355
|
+
bestUv[0] = currentUv[0];
|
|
356
|
+
bestUv[1] = currentUv[1];
|
|
357
|
+
}
|
|
358
|
+
} else if (
|
|
359
|
+
record.snapshot instanceof TargetSnapshot2D &&
|
|
360
|
+
record.snapshot.contains(this.worldPoint, record.listener.target, currentUv)
|
|
361
|
+
) {
|
|
362
|
+
const a = record.snapshot,
|
|
363
|
+
b = best?.snapshot as TargetSnapshot2D | undefined;
|
|
364
|
+
const camera2d = camera as CameraSnapshot2D;
|
|
365
|
+
const d = 10 - a.zLayer;
|
|
366
|
+
if (d < camera2d.near || d > camera2d.far) continue;
|
|
367
|
+
if (
|
|
368
|
+
!b ||
|
|
369
|
+
a.zLayer > b.zLayer ||
|
|
370
|
+
(a.zLayer === b.zLayer &&
|
|
371
|
+
(a.drawOrder > b.drawOrder ||
|
|
372
|
+
(a.drawOrder === b.drawOrder && a.resourceId > b.resourceId)))
|
|
373
|
+
) {
|
|
374
|
+
best = record;
|
|
375
|
+
bestUv[0] = currentUv[0];
|
|
376
|
+
bestUv[1] = currentUv[1];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (best) {
|
|
382
|
+
if (depth < 3) {
|
|
383
|
+
let connection: PresentationConnection | undefined;
|
|
384
|
+
for (const conn of view.connections) {
|
|
385
|
+
if (conn.realmId === best.realm.realmId && conn.entityId === best.entityId) {
|
|
386
|
+
connection = conn;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (connection && !visitedTargets.has(connection.sourceTargetId)) {
|
|
391
|
+
const isCut = view.softCutEdges.some(
|
|
392
|
+
([src, dst]) => src === connection!.sourceTargetId && dst === targetId
|
|
393
|
+
);
|
|
394
|
+
if (!isCut) {
|
|
395
|
+
const targetDesc = this.multiverse.getTarget(connection.sourceTargetId);
|
|
396
|
+
const tw = targetDesc?.kind.type === 'texture' ? targetDesc.kind.width : 1;
|
|
397
|
+
const th = targetDesc?.kind.type === 'texture' ? targetDesc.kind.height : 1;
|
|
398
|
+
const nextPos: [number, number] = [bestUv[0] * tw, bestUv[1] * th];
|
|
399
|
+
visitedTargets.add(connection.sourceTargetId);
|
|
400
|
+
const innerPos: [number, number] = [0, 0];
|
|
401
|
+
const inner = this.pickInTarget(
|
|
402
|
+
view,
|
|
403
|
+
connection.sourceTargetId,
|
|
404
|
+
nextPos,
|
|
405
|
+
depth + 1,
|
|
406
|
+
visitedTargets,
|
|
407
|
+
innerPos
|
|
408
|
+
);
|
|
409
|
+
visitedTargets.delete(connection.sourceTargetId);
|
|
410
|
+
if (inner) {
|
|
411
|
+
outLocalPos[0] = innerPos[0];
|
|
412
|
+
outLocalPos[1] = innerPos[1];
|
|
413
|
+
return { listener: inner.listener, transformed: true };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
outLocalPos[0] = position[0];
|
|
419
|
+
outLocalPos[1] = position[1];
|
|
420
|
+
return { listener: best.listener, transformed: depth > 1 };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private emit(
|
|
427
|
+
listener: InteractiveListener | undefined,
|
|
428
|
+
type: InteractiveEventType,
|
|
429
|
+
pointer: PointerEvent,
|
|
430
|
+
localPos?: [number, number]
|
|
431
|
+
): void {
|
|
432
|
+
if (listener?.hasListeners(type)) {
|
|
433
|
+
const p =
|
|
434
|
+
localPos && (localPos[0] !== pointer.position[0] || localPos[1] !== pointer.position[1])
|
|
435
|
+
? { ...pointer, position: [localPos[0], localPos[1]] as [number, number] }
|
|
436
|
+
: pointer;
|
|
437
|
+
listener.emit({ type, realmId: listener.realmId, target: listener.target, pointer: p });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private updateHover(pointer: PointerState): void {
|
|
442
|
+
const hit = this.pick(pointer.event);
|
|
443
|
+
const next = hit?.listener;
|
|
444
|
+
if (hit?.localPos) pointer.positions.set(hit.listener, hit.localPos);
|
|
445
|
+
else if (next) pointer.positions.delete(next);
|
|
446
|
+
if (next === pointer.hover) return;
|
|
447
|
+
const previous = pointer.hover;
|
|
448
|
+
pointer.hover = next;
|
|
449
|
+
if (previous) {
|
|
450
|
+
this.emit(previous, 'leave', pointer.event, pointer.positions.get(previous));
|
|
451
|
+
let isPressed = false;
|
|
452
|
+
for (const pressed of pointer.pressed.values()) {
|
|
453
|
+
if (pressed === previous) {
|
|
454
|
+
isPressed = true;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!isPressed) pointer.positions.delete(previous);
|
|
459
|
+
}
|
|
460
|
+
if (next && pointer.hover === next)
|
|
461
|
+
this.emit(next, 'hover', pointer.event, pointer.positions.get(next));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
handle(event: PointerEvent): void {
|
|
465
|
+
if (!this.records.size) return;
|
|
466
|
+
let window = this.pointers.get(event.windowId);
|
|
467
|
+
if (!window) this.pointers.set(event.windowId, (window = new Map()));
|
|
468
|
+
let pointer = window.get(event.pointerId);
|
|
469
|
+
if (!pointer)
|
|
470
|
+
window.set(event.pointerId, (pointer = { event, pressed: new Map(), positions: new Map() }));
|
|
471
|
+
pointer.event = event;
|
|
472
|
+
if (event.type === 'leave' || event.type === 'cancel') {
|
|
473
|
+
this.cancel(pointer);
|
|
474
|
+
window.delete(event.pointerId);
|
|
475
|
+
if (!window.size) this.pointers.delete(event.windowId);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
this.updateHover(pointer);
|
|
479
|
+
if (event.type === 'move') {
|
|
480
|
+
this.emit(pointer.hover, 'move', event, pointer.positions.get(pointer.hover!));
|
|
481
|
+
this.emitPressed(pointer, 'pressmove');
|
|
482
|
+
}
|
|
483
|
+
if (event.type === 'button') {
|
|
484
|
+
const button = typeof event.button === 'string' ? event.button : event.button.other;
|
|
485
|
+
if (event.state === 'pressed') {
|
|
486
|
+
if (pointer.hover) pointer.pressed.set(button, pointer.hover);
|
|
487
|
+
this.emit(pointer.hover, 'down', event, pointer.positions.get(pointer.hover!));
|
|
488
|
+
} else {
|
|
489
|
+
const pressed = pointer.pressed.get(button);
|
|
490
|
+
pointer.pressed.delete(button);
|
|
491
|
+
this.emit(pointer.hover, 'up', event, pointer.positions.get(pointer.hover!));
|
|
492
|
+
this.emit(pressed, 'pressup', event, pointer.positions.get(pressed!));
|
|
493
|
+
if (pressed && pressed === pointer.hover)
|
|
494
|
+
this.emit(pressed, 'click', event, pointer.positions.get(pressed));
|
|
495
|
+
if (pressed && pressed !== pointer.hover) {
|
|
496
|
+
let isStillPressed = false;
|
|
497
|
+
for (const remaining of pointer.pressed.values()) {
|
|
498
|
+
if (remaining === pressed) {
|
|
499
|
+
isStillPressed = true;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (!isStillPressed) pointer.positions.delete(pressed);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private cancel(pointer: PointerState): void {
|
|
510
|
+
this.emitPressed(pointer, 'cancel');
|
|
511
|
+
pointer.pressed.clear();
|
|
512
|
+
pointer.positions.clear();
|
|
513
|
+
this.emit(pointer.hover, 'leave', pointer.event, pointer.positions.get(pointer.hover!));
|
|
514
|
+
pointer.hover = undefined;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Delivers once per listener even when several pointer buttons are pressed. */
|
|
518
|
+
private emitPressed(pointer: PointerState, type: 'pressmove' | 'cancel'): void {
|
|
519
|
+
for (const [button, listener] of pointer.pressed) {
|
|
520
|
+
let first = true;
|
|
521
|
+
for (const [previousButton, previous] of pointer.pressed) {
|
|
522
|
+
if (previousButton === button) break;
|
|
523
|
+
if (previous === listener) {
|
|
524
|
+
first = false;
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (first) this.emit(listener, type, pointer.event, pointer.positions.get(listener));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
windowClosed(windowId: number): void {
|
|
533
|
+
const window = this.pointers.get(windowId);
|
|
534
|
+
if (!window) return;
|
|
535
|
+
this.pointers.delete(windowId);
|
|
536
|
+
for (const pointer of window.values()) this.cancel(pointer);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
dispose(): void {
|
|
540
|
+
for (const id of this.pointers.keys()) this.windowClosed(id);
|
|
541
|
+
for (const listener of this.records.keys()) listener.dispose();
|
|
542
|
+
this.realms.clear();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PointerEvent } from '@galfus/transport-types';
|
|
2
|
+
|
|
3
|
+
export type InteractiveTarget =
|
|
4
|
+
| { type: 'collider'; entityId: number; colliderId?: number }
|
|
5
|
+
| { type: 'svg'; svgId: number; elementId: string };
|
|
6
|
+
|
|
7
|
+
export type InteractiveEventType =
|
|
8
|
+
| 'hover'
|
|
9
|
+
| 'leave'
|
|
10
|
+
| 'move'
|
|
11
|
+
| 'down'
|
|
12
|
+
| 'up'
|
|
13
|
+
| 'click'
|
|
14
|
+
| 'cancel'
|
|
15
|
+
/** Movement delivered to the original pressed target, including outside its hit area. */
|
|
16
|
+
| 'pressmove'
|
|
17
|
+
/** Release delivered to the original pressed target, including outside its hit area. */
|
|
18
|
+
| 'pressup';
|
|
19
|
+
|
|
20
|
+
export interface InteractiveEvent {
|
|
21
|
+
readonly type: InteractiveEventType;
|
|
22
|
+
readonly realmId: number;
|
|
23
|
+
readonly target: InteractiveTarget;
|
|
24
|
+
readonly pointer: PointerEvent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type InteractiveCallback = (event: InteractiveEvent) => void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core mathematical constants and utility functions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Epsilon threshold for floating-point comparisons. */
|
|
6
|
+
export const EPSILON = 0.000001;
|
|
7
|
+
|
|
8
|
+
/** Degree-to-radian conversion factor. */
|
|
9
|
+
export const DEG2RAD = Math.PI / 180;
|
|
10
|
+
|
|
11
|
+
/** Radian-to-degree conversion factor. */
|
|
12
|
+
export const RAD2DEG = 180 / Math.PI;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Interface guaranteeing numerical index access for vectors and matrices
|
|
16
|
+
* under strict TypeScript noUncheckedIndexedAccess policy.
|
|
17
|
+
*/
|
|
18
|
+
export interface IndexedCollection extends Iterable<number> {
|
|
19
|
+
readonly length: number;
|
|
20
|
+
[index: number]: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Converts degrees to radians.
|
|
25
|
+
*
|
|
26
|
+
* @param deg Angle in degrees.
|
|
27
|
+
* @returns Angle in radians.
|
|
28
|
+
*/
|
|
29
|
+
export function toRadian(deg: number): number {
|
|
30
|
+
return deg * DEG2RAD;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Converts radians to degrees.
|
|
35
|
+
*
|
|
36
|
+
* @param rad Angle in radians.
|
|
37
|
+
* @returns Angle in degrees.
|
|
38
|
+
*/
|
|
39
|
+
export function toDegree(rad: number): number {
|
|
40
|
+
return rad * RAD2DEG;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Tests whether two floating-point numbers are approximately equal within EPSILON.
|
|
45
|
+
*
|
|
46
|
+
* @param a First number.
|
|
47
|
+
* @param b Second number.
|
|
48
|
+
* @param epsilon Optional tolerance threshold (defaults to EPSILON).
|
|
49
|
+
* @returns True if the numbers are approximately equal.
|
|
50
|
+
*/
|
|
51
|
+
export function equalsApprox(a: number, b: number, epsilon: number = EPSILON): boolean {
|
|
52
|
+
return Math.abs(a - b) <= epsilon * Math.max(1.0, Math.abs(a), Math.abs(b));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Clamps a scalar value between a minimum and maximum limit.
|
|
57
|
+
*/
|
|
58
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
59
|
+
return Math.min(Math.max(value, min), max);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Linearly interpolates between two numbers.
|
|
64
|
+
*/
|
|
65
|
+
export function lerp(a: number, b: number, t: number): number {
|
|
66
|
+
return a + t * (b - a);
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-performance, zero-allocation math library for 2D and 3D graphics and transforms.
|
|
3
|
+
*/
|
|
4
|
+
export * as common from './common';
|
|
5
|
+
export * as mat2d from './mat2d';
|
|
6
|
+
export * as mat4 from './mat4';
|
|
7
|
+
export * as quat from './quat';
|
|
8
|
+
export * as vec2 from './vec2';
|
|
9
|
+
export * as vec3 from './vec3';
|
|
10
|
+
export * as vec4 from './vec4';
|
|
11
|
+
|
|
12
|
+
export type { Mat2d, ReadonlyMat2d } from './mat2d';
|
|
13
|
+
export type { Mat4, ReadonlyMat4 } from './mat4';
|
|
14
|
+
export type { Quat, ReadonlyQuat } from './quat';
|
|
15
|
+
export type { ReadonlyVec2, Vec2, WritableVec2 } from './vec2';
|
|
16
|
+
export type { ReadonlyVec3, Vec3, WritableVec3 } from './vec3';
|
|
17
|
+
export type { ReadonlyVec4, Vec4 } from './vec4';
|