@realitycollective/webxr-interactions 0.1.1-preview.1 → 0.1.1-preview.3
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/CHANGELOG.md +29 -0
- package/README.md +3 -0
- package/dist/behaviours/grab.js.map +1 -1
- package/dist/contract-cases.d.ts +19 -0
- package/dist/contract-cases.js +128 -0
- package/dist/contract-cases.js.map +1 -1
- package/dist/descriptor.d.ts +7 -1
- package/dist/descriptor.js.map +1 -1
- package/dist/gaze.d.ts +50 -0
- package/dist/gaze.js +162 -0
- package/dist/gaze.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/memory-physics.d.ts +41 -0
- package/dist/memory-physics.js +168 -0
- package/dist/memory-physics.js.map +1 -0
- package/dist/near-pointer-contract-cases.d.ts +16 -0
- package/dist/near-pointer-contract-cases.js +235 -0
- package/dist/near-pointer-contract-cases.js.map +1 -0
- package/dist/near-pointer.d.ts +131 -0
- package/dist/near-pointer.js +128 -0
- package/dist/near-pointer.js.map +1 -0
- package/dist/physics-contract-cases.d.ts +11 -0
- package/dist/physics-contract-cases.js +229 -0
- package/dist/physics-contract-cases.js.map +1 -0
- package/dist/physics.d.ts +148 -0
- package/dist/physics.js +35 -0
- package/dist/physics.js.map +1 -0
- package/dist/ports.d.ts +51 -1
- package/dist/ports.js.map +1 -1
- package/dist/runtime.d.ts +93 -4
- package/dist/runtime.js +299 -37
- package/dist/runtime.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared near-pointer conformance suite: the touch press, the grip grab,
|
|
3
|
+
* pointer priority with its selection lock, and the ray and cursor visuals
|
|
4
|
+
* (`near-pointer.ts`), run through a binding's own `HitTester` so every
|
|
5
|
+
* platform proves the same rule over its own scene queries.
|
|
6
|
+
*
|
|
7
|
+
* Runner-free, like `hitTesterContractCases()`: an adapter's test file is a
|
|
8
|
+
* loop over these cases with the same subject factory it already uses for
|
|
9
|
+
* the hit-tester suite. The suite drives an `InteractionRuntime` itself, with
|
|
10
|
+
* a scripted provider, so the only platform code under test is the hit
|
|
11
|
+
* tester and the targets the driver places.
|
|
12
|
+
*
|
|
13
|
+
* Every distance below is metres to the target's SURFACE, the quantity the
|
|
14
|
+
* touch and grab pointers compare (IWSDK's sphere intersector reports the
|
|
15
|
+
* distance from the fingertip to the point on the mesh).
|
|
16
|
+
*/
|
|
17
|
+
import { NO_CAPABILITIES, } from "@realitycollective/webxr-input";
|
|
18
|
+
import { NEAR_POINTER_DEFAULTS } from "./near-pointer.js";
|
|
19
|
+
import { InteractionRuntime } from "./runtime.js";
|
|
20
|
+
/**
|
|
21
|
+
* The shared near-pointer conformance suite. An adapter's test file:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* for (const contractCase of nearPointerContractCases()) {
|
|
25
|
+
* it(contractCase.name, () => contractCase.run(makeSubject()));
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function nearPointerContractCases() {
|
|
30
|
+
return NEAR_POINTER_CASES;
|
|
31
|
+
}
|
|
32
|
+
/** A provider whose sources a case sets frame by frame. */
|
|
33
|
+
class ScriptedProvider {
|
|
34
|
+
sources = [];
|
|
35
|
+
capabilities = {
|
|
36
|
+
...NO_CAPABILITIES,
|
|
37
|
+
rays: true,
|
|
38
|
+
pokes: true,
|
|
39
|
+
grabs: "poseOnly",
|
|
40
|
+
handJoints: true,
|
|
41
|
+
pinch: true,
|
|
42
|
+
headPose: false,
|
|
43
|
+
gaze: false,
|
|
44
|
+
};
|
|
45
|
+
getCapabilities() {
|
|
46
|
+
return this.capabilities;
|
|
47
|
+
}
|
|
48
|
+
onCapabilitiesChanged() {
|
|
49
|
+
return () => undefined;
|
|
50
|
+
}
|
|
51
|
+
onSourcesChanged() {
|
|
52
|
+
return () => undefined;
|
|
53
|
+
}
|
|
54
|
+
sample() {
|
|
55
|
+
return this.sources;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const CENTRE = [0, 1, -1];
|
|
59
|
+
const RADIUS = 0.1;
|
|
60
|
+
/** A ray from the viewer straight through the target. */
|
|
61
|
+
const RAY_AT = { origin: [0, 1, 0], direction: [0, 0, -1] };
|
|
62
|
+
/** A ray pointing away from everything. */
|
|
63
|
+
const RAY_AWAY = { origin: [0, 1, 0], direction: [0, 0, 1] };
|
|
64
|
+
const FAR = [0, 1, 1];
|
|
65
|
+
/** A point `distance` metres outside the target's surface, on its near side. */
|
|
66
|
+
function outside(distance) {
|
|
67
|
+
return [CENTRE[0], CENTRE[1], CENTRE[2] + RADIUS + distance];
|
|
68
|
+
}
|
|
69
|
+
function hand(frame) {
|
|
70
|
+
const snapshot = {
|
|
71
|
+
id: "right",
|
|
72
|
+
kind: frame.kind ?? "hand",
|
|
73
|
+
handedness: "right",
|
|
74
|
+
select: frame.select ?? 0,
|
|
75
|
+
squeeze: frame.squeeze ?? 0,
|
|
76
|
+
};
|
|
77
|
+
const ray = frame.ray ?? "away";
|
|
78
|
+
if (ray !== "none")
|
|
79
|
+
snapshot.ray = ray === "at" ? { ...RAY_AT } : { ...RAY_AWAY };
|
|
80
|
+
snapshot.indexTip = frame.tip ?? FAR;
|
|
81
|
+
snapshot.gripPose = { position: frame.grip ?? FAR, quaternion: [0, 0, 0, 1] };
|
|
82
|
+
return snapshot;
|
|
83
|
+
}
|
|
84
|
+
function rig(subject, behaviours = [{ kind: "press" }]) {
|
|
85
|
+
subject.driver.place("target", CENTRE, RADIUS);
|
|
86
|
+
const provider = new ScriptedProvider();
|
|
87
|
+
const runtime = new InteractionRuntime({ provider, hitTester: subject.hitTester, velocity: false });
|
|
88
|
+
const events = [];
|
|
89
|
+
const visuals = [];
|
|
90
|
+
runtime.onEvent((event) => events.push(event));
|
|
91
|
+
runtime.onPointerVisuals((list) => {
|
|
92
|
+
visuals.length = 0;
|
|
93
|
+
visuals.push(...list);
|
|
94
|
+
});
|
|
95
|
+
runtime.registerInteractable({ id: "target", behaviours });
|
|
96
|
+
return {
|
|
97
|
+
runtime,
|
|
98
|
+
provider,
|
|
99
|
+
events,
|
|
100
|
+
visuals,
|
|
101
|
+
frame(frame) {
|
|
102
|
+
const before = events.length;
|
|
103
|
+
provider.sources = [hand(frame)];
|
|
104
|
+
runtime.update(1 / 60);
|
|
105
|
+
return events.slice(before).map((event) => event.type);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function assert(condition, message) {
|
|
110
|
+
if (!condition)
|
|
111
|
+
throw new Error(message);
|
|
112
|
+
}
|
|
113
|
+
function expectActive(r, kind, when) {
|
|
114
|
+
const active = r.runtime.getActivePointer("right");
|
|
115
|
+
assert(active === kind, `${when}: the active pointer must be ${String(kind)}, got ${String(active)}`);
|
|
116
|
+
}
|
|
117
|
+
const D = NEAR_POINTER_DEFAULTS;
|
|
118
|
+
const NEAR_POINTER_CASES = [
|
|
119
|
+
{
|
|
120
|
+
name: "a fingertip approaching from outside hovers at 0.15 m, presses at 0.02 m, releases on the way out and stops hovering beyond 0.2 m",
|
|
121
|
+
run(subject) {
|
|
122
|
+
const r = rig(subject);
|
|
123
|
+
r.frame({ tip: outside(0.3) });
|
|
124
|
+
expectActive(r, null, "at 0.3 m");
|
|
125
|
+
let types = r.frame({ tip: outside(D.touchHoverEnter - 0.01) });
|
|
126
|
+
expectActive(r, "touch", "inside the hover enter distance");
|
|
127
|
+
assert(types.includes("hoverEnter"), "entering the hover band must raise hoverEnter");
|
|
128
|
+
types = r.frame({ tip: outside(0.05) });
|
|
129
|
+
assert(!types.includes("pressStart"), "5 cm out must not press");
|
|
130
|
+
types = r.frame({ tip: outside(D.touchDown) });
|
|
131
|
+
assert(types.includes("pressStart"), "reaching the press distance must raise pressStart");
|
|
132
|
+
assert(r.runtime.getState("target")?.pressed === true, "the target must be pressed");
|
|
133
|
+
types = r.frame({ tip: outside(0.005) });
|
|
134
|
+
assert(!types.includes("pressEnd") && !types.includes("pressStart"), "going deeper must change nothing");
|
|
135
|
+
types = r.frame({ tip: outside(D.touchDown + 0.01) });
|
|
136
|
+
assert(types.includes("pressEnd"), "coming back out past the press distance must release");
|
|
137
|
+
r.frame({ tip: outside(D.touchHoverEnter + 0.02) });
|
|
138
|
+
expectActive(r, "touch", "between enter and exit while hovering (hysteresis)");
|
|
139
|
+
types = r.frame({ tip: outside(D.touchHoverExit + 0.01) });
|
|
140
|
+
expectActive(r, null, "beyond the exit distance");
|
|
141
|
+
assert(types.includes("hoverExit"), "leaving the exit distance must raise hoverExit");
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "a fingertip first seen inside the press band never presses until it has been outside it",
|
|
146
|
+
run(subject) {
|
|
147
|
+
const r = rig(subject);
|
|
148
|
+
let types = r.frame({ tip: outside(0.01) });
|
|
149
|
+
assert(types.includes("hoverEnter") && !types.includes("pressStart"), "a fingertip that appears inside the band hovers but must not press");
|
|
150
|
+
types = r.frame({ tip: outside(0.0) });
|
|
151
|
+
assert(!types.includes("pressStart"), "staying inside must not press");
|
|
152
|
+
r.frame({ tip: outside(0.05) });
|
|
153
|
+
types = r.frame({ tip: outside(0.015) });
|
|
154
|
+
assert(types.includes("pressStart"), "coming back in from outside must press");
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "a grip within 0.07 m of the surface makes grab the active pointer and a squeeze grabs that target",
|
|
159
|
+
run(subject) {
|
|
160
|
+
const r = rig(subject, [{ kind: "grab" }]);
|
|
161
|
+
r.frame({ grip: outside(D.grabRadius - 0.02), ray: "at" });
|
|
162
|
+
expectActive(r, "grab", "grip inside the grab radius, ray also hitting");
|
|
163
|
+
let types = r.frame({ grip: outside(D.grabRadius - 0.02), ray: "at", squeeze: 1 });
|
|
164
|
+
assert(types.includes("grabStart"), "a squeeze while grab owns the hand must grab");
|
|
165
|
+
types = r.frame({ grip: outside(D.grabRadius - 0.02), ray: "at", squeeze: 0 });
|
|
166
|
+
assert(types.includes("grabEnd"), "releasing the squeeze must end the grab");
|
|
167
|
+
r.frame({ grip: outside(D.grabRadius + 0.01), ray: "away" });
|
|
168
|
+
expectActive(r, null, "grip outside the grab radius with no ray hit");
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "touch beats grab beats ray",
|
|
173
|
+
run(subject) {
|
|
174
|
+
const r = rig(subject);
|
|
175
|
+
r.frame({ tip: outside(0.1), grip: outside(0.05), ray: "at" });
|
|
176
|
+
expectActive(r, "touch", "fingertip, grip and ray all on the target");
|
|
177
|
+
r.frame({ grip: outside(0.05), ray: "at" });
|
|
178
|
+
expectActive(r, "grab", "grip and ray on the target");
|
|
179
|
+
r.frame({ ray: "at" });
|
|
180
|
+
expectActive(r, "ray", "only the ray on the target");
|
|
181
|
+
r.frame({ ray: "away" });
|
|
182
|
+
expectActive(r, null, "nothing on the target");
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "the ray and its cursor show only while the ray owns the source, and the cursor sits at the active pointer's hit",
|
|
187
|
+
run(subject) {
|
|
188
|
+
const r = rig(subject);
|
|
189
|
+
r.frame({ ray: "at" });
|
|
190
|
+
let v = r.visuals[0];
|
|
191
|
+
assert(v.ray && v.cursor && v.cursorPoint !== null, "with the ray active, the ray and its cursor show");
|
|
192
|
+
assert(Math.abs(v.cursorPoint[2] - (CENTRE[2] + RADIUS)) <= RADIUS + 0.05, "the cursor sits near the ray's hit on the target");
|
|
193
|
+
r.frame({ tip: outside(0.05), ray: "at" });
|
|
194
|
+
v = r.visuals[0];
|
|
195
|
+
assert(!v.ray, "with touch active, the ray is hidden");
|
|
196
|
+
assert(v.cursor && v.cursorPoint !== null, "with touch active, the cursor shows at the touch point");
|
|
197
|
+
assert(Math.abs(v.cursorPoint[2] - (CENTRE[2] + RADIUS)) <= RADIUS + 0.05, "the cursor sits on the surface under the fingertip");
|
|
198
|
+
r.frame({ grip: outside(0.05), ray: "at" });
|
|
199
|
+
v = r.visuals[0];
|
|
200
|
+
assert(!v.ray && v.cursor, "with grab active, the ray is hidden and the cursor shows");
|
|
201
|
+
r.frame({ ray: "away" });
|
|
202
|
+
v = r.visuals[0];
|
|
203
|
+
assert(v.ray && !v.cursor && v.cursorPoint === null, "with nothing active, the ray shows and the cursor does not");
|
|
204
|
+
r.frame({ ray: "none" });
|
|
205
|
+
v = r.visuals[0];
|
|
206
|
+
assert(!v.ray && !v.cursor, "a source with no ray draws no ray");
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "a pointer that is pressing keeps the source until it releases",
|
|
211
|
+
run(subject) {
|
|
212
|
+
const r = rig(subject);
|
|
213
|
+
r.frame({ ray: "at" });
|
|
214
|
+
let types = r.frame({ ray: "at", select: 1 });
|
|
215
|
+
assert(types.includes("pressStart"), "a select on the ray target presses it");
|
|
216
|
+
r.frame({ tip: outside(0.05), ray: "at", select: 1 });
|
|
217
|
+
expectActive(r, "ray", "a fingertip entering the band while the ray presses");
|
|
218
|
+
types = r.frame({ tip: outside(0.05), ray: "at", select: 0 });
|
|
219
|
+
assert(types.includes("pressEnd"), "releasing select ends the ray press");
|
|
220
|
+
r.frame({ tip: outside(0.05), ray: "at" });
|
|
221
|
+
expectActive(r, "touch", "once released, touch takes the source");
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "a controller's ray origin presses by touch exactly as a fingertip does",
|
|
226
|
+
run(subject) {
|
|
227
|
+
const r = rig(subject);
|
|
228
|
+
r.frame({ kind: "controller", tip: outside(0.1) });
|
|
229
|
+
expectActive(r, "touch", "a controller nose inside the hover band");
|
|
230
|
+
const types = r.frame({ kind: "controller", tip: outside(0.01) });
|
|
231
|
+
assert(types.includes("pressStart"), "a controller nose reaching the press distance presses");
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
//# sourceMappingURL=near-pointer-contract-cases.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"near-pointer-contract-cases.js","sourceRoot":"","sources":["../src/near-pointer-contract-cases.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The shared near-pointer conformance suite: the touch press, the grip grab,\n * pointer priority with its selection lock, and the ray and cursor visuals\n * (`near-pointer.ts`), run through a binding's own `HitTester` so every\n * platform proves the same rule over its own scene queries.\n *\n * Runner-free, like `hitTesterContractCases()`: an adapter's test file is a\n * loop over these cases with the same subject factory it already uses for\n * the hit-tester suite. The suite drives an `InteractionRuntime` itself, with\n * a scripted provider, so the only platform code under test is the hit\n * tester and the targets the driver places.\n *\n * Every distance below is metres to the target's SURFACE, the quantity the\n * touch and grab pointers compare (IWSDK's sphere intersector reports the\n * distance from the fingertip to the point on the mesh).\n */\nimport {\n NO_CAPABILITIES,\n type InputCapabilities,\n type InputProvider,\n type InputSourceSnapshot,\n type Vec3Tuple,\n} from \"@realitycollective/webxr-input\";\nimport type { HitTesterContractSubject } from \"./contract-cases.js\";\nimport type { InteractionEvent } from \"./events.js\";\nimport { NEAR_POINTER_DEFAULTS, type PointerVisuals } from \"./near-pointer.js\";\nimport { InteractionRuntime } from \"./runtime.js\";\n\n/** One check a binding's hit tester must pass under the near-pointer rule. */\nexport interface NearPointerContractCase {\n name: string;\n run(subject: HitTesterContractSubject): void;\n}\n\n/**\n * The shared near-pointer conformance suite. An adapter's test file:\n *\n * ```ts\n * for (const contractCase of nearPointerContractCases()) {\n * it(contractCase.name, () => contractCase.run(makeSubject()));\n * }\n * ```\n */\nexport function nearPointerContractCases(): readonly NearPointerContractCase[] {\n return NEAR_POINTER_CASES;\n}\n\n/** A provider whose sources a case sets frame by frame. */\nclass ScriptedProvider implements InputProvider {\n sources: InputSourceSnapshot[] = [];\n private readonly capabilities: InputCapabilities = {\n ...NO_CAPABILITIES,\n rays: true,\n pokes: true,\n grabs: \"poseOnly\",\n handJoints: true,\n pinch: true,\n headPose: false,\n gaze: false,\n };\n\n getCapabilities(): InputCapabilities {\n return this.capabilities;\n }\n\n onCapabilitiesChanged(): () => void {\n return () => undefined;\n }\n\n onSourcesChanged(): () => void {\n return () => undefined;\n }\n\n sample(): readonly InputSourceSnapshot[] {\n return this.sources;\n }\n}\n\nconst CENTRE: Vec3Tuple = [0, 1, -1];\nconst RADIUS = 0.1;\n/** A ray from the viewer straight through the target. */\nconst RAY_AT = { origin: [0, 1, 0] as Vec3Tuple, direction: [0, 0, -1] as Vec3Tuple };\n/** A ray pointing away from everything. */\nconst RAY_AWAY = { origin: [0, 1, 0] as Vec3Tuple, direction: [0, 0, 1] as Vec3Tuple };\nconst FAR: Vec3Tuple = [0, 1, 1];\n\n/** A point `distance` metres outside the target's surface, on its near side. */\nfunction outside(distance: number): Vec3Tuple {\n return [CENTRE[0], CENTRE[1], CENTRE[2] + RADIUS + distance];\n}\n\ninterface HandFrame {\n tip?: Vec3Tuple;\n grip?: Vec3Tuple;\n ray?: \"at\" | \"away\" | \"none\";\n select?: number;\n squeeze?: number;\n kind?: \"hand\" | \"controller\";\n}\n\nfunction hand(frame: HandFrame): InputSourceSnapshot {\n const snapshot: InputSourceSnapshot = {\n id: \"right\",\n kind: frame.kind ?? \"hand\",\n handedness: \"right\",\n select: frame.select ?? 0,\n squeeze: frame.squeeze ?? 0,\n };\n const ray = frame.ray ?? \"away\";\n if (ray !== \"none\") snapshot.ray = ray === \"at\" ? { ...RAY_AT } : { ...RAY_AWAY };\n snapshot.indexTip = frame.tip ?? FAR;\n snapshot.gripPose = { position: frame.grip ?? FAR, quaternion: [0, 0, 0, 1] };\n return snapshot;\n}\n\ninterface Rig {\n runtime: InteractionRuntime;\n provider: ScriptedProvider;\n events: InteractionEvent[];\n visuals: PointerVisuals[];\n /** Feed one frame and return the event types it produced. */\n frame(frame: HandFrame): string[];\n}\n\nfunction rig(subject: HitTesterContractSubject, behaviours: Array<{ kind: \"press\" } | { kind: \"grab\" }> = [{ kind: \"press\" }]): Rig {\n subject.driver.place(\"target\", CENTRE, RADIUS);\n const provider = new ScriptedProvider();\n const runtime = new InteractionRuntime({ provider, hitTester: subject.hitTester, velocity: false });\n const events: InteractionEvent[] = [];\n const visuals: PointerVisuals[] = [];\n runtime.onEvent((event) => events.push(event));\n runtime.onPointerVisuals((list) => {\n visuals.length = 0;\n visuals.push(...list);\n });\n runtime.registerInteractable({ id: \"target\", behaviours });\n return {\n runtime,\n provider,\n events,\n visuals,\n frame(frame) {\n const before = events.length;\n provider.sources = [hand(frame)];\n runtime.update(1 / 60);\n return events.slice(before).map((event) => event.type);\n },\n };\n}\n\nfunction assert(condition: boolean, message: string): asserts condition {\n if (!condition) throw new Error(message);\n}\n\nfunction expectActive(r: Rig, kind: string | null, when: string): void {\n const active = r.runtime.getActivePointer(\"right\");\n assert(active === kind, `${when}: the active pointer must be ${String(kind)}, got ${String(active)}`);\n}\n\nconst D = NEAR_POINTER_DEFAULTS;\n\nconst NEAR_POINTER_CASES: readonly NearPointerContractCase[] = [\n {\n name: \"a fingertip approaching from outside hovers at 0.15 m, presses at 0.02 m, releases on the way out and stops hovering beyond 0.2 m\",\n run(subject) {\n const r = rig(subject);\n r.frame({ tip: outside(0.3) });\n expectActive(r, null, \"at 0.3 m\");\n let types = r.frame({ tip: outside(D.touchHoverEnter - 0.01) });\n expectActive(r, \"touch\", \"inside the hover enter distance\");\n assert(types.includes(\"hoverEnter\"), \"entering the hover band must raise hoverEnter\");\n types = r.frame({ tip: outside(0.05) });\n assert(!types.includes(\"pressStart\"), \"5 cm out must not press\");\n types = r.frame({ tip: outside(D.touchDown) });\n assert(types.includes(\"pressStart\"), \"reaching the press distance must raise pressStart\");\n assert(r.runtime.getState(\"target\")?.pressed === true, \"the target must be pressed\");\n types = r.frame({ tip: outside(0.005) });\n assert(!types.includes(\"pressEnd\") && !types.includes(\"pressStart\"), \"going deeper must change nothing\");\n types = r.frame({ tip: outside(D.touchDown + 0.01) });\n assert(types.includes(\"pressEnd\"), \"coming back out past the press distance must release\");\n r.frame({ tip: outside(D.touchHoverEnter + 0.02) });\n expectActive(r, \"touch\", \"between enter and exit while hovering (hysteresis)\");\n types = r.frame({ tip: outside(D.touchHoverExit + 0.01) });\n expectActive(r, null, \"beyond the exit distance\");\n assert(types.includes(\"hoverExit\"), \"leaving the exit distance must raise hoverExit\");\n },\n },\n {\n name: \"a fingertip first seen inside the press band never presses until it has been outside it\",\n run(subject) {\n const r = rig(subject);\n let types = r.frame({ tip: outside(0.01) });\n assert(types.includes(\"hoverEnter\") && !types.includes(\"pressStart\"), \"a fingertip that appears inside the band hovers but must not press\");\n types = r.frame({ tip: outside(0.0) });\n assert(!types.includes(\"pressStart\"), \"staying inside must not press\");\n r.frame({ tip: outside(0.05) });\n types = r.frame({ tip: outside(0.015) });\n assert(types.includes(\"pressStart\"), \"coming back in from outside must press\");\n },\n },\n {\n name: \"a grip within 0.07 m of the surface makes grab the active pointer and a squeeze grabs that target\",\n run(subject) {\n const r = rig(subject, [{ kind: \"grab\" }]);\n r.frame({ grip: outside(D.grabRadius - 0.02), ray: \"at\" });\n expectActive(r, \"grab\", \"grip inside the grab radius, ray also hitting\");\n let types = r.frame({ grip: outside(D.grabRadius - 0.02), ray: \"at\", squeeze: 1 });\n assert(types.includes(\"grabStart\"), \"a squeeze while grab owns the hand must grab\");\n types = r.frame({ grip: outside(D.grabRadius - 0.02), ray: \"at\", squeeze: 0 });\n assert(types.includes(\"grabEnd\"), \"releasing the squeeze must end the grab\");\n r.frame({ grip: outside(D.grabRadius + 0.01), ray: \"away\" });\n expectActive(r, null, \"grip outside the grab radius with no ray hit\");\n },\n },\n {\n name: \"touch beats grab beats ray\",\n run(subject) {\n const r = rig(subject);\n r.frame({ tip: outside(0.1), grip: outside(0.05), ray: \"at\" });\n expectActive(r, \"touch\", \"fingertip, grip and ray all on the target\");\n r.frame({ grip: outside(0.05), ray: \"at\" });\n expectActive(r, \"grab\", \"grip and ray on the target\");\n r.frame({ ray: \"at\" });\n expectActive(r, \"ray\", \"only the ray on the target\");\n r.frame({ ray: \"away\" });\n expectActive(r, null, \"nothing on the target\");\n },\n },\n {\n name: \"the ray and its cursor show only while the ray owns the source, and the cursor sits at the active pointer's hit\",\n run(subject) {\n const r = rig(subject);\n r.frame({ ray: \"at\" });\n let v = r.visuals[0]!;\n assert(v.ray && v.cursor && v.cursorPoint !== null, \"with the ray active, the ray and its cursor show\");\n assert(Math.abs(v.cursorPoint![2] - (CENTRE[2] + RADIUS)) <= RADIUS + 0.05, \"the cursor sits near the ray's hit on the target\");\n r.frame({ tip: outside(0.05), ray: \"at\" });\n v = r.visuals[0]!;\n assert(!v.ray, \"with touch active, the ray is hidden\");\n assert(v.cursor && v.cursorPoint !== null, \"with touch active, the cursor shows at the touch point\");\n assert(Math.abs(v.cursorPoint![2] - (CENTRE[2] + RADIUS)) <= RADIUS + 0.05, \"the cursor sits on the surface under the fingertip\");\n r.frame({ grip: outside(0.05), ray: \"at\" });\n v = r.visuals[0]!;\n assert(!v.ray && v.cursor, \"with grab active, the ray is hidden and the cursor shows\");\n r.frame({ ray: \"away\" });\n v = r.visuals[0]!;\n assert(v.ray && !v.cursor && v.cursorPoint === null, \"with nothing active, the ray shows and the cursor does not\");\n r.frame({ ray: \"none\" });\n v = r.visuals[0]!;\n assert(!v.ray && !v.cursor, \"a source with no ray draws no ray\");\n },\n },\n {\n name: \"a pointer that is pressing keeps the source until it releases\",\n run(subject) {\n const r = rig(subject);\n r.frame({ ray: \"at\" });\n let types = r.frame({ ray: \"at\", select: 1 });\n assert(types.includes(\"pressStart\"), \"a select on the ray target presses it\");\n r.frame({ tip: outside(0.05), ray: \"at\", select: 1 });\n expectActive(r, \"ray\", \"a fingertip entering the band while the ray presses\");\n types = r.frame({ tip: outside(0.05), ray: \"at\", select: 0 });\n assert(types.includes(\"pressEnd\"), \"releasing select ends the ray press\");\n r.frame({ tip: outside(0.05), ray: \"at\" });\n expectActive(r, \"touch\", \"once released, touch takes the source\");\n },\n },\n {\n name: \"a controller's ray origin presses by touch exactly as a fingertip does\",\n run(subject) {\n const r = rig(subject);\n r.frame({ kind: \"controller\", tip: outside(0.1) });\n expectActive(r, \"touch\", \"a controller nose inside the hover band\");\n const types = r.frame({ kind: \"controller\", tip: outside(0.01) });\n assert(types.includes(\"pressStart\"), \"a controller nose reaching the press distance presses\");\n },\n },\n];\n"]}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Near pointers - the touch press, the grip grab, which pointer owns a hand
|
|
3
|
+
* and what that means for the ray and cursor. Pure logic, no engine.
|
|
4
|
+
*
|
|
5
|
+
* The template is IWSDK 1.0.0's pointer stack (`@iwsdk/xr-input`
|
|
6
|
+
* `multi-pointer.js`, `touch-pointer.js`, `grab-pointer.js`, over
|
|
7
|
+
* `@pmndrs/pointer-events`), restated here so every binding runs the same
|
|
8
|
+
* rule from the facts it already reports (`indexTip`, `gripPose`, `ray`):
|
|
9
|
+
*
|
|
10
|
+
* - **Touch.** A fingertip (a controller's ray origin stands in for one)
|
|
11
|
+
* hovers a target once its distance to the target's SURFACE is within
|
|
12
|
+
* `touchHoverEnter` (0.15 m) and keeps hovering until it is beyond
|
|
13
|
+
* `touchHoverExit` (0.2 m): `TOUCH_HYSTERESIS`. It presses while that
|
|
14
|
+
* distance is at or under `touchDown` (0.02 m): `TOUCH_DEFAULTS.downRadius`,
|
|
15
|
+
* which `@pmndrs/pointer-events` compares every frame, so the release is
|
|
16
|
+
* the way out past the same distance.
|
|
17
|
+
* - **Grab.** The grip is a sphere of `grabRadius` (0.07 m,
|
|
18
|
+
* `createGrabPointer`'s default) and a target whose surface it reaches is
|
|
19
|
+
* the grab candidate. The squeeze (or a hand's pinch on a grab-only
|
|
20
|
+
* target) then grabs it.
|
|
21
|
+
* - **Priority.** Per source, the first of touch, grab, ray with a candidate
|
|
22
|
+
* is the active pointer (`PRIORITY_ORDER`), and once it is pressing or
|
|
23
|
+
* grabbing it stays active until the release (IWSDK's selection lock).
|
|
24
|
+
* - **Visuals.** The ray and its cursor show only while the ray is the active
|
|
25
|
+
* pointer or nothing is (`shouldHideRay`); a near pointer owning the hand
|
|
26
|
+
* hides them, and the shared cursor sits at the active pointer's hit point.
|
|
27
|
+
*
|
|
28
|
+
* One rule is deliberately stricter than the template, as the Pale Signal
|
|
29
|
+
* work order of 28 September 2026 required: a touch never presses from inside
|
|
30
|
+
* or from behind. A fingertip first seen already within the press band, a
|
|
31
|
+
* hand that pops into tracking inside the target, does not press until it has
|
|
32
|
+
* been seen outside the band and comes back in. IWSDK's touch pointer would
|
|
33
|
+
* press on that first frame; `touch-reference.test.ts` records the one frame
|
|
34
|
+
* on which the two differ, and that they agree everywhere else.
|
|
35
|
+
*/
|
|
36
|
+
/** The pointer that owns a source this frame. `"gaze"` is the eye-gaze source's own pointer. */
|
|
37
|
+
export type ActivePointerKind = "touch" | "grab" | "ray" | "gaze";
|
|
38
|
+
/** IWSDK's order: the first with a candidate wins. `multi-pointer.js` `PRIORITY_ORDER`. */
|
|
39
|
+
export declare const POINTER_PRIORITY: readonly ActivePointerKind[];
|
|
40
|
+
export interface NearPointerOptions {
|
|
41
|
+
/** Surface distance, metres, within which a fingertip starts hovering a target. IWSDK `enterHoverDistance` 0.15. */
|
|
42
|
+
touchHoverEnter: number;
|
|
43
|
+
/** Surface distance, metres, beyond which a hovering fingertip stops hovering. IWSDK `exitHoverDistance` 0.2. Never less than `touchHoverEnter`. */
|
|
44
|
+
touchHoverExit: number;
|
|
45
|
+
/** Surface distance, metres, at or under which a hovering fingertip presses, and above which it releases. IWSDK `downRadius` 0.02. */
|
|
46
|
+
touchDown: number;
|
|
47
|
+
/** Radius, metres, of the grip sphere that finds a grab candidate. `createGrabPointer` default 0.07. */
|
|
48
|
+
grabRadius: number;
|
|
49
|
+
}
|
|
50
|
+
/** IWSDK 1.0.0's constants: `TOUCH_HYSTERESIS`, `TOUCH_DEFAULTS.downRadius`, `createGrabPointer`'s radius. */
|
|
51
|
+
export declare const NEAR_POINTER_DEFAULTS: Readonly<NearPointerOptions>;
|
|
52
|
+
export declare function resolveNearPointerOptions(options?: Partial<NearPointerOptions>): NearPointerOptions;
|
|
53
|
+
/** What the touch machine reports after one frame. */
|
|
54
|
+
export interface TouchPointerUpdate {
|
|
55
|
+
/** The fingertip is hovering a target this frame (the touch pointer has a candidate). */
|
|
56
|
+
hovering: boolean;
|
|
57
|
+
/** The touch is pressing this frame. */
|
|
58
|
+
pressed: boolean;
|
|
59
|
+
/** True on the exact frame a press started. */
|
|
60
|
+
pressStarted: boolean;
|
|
61
|
+
/** True on the exact frame a press ended. */
|
|
62
|
+
pressEnded: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* One source's touch pointer: hover with hysteresis, press at `touchDown`
|
|
66
|
+
* armed only from outside the band. Feed it the fingertip's surface distance
|
|
67
|
+
* to the nearest target each frame, or `null` when no target is in reach.
|
|
68
|
+
*/
|
|
69
|
+
export declare class TouchPointerState {
|
|
70
|
+
private hovering;
|
|
71
|
+
private pressed;
|
|
72
|
+
/** The fingertip has been seen outside the press band since it last lost contact. */
|
|
73
|
+
private armed;
|
|
74
|
+
get isHovering(): boolean;
|
|
75
|
+
get isPressed(): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Advance one frame. `enter`/`exit` are the hover band for the target in
|
|
78
|
+
* question (an interactable may widen the default), `down` the press
|
|
79
|
+
* distance.
|
|
80
|
+
*/
|
|
81
|
+
update(distance: number | null, enter: number, exit: number, down: number): TouchPointerUpdate;
|
|
82
|
+
/** Drop everything without reporting (the source went away). */
|
|
83
|
+
reset(): void;
|
|
84
|
+
}
|
|
85
|
+
/** A candidate one pointer found: the target and the point the cursor sits at. */
|
|
86
|
+
export interface PointerCandidate {
|
|
87
|
+
targetId: string;
|
|
88
|
+
/** World-space point: the ray's hit, or the closest point of a near target. */
|
|
89
|
+
point: readonly [number, number, number];
|
|
90
|
+
/** Metres: the ray parameter for a ray, the surface distance for a near pointer. */
|
|
91
|
+
distance: number;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* IWSDK's `pickActiveByPriority` with its selection lock: while the current
|
|
95
|
+
* active pointer is selecting it stays; otherwise the first pointer in
|
|
96
|
+
* `POINTER_PRIORITY` with a candidate wins, or none.
|
|
97
|
+
*/
|
|
98
|
+
export declare function pickActivePointer(candidates: {
|
|
99
|
+
touch: boolean;
|
|
100
|
+
grab: boolean;
|
|
101
|
+
ray: boolean;
|
|
102
|
+
}, current: ActivePointerKind | null, selecting: boolean): ActivePointerKind | null;
|
|
103
|
+
/**
|
|
104
|
+
* What a binding shows for one source: IWSDK's `shouldHideRay` and the shared
|
|
105
|
+
* cursor from `MultiPointer.update`. A host draws exactly this and decides
|
|
106
|
+
* nothing.
|
|
107
|
+
*/
|
|
108
|
+
export interface PointerVisuals {
|
|
109
|
+
/** The source these visuals belong to. */
|
|
110
|
+
sourceId: string;
|
|
111
|
+
/** The pointer owning the source, or null when none has a candidate. */
|
|
112
|
+
activePointer: ActivePointerKind | null;
|
|
113
|
+
/**
|
|
114
|
+
* Draw this source's ray. True while the source has a ray and no near
|
|
115
|
+
* pointer owns it: the ray is active, or nothing is. False while touch or
|
|
116
|
+
* grab owns the hand, while eye gaze has taken the far ray, and for a
|
|
117
|
+
* source with no ray at all.
|
|
118
|
+
*/
|
|
119
|
+
ray: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Draw the cursor disc at `cursorPoint`. True exactly while the active
|
|
122
|
+
* pointer has a candidate: at the ray's hit, or on the surface the
|
|
123
|
+
* fingertip or grip reaches. Never drawn "at every ray hit" regardless of
|
|
124
|
+
* ownership, which is what the September 2026 native contract said.
|
|
125
|
+
*/
|
|
126
|
+
cursor: boolean;
|
|
127
|
+
/** World-space cursor position while `cursor` is true, else null. */
|
|
128
|
+
cursorPoint: readonly [number, number, number] | null;
|
|
129
|
+
}
|
|
130
|
+
/** Build the visuals for one source from its active pointer and that pointer's candidate. */
|
|
131
|
+
export declare function pointerVisualsFor(sourceId: string, hasRay: boolean, active: ActivePointerKind | null, candidate: PointerCandidate | null): PointerVisuals;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Near pointers - the touch press, the grip grab, which pointer owns a hand
|
|
3
|
+
* and what that means for the ray and cursor. Pure logic, no engine.
|
|
4
|
+
*
|
|
5
|
+
* The template is IWSDK 1.0.0's pointer stack (`@iwsdk/xr-input`
|
|
6
|
+
* `multi-pointer.js`, `touch-pointer.js`, `grab-pointer.js`, over
|
|
7
|
+
* `@pmndrs/pointer-events`), restated here so every binding runs the same
|
|
8
|
+
* rule from the facts it already reports (`indexTip`, `gripPose`, `ray`):
|
|
9
|
+
*
|
|
10
|
+
* - **Touch.** A fingertip (a controller's ray origin stands in for one)
|
|
11
|
+
* hovers a target once its distance to the target's SURFACE is within
|
|
12
|
+
* `touchHoverEnter` (0.15 m) and keeps hovering until it is beyond
|
|
13
|
+
* `touchHoverExit` (0.2 m): `TOUCH_HYSTERESIS`. It presses while that
|
|
14
|
+
* distance is at or under `touchDown` (0.02 m): `TOUCH_DEFAULTS.downRadius`,
|
|
15
|
+
* which `@pmndrs/pointer-events` compares every frame, so the release is
|
|
16
|
+
* the way out past the same distance.
|
|
17
|
+
* - **Grab.** The grip is a sphere of `grabRadius` (0.07 m,
|
|
18
|
+
* `createGrabPointer`'s default) and a target whose surface it reaches is
|
|
19
|
+
* the grab candidate. The squeeze (or a hand's pinch on a grab-only
|
|
20
|
+
* target) then grabs it.
|
|
21
|
+
* - **Priority.** Per source, the first of touch, grab, ray with a candidate
|
|
22
|
+
* is the active pointer (`PRIORITY_ORDER`), and once it is pressing or
|
|
23
|
+
* grabbing it stays active until the release (IWSDK's selection lock).
|
|
24
|
+
* - **Visuals.** The ray and its cursor show only while the ray is the active
|
|
25
|
+
* pointer or nothing is (`shouldHideRay`); a near pointer owning the hand
|
|
26
|
+
* hides them, and the shared cursor sits at the active pointer's hit point.
|
|
27
|
+
*
|
|
28
|
+
* One rule is deliberately stricter than the template, as the Pale Signal
|
|
29
|
+
* work order of 28 September 2026 required: a touch never presses from inside
|
|
30
|
+
* or from behind. A fingertip first seen already within the press band, a
|
|
31
|
+
* hand that pops into tracking inside the target, does not press until it has
|
|
32
|
+
* been seen outside the band and comes back in. IWSDK's touch pointer would
|
|
33
|
+
* press on that first frame; `touch-reference.test.ts` records the one frame
|
|
34
|
+
* on which the two differ, and that they agree everywhere else.
|
|
35
|
+
*/
|
|
36
|
+
/** IWSDK's order: the first with a candidate wins. `multi-pointer.js` `PRIORITY_ORDER`. */
|
|
37
|
+
export const POINTER_PRIORITY = Object.freeze(["touch", "grab", "ray"]);
|
|
38
|
+
/** IWSDK 1.0.0's constants: `TOUCH_HYSTERESIS`, `TOUCH_DEFAULTS.downRadius`, `createGrabPointer`'s radius. */
|
|
39
|
+
export const NEAR_POINTER_DEFAULTS = Object.freeze({
|
|
40
|
+
touchHoverEnter: 0.15,
|
|
41
|
+
touchHoverExit: 0.2,
|
|
42
|
+
touchDown: 0.02,
|
|
43
|
+
grabRadius: 0.07,
|
|
44
|
+
});
|
|
45
|
+
export function resolveNearPointerOptions(options = {}) {
|
|
46
|
+
const resolved = { ...NEAR_POINTER_DEFAULTS, ...options };
|
|
47
|
+
if (resolved.touchDown < 0 || resolved.touchHoverEnter < 0 || resolved.grabRadius < 0) {
|
|
48
|
+
throw new Error("[webxr-interactions] near pointer distances must be >= 0");
|
|
49
|
+
}
|
|
50
|
+
if (resolved.touchHoverExit < resolved.touchHoverEnter) {
|
|
51
|
+
throw new Error(`[webxr-interactions] touchHoverExit (${resolved.touchHoverExit}) must be >= touchHoverEnter (${resolved.touchHoverEnter})`);
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One source's touch pointer: hover with hysteresis, press at `touchDown`
|
|
57
|
+
* armed only from outside the band. Feed it the fingertip's surface distance
|
|
58
|
+
* to the nearest target each frame, or `null` when no target is in reach.
|
|
59
|
+
*/
|
|
60
|
+
export class TouchPointerState {
|
|
61
|
+
hovering = false;
|
|
62
|
+
pressed = false;
|
|
63
|
+
/** The fingertip has been seen outside the press band since it last lost contact. */
|
|
64
|
+
armed = false;
|
|
65
|
+
get isHovering() {
|
|
66
|
+
return this.hovering;
|
|
67
|
+
}
|
|
68
|
+
get isPressed() {
|
|
69
|
+
return this.pressed;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Advance one frame. `enter`/`exit` are the hover band for the target in
|
|
73
|
+
* question (an interactable may widen the default), `down` the press
|
|
74
|
+
* distance.
|
|
75
|
+
*/
|
|
76
|
+
update(distance, enter, exit, down) {
|
|
77
|
+
const wasPressed = this.pressed;
|
|
78
|
+
const threshold = this.hovering ? exit : enter;
|
|
79
|
+
if (distance === null || distance > threshold) {
|
|
80
|
+
this.hovering = false;
|
|
81
|
+
this.pressed = false;
|
|
82
|
+
this.armed = false;
|
|
83
|
+
return { hovering: false, pressed: false, pressStarted: false, pressEnded: wasPressed };
|
|
84
|
+
}
|
|
85
|
+
this.hovering = true;
|
|
86
|
+
if (distance > down) {
|
|
87
|
+
// Outside the press band: released if it was pressing, and armed for the next press.
|
|
88
|
+
this.armed = true;
|
|
89
|
+
this.pressed = false;
|
|
90
|
+
return { hovering: true, pressed: false, pressStarted: false, pressEnded: wasPressed };
|
|
91
|
+
}
|
|
92
|
+
// Within the press band. IWSDK presses here unconditionally; the core
|
|
93
|
+
// requires the fingertip to have arrived from outside (see the file comment).
|
|
94
|
+
if (!this.pressed && this.armed) {
|
|
95
|
+
this.pressed = true;
|
|
96
|
+
this.armed = false;
|
|
97
|
+
return { hovering: true, pressed: true, pressStarted: true, pressEnded: false };
|
|
98
|
+
}
|
|
99
|
+
return { hovering: true, pressed: this.pressed, pressStarted: false, pressEnded: false };
|
|
100
|
+
}
|
|
101
|
+
/** Drop everything without reporting (the source went away). */
|
|
102
|
+
reset() {
|
|
103
|
+
this.hovering = false;
|
|
104
|
+
this.pressed = false;
|
|
105
|
+
this.armed = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* IWSDK's `pickActiveByPriority` with its selection lock: while the current
|
|
110
|
+
* active pointer is selecting it stays; otherwise the first pointer in
|
|
111
|
+
* `POINTER_PRIORITY` with a candidate wins, or none.
|
|
112
|
+
*/
|
|
113
|
+
export function pickActivePointer(candidates, current, selecting) {
|
|
114
|
+
if (selecting && current !== null)
|
|
115
|
+
return current;
|
|
116
|
+
for (const kind of POINTER_PRIORITY) {
|
|
117
|
+
if (candidates[kind])
|
|
118
|
+
return kind;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** Build the visuals for one source from its active pointer and that pointer's candidate. */
|
|
123
|
+
export function pointerVisualsFor(sourceId, hasRay, active, candidate) {
|
|
124
|
+
const ray = hasRay && (active === null || active === "ray");
|
|
125
|
+
const cursor = active !== null && candidate !== null;
|
|
126
|
+
return { sourceId, activePointer: active, ray, cursor, cursorPoint: cursor ? candidate.point : null };
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=near-pointer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"near-pointer.js","sourceRoot":"","sources":["../src/near-pointer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAKH,2FAA2F;AAC3F,MAAM,CAAC,MAAM,gBAAgB,GAAiC,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;AAatG,8GAA8G;AAC9G,MAAM,CAAC,MAAM,qBAAqB,GAAiC,MAAM,CAAC,MAAM,CAAC;IAC/E,eAAe,EAAE,IAAI;IACrB,cAAc,EAAE,GAAG;IACnB,SAAS,EAAE,IAAI;IACf,UAAU,EAAE,IAAI;CACjB,CAAC,CAAC;AAEH,MAAM,UAAU,yBAAyB,CAAC,UAAuC,EAAE;IACjF,MAAM,QAAQ,GAAG,EAAE,GAAG,qBAAqB,EAAE,GAAG,OAAO,EAAE,CAAC;IAC1D,IAAI,QAAQ,CAAC,SAAS,GAAG,CAAC,IAAI,QAAQ,CAAC,eAAe,GAAG,CAAC,IAAI,QAAQ,CAAC,UAAU,GAAG,CAAC,EAAE,CAAC;QACtF,MAAM,IAAI,KAAK,CAAC,0DAA0D,CAAC,CAAC;IAC9E,CAAC;IACD,IAAI,QAAQ,CAAC,cAAc,GAAG,QAAQ,CAAC,eAAe,EAAE,CAAC;QACvD,MAAM,IAAI,KAAK,CACb,wCAAwC,QAAQ,CAAC,cAAc,iCAAiC,QAAQ,CAAC,eAAe,GAAG,CAC5H,CAAC;IACJ,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAcD;;;;GAIG;AACH,MAAM,OAAO,iBAAiB;IACpB,QAAQ,GAAG,KAAK,CAAC;IACjB,OAAO,GAAG,KAAK,CAAC;IACxB,qFAAqF;IAC7E,KAAK,GAAG,KAAK,CAAC;IAEtB,IAAI,UAAU;QACZ,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,OAAO,CAAC;IACtB,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,QAAuB,EAAE,KAAa,EAAE,IAAY,EAAE,IAAY;QACvE,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC;QAChC,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC;QAC/C,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,GAAG,SAAS,EAAE,CAAC;YAC9C,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;YACtB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;YACrB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;YACnB,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;QAC1F,CAAC;QACD,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,IAAI,QAAQ,GAAG,IAAI,EAAE,CAAC;YACpB,qFAAqF;YACrF,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;YAClB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;YACrB,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;QACzF,CAAC;QACD,sEAAsE;QACtE,8EAA8E;QAC9E,IAAI,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAChC,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;YACpB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;YACnB,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;QAClF,CAAC;QACD,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;IAC3F,CAAC;IAED,gEAAgE;IAChE,KAAK;QACH,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;QACtB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;QACrB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;CACF;AAWD;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,UAA2D,EAC3D,OAAiC,EACjC,SAAkB;IAElB,IAAI,SAAS,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,OAAO,CAAC;IAClD,KAAK,MAAM,IAAI,IAAI,gBAAgB,EAAE,CAAC;QACpC,IAAI,UAAU,CAAC,IAAgC,CAAC;YAAE,OAAO,IAAI,CAAC;IAChE,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AA8BD,6FAA6F;AAC7F,MAAM,UAAU,iBAAiB,CAC/B,QAAgB,EAChB,MAAe,EACf,MAAgC,EAChC,SAAkC;IAElC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,KAAK,CAAC,CAAC;IAC5D,MAAM,MAAM,GAAG,MAAM,KAAK,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC;IACrD,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC,SAAU,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AACzG,CAAC","sourcesContent":["/**\n * Near pointers - the touch press, the grip grab, which pointer owns a hand\n * and what that means for the ray and cursor. Pure logic, no engine.\n *\n * The template is IWSDK 1.0.0's pointer stack (`@iwsdk/xr-input`\n * `multi-pointer.js`, `touch-pointer.js`, `grab-pointer.js`, over\n * `@pmndrs/pointer-events`), restated here so every binding runs the same\n * rule from the facts it already reports (`indexTip`, `gripPose`, `ray`):\n *\n * - **Touch.** A fingertip (a controller's ray origin stands in for one)\n * hovers a target once its distance to the target's SURFACE is within\n * `touchHoverEnter` (0.15 m) and keeps hovering until it is beyond\n * `touchHoverExit` (0.2 m): `TOUCH_HYSTERESIS`. It presses while that\n * distance is at or under `touchDown` (0.02 m): `TOUCH_DEFAULTS.downRadius`,\n * which `@pmndrs/pointer-events` compares every frame, so the release is\n * the way out past the same distance.\n * - **Grab.** The grip is a sphere of `grabRadius` (0.07 m,\n * `createGrabPointer`'s default) and a target whose surface it reaches is\n * the grab candidate. The squeeze (or a hand's pinch on a grab-only\n * target) then grabs it.\n * - **Priority.** Per source, the first of touch, grab, ray with a candidate\n * is the active pointer (`PRIORITY_ORDER`), and once it is pressing or\n * grabbing it stays active until the release (IWSDK's selection lock).\n * - **Visuals.** The ray and its cursor show only while the ray is the active\n * pointer or nothing is (`shouldHideRay`); a near pointer owning the hand\n * hides them, and the shared cursor sits at the active pointer's hit point.\n *\n * One rule is deliberately stricter than the template, as the Pale Signal\n * work order of 28 September 2026 required: a touch never presses from inside\n * or from behind. A fingertip first seen already within the press band, a\n * hand that pops into tracking inside the target, does not press until it has\n * been seen outside the band and comes back in. IWSDK's touch pointer would\n * press on that first frame; `touch-reference.test.ts` records the one frame\n * on which the two differ, and that they agree everywhere else.\n */\n\n/** The pointer that owns a source this frame. `\"gaze\"` is the eye-gaze source's own pointer. */\nexport type ActivePointerKind = \"touch\" | \"grab\" | \"ray\" | \"gaze\";\n\n/** IWSDK's order: the first with a candidate wins. `multi-pointer.js` `PRIORITY_ORDER`. */\nexport const POINTER_PRIORITY: readonly ActivePointerKind[] = Object.freeze([\"touch\", \"grab\", \"ray\"]);\n\nexport interface NearPointerOptions {\n /** Surface distance, metres, within which a fingertip starts hovering a target. IWSDK `enterHoverDistance` 0.15. */\n touchHoverEnter: number;\n /** Surface distance, metres, beyond which a hovering fingertip stops hovering. IWSDK `exitHoverDistance` 0.2. Never less than `touchHoverEnter`. */\n touchHoverExit: number;\n /** Surface distance, metres, at or under which a hovering fingertip presses, and above which it releases. IWSDK `downRadius` 0.02. */\n touchDown: number;\n /** Radius, metres, of the grip sphere that finds a grab candidate. `createGrabPointer` default 0.07. */\n grabRadius: number;\n}\n\n/** IWSDK 1.0.0's constants: `TOUCH_HYSTERESIS`, `TOUCH_DEFAULTS.downRadius`, `createGrabPointer`'s radius. */\nexport const NEAR_POINTER_DEFAULTS: Readonly<NearPointerOptions> = Object.freeze({\n touchHoverEnter: 0.15,\n touchHoverExit: 0.2,\n touchDown: 0.02,\n grabRadius: 0.07,\n});\n\nexport function resolveNearPointerOptions(options: Partial<NearPointerOptions> = {}): NearPointerOptions {\n const resolved = { ...NEAR_POINTER_DEFAULTS, ...options };\n if (resolved.touchDown < 0 || resolved.touchHoverEnter < 0 || resolved.grabRadius < 0) {\n throw new Error(\"[webxr-interactions] near pointer distances must be >= 0\");\n }\n if (resolved.touchHoverExit < resolved.touchHoverEnter) {\n throw new Error(\n `[webxr-interactions] touchHoverExit (${resolved.touchHoverExit}) must be >= touchHoverEnter (${resolved.touchHoverEnter})`,\n );\n }\n return resolved;\n}\n\n/** What the touch machine reports after one frame. */\nexport interface TouchPointerUpdate {\n /** The fingertip is hovering a target this frame (the touch pointer has a candidate). */\n hovering: boolean;\n /** The touch is pressing this frame. */\n pressed: boolean;\n /** True on the exact frame a press started. */\n pressStarted: boolean;\n /** True on the exact frame a press ended. */\n pressEnded: boolean;\n}\n\n/**\n * One source's touch pointer: hover with hysteresis, press at `touchDown`\n * armed only from outside the band. Feed it the fingertip's surface distance\n * to the nearest target each frame, or `null` when no target is in reach.\n */\nexport class TouchPointerState {\n private hovering = false;\n private pressed = false;\n /** The fingertip has been seen outside the press band since it last lost contact. */\n private armed = false;\n\n get isHovering(): boolean {\n return this.hovering;\n }\n\n get isPressed(): boolean {\n return this.pressed;\n }\n\n /**\n * Advance one frame. `enter`/`exit` are the hover band for the target in\n * question (an interactable may widen the default), `down` the press\n * distance.\n */\n update(distance: number | null, enter: number, exit: number, down: number): TouchPointerUpdate {\n const wasPressed = this.pressed;\n const threshold = this.hovering ? exit : enter;\n if (distance === null || distance > threshold) {\n this.hovering = false;\n this.pressed = false;\n this.armed = false;\n return { hovering: false, pressed: false, pressStarted: false, pressEnded: wasPressed };\n }\n this.hovering = true;\n if (distance > down) {\n // Outside the press band: released if it was pressing, and armed for the next press.\n this.armed = true;\n this.pressed = false;\n return { hovering: true, pressed: false, pressStarted: false, pressEnded: wasPressed };\n }\n // Within the press band. IWSDK presses here unconditionally; the core\n // requires the fingertip to have arrived from outside (see the file comment).\n if (!this.pressed && this.armed) {\n this.pressed = true;\n this.armed = false;\n return { hovering: true, pressed: true, pressStarted: true, pressEnded: false };\n }\n return { hovering: true, pressed: this.pressed, pressStarted: false, pressEnded: false };\n }\n\n /** Drop everything without reporting (the source went away). */\n reset(): void {\n this.hovering = false;\n this.pressed = false;\n this.armed = false;\n }\n}\n\n/** A candidate one pointer found: the target and the point the cursor sits at. */\nexport interface PointerCandidate {\n targetId: string;\n /** World-space point: the ray's hit, or the closest point of a near target. */\n point: readonly [number, number, number];\n /** Metres: the ray parameter for a ray, the surface distance for a near pointer. */\n distance: number;\n}\n\n/**\n * IWSDK's `pickActiveByPriority` with its selection lock: while the current\n * active pointer is selecting it stays; otherwise the first pointer in\n * `POINTER_PRIORITY` with a candidate wins, or none.\n */\nexport function pickActivePointer(\n candidates: { touch: boolean; grab: boolean; ray: boolean },\n current: ActivePointerKind | null,\n selecting: boolean,\n): ActivePointerKind | null {\n if (selecting && current !== null) return current;\n for (const kind of POINTER_PRIORITY) {\n if (candidates[kind as \"touch\" | \"grab\" | \"ray\"]) return kind;\n }\n return null;\n}\n\n/**\n * What a binding shows for one source: IWSDK's `shouldHideRay` and the shared\n * cursor from `MultiPointer.update`. A host draws exactly this and decides\n * nothing.\n */\nexport interface PointerVisuals {\n /** The source these visuals belong to. */\n sourceId: string;\n /** The pointer owning the source, or null when none has a candidate. */\n activePointer: ActivePointerKind | null;\n /**\n * Draw this source's ray. True while the source has a ray and no near\n * pointer owns it: the ray is active, or nothing is. False while touch or\n * grab owns the hand, while eye gaze has taken the far ray, and for a\n * source with no ray at all.\n */\n ray: boolean;\n /**\n * Draw the cursor disc at `cursorPoint`. True exactly while the active\n * pointer has a candidate: at the ray's hit, or on the surface the\n * fingertip or grip reaches. Never drawn \"at every ray hit\" regardless of\n * ownership, which is what the September 2026 native contract said.\n */\n cursor: boolean;\n /** World-space cursor position while `cursor` is true, else null. */\n cursorPoint: readonly [number, number, number] | null;\n}\n\n/** Build the visuals for one source from its active pointer and that pointer's candidate. */\nexport function pointerVisualsFor(\n sourceId: string,\n hasRay: boolean,\n active: ActivePointerKind | null,\n candidate: PointerCandidate | null,\n): PointerVisuals {\n const ray = hasRay && (active === null || active === \"ray\");\n const cursor = active !== null && candidate !== null;\n return { sourceId, activePointer: active, ray, cursor, cursorPoint: cursor ? candidate!.point : null };\n}\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type PhysicsFacility } from "./physics.js";
|
|
2
|
+
/** The facility under test. Build a FRESH one per case. */
|
|
3
|
+
export interface PhysicsFacilityContractSubject {
|
|
4
|
+
facility: PhysicsFacility;
|
|
5
|
+
}
|
|
6
|
+
/** One check a {@link PhysicsFacility} implementation must pass. */
|
|
7
|
+
export interface PhysicsFacilityContractCase {
|
|
8
|
+
name: string;
|
|
9
|
+
run(subject: PhysicsFacilityContractSubject): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function physicsFacilityContractCases(): readonly PhysicsFacilityContractCase[];
|