ohzi-core 13.2.2 → 14.0.0
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/build/index.mjs +1 -1
- package/build/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/camera_controller/CameraController.ts +6 -4
- package/src/dev_bridge/CameraBridge.ts +363 -0
- package/src/dev_bridge/CaptureService.ts +256 -0
- package/src/dev_bridge/CommandDispatcher.ts +114 -0
- package/src/dev_bridge/ConsoleBuffer.ts +286 -0
- package/src/dev_bridge/DevBridge.ts +195 -0
- package/src/dev_bridge/InputSynthesizer.ts +287 -0
- package/src/dev_bridge/PerformanceProbe.ts +118 -0
- package/src/dev_bridge/RenderModeRegistry.ts +66 -0
- package/src/dev_bridge/SceneEditor.ts +288 -0
- package/src/dev_bridge/SceneInspector.ts +388 -0
- package/src/dev_bridge/ViewNavigator.ts +101 -0
- package/src/dev_bridge/protocol.ts +31 -0
- package/src/index.ts +15 -5
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
const DEFAULT_HOLD_MS = 60;
|
|
2
|
+
const DEFAULT_DRAG_STEPS = 8;
|
|
3
|
+
const DEFAULT_DRAG_DURATION_MS = 240;
|
|
4
|
+
const MAX_DRAG_STEPS = 120;
|
|
5
|
+
// Bounded so a caller cannot request a gesture that outlives the command timeout.
|
|
6
|
+
const MAX_DURATION_MS = 10000;
|
|
7
|
+
const MAX_HOLD_MS = 10000;
|
|
8
|
+
|
|
9
|
+
const BUTTONS: Record<string, number> = { left: 0, middle: 1, right: 2 };
|
|
10
|
+
|
|
11
|
+
export type DispatchEvent = (type: string, init: Record<string, unknown>) => void;
|
|
12
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
13
|
+
export type ToClient = (x: number, y: number, space: string) => { x: number; y: number };
|
|
14
|
+
|
|
15
|
+
export interface PointerRequest
|
|
16
|
+
{
|
|
17
|
+
action?: unknown;
|
|
18
|
+
x?: unknown;
|
|
19
|
+
y?: unknown;
|
|
20
|
+
button?: unknown;
|
|
21
|
+
space?: unknown;
|
|
22
|
+
hold_ms?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DragRequest
|
|
26
|
+
{
|
|
27
|
+
from?: unknown;
|
|
28
|
+
to?: unknown;
|
|
29
|
+
steps?: unknown;
|
|
30
|
+
duration_ms?: unknown;
|
|
31
|
+
button?: unknown;
|
|
32
|
+
space?: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ScrollRequest
|
|
36
|
+
{
|
|
37
|
+
delta?: unknown;
|
|
38
|
+
x?: unknown;
|
|
39
|
+
y?: unknown;
|
|
40
|
+
space?: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface KeyRequest
|
|
44
|
+
{
|
|
45
|
+
code?: unknown;
|
|
46
|
+
action?: unknown;
|
|
47
|
+
hold_ms?: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InputResult
|
|
51
|
+
{
|
|
52
|
+
dispatched: string[];
|
|
53
|
+
at?: number[];
|
|
54
|
+
from?: number[];
|
|
55
|
+
to?: number[];
|
|
56
|
+
code?: string;
|
|
57
|
+
delta?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Drives real DOM events at the element PIT already listens on, rather than
|
|
61
|
+
// writing into PIT's state. That way the whole genuine path runs: the region
|
|
62
|
+
// maths, Pointer bookkeeping, and any Input subclass deriving clicked or
|
|
63
|
+
// swiped from the raw flags. A parallel fake input module would drift from it.
|
|
64
|
+
//
|
|
65
|
+
// Timing matters. PIT clears pressed and released at the end of every frame, so
|
|
66
|
+
// a press and its release must be separated by real time for the application's
|
|
67
|
+
// update to observe them. Hence the sleeps rather than back-to-back dispatch.
|
|
68
|
+
class InputSynthesizer
|
|
69
|
+
{
|
|
70
|
+
private dispatch: DispatchEvent;
|
|
71
|
+
private sleep: Sleep;
|
|
72
|
+
private to_client: ToClient;
|
|
73
|
+
|
|
74
|
+
constructor(dispatch: DispatchEvent, sleep: Sleep, to_client: ToClient)
|
|
75
|
+
{
|
|
76
|
+
this.dispatch = dispatch;
|
|
77
|
+
this.sleep = sleep;
|
|
78
|
+
this.to_client = to_client;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async pointer(request: PointerRequest): Promise<InputResult>
|
|
82
|
+
{
|
|
83
|
+
const action = this.text(request.action, 'click');
|
|
84
|
+
const space = this.space(request.space);
|
|
85
|
+
const point = this.point(request.x, request.y, space);
|
|
86
|
+
const button = this.button(request.button);
|
|
87
|
+
const hold = Math.min(MAX_HOLD_MS, this.positive(request.hold_ms, DEFAULT_HOLD_MS));
|
|
88
|
+
const dispatched: string[] = [];
|
|
89
|
+
|
|
90
|
+
if (action === 'move' || action === 'down' || action === 'click')
|
|
91
|
+
{
|
|
92
|
+
this.send('mousemove', { clientX: point.x, clientY: point.y });
|
|
93
|
+
dispatched.push('mousemove');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (action === 'down' || action === 'click')
|
|
97
|
+
{
|
|
98
|
+
this.send('mousedown', { clientX: point.x, clientY: point.y, button });
|
|
99
|
+
dispatched.push('mousedown');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (action === 'click')
|
|
103
|
+
{
|
|
104
|
+
await this.sleep(hold);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (action === 'up' || action === 'click')
|
|
108
|
+
{
|
|
109
|
+
this.send('mouseup', { clientX: point.x, clientY: point.y, button });
|
|
110
|
+
dispatched.push('mouseup');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (dispatched.length === 0)
|
|
114
|
+
{
|
|
115
|
+
throw this.error('bad_request', `Unknown pointer action '${action}'. Use click, down, up or move.`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { dispatched, at: [point.x, point.y] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async drag(request: DragRequest): Promise<InputResult>
|
|
122
|
+
{
|
|
123
|
+
const space = this.space(request.space);
|
|
124
|
+
const from = this.pair(request.from, space, 'from');
|
|
125
|
+
const to = this.pair(request.to, space, 'to');
|
|
126
|
+
const steps = Math.min(MAX_DRAG_STEPS, Math.max(1, Math.floor(this.positive(request.steps, DEFAULT_DRAG_STEPS))));
|
|
127
|
+
const duration = Math.min(MAX_DURATION_MS, this.positive(request.duration_ms, DEFAULT_DRAG_DURATION_MS));
|
|
128
|
+
const button = this.button(request.button);
|
|
129
|
+
const dispatched: string[] = [];
|
|
130
|
+
|
|
131
|
+
this.send('mousemove', { clientX: from.x, clientY: from.y });
|
|
132
|
+
dispatched.push('mousemove');
|
|
133
|
+
|
|
134
|
+
this.send('mousedown', { clientX: from.x, clientY: from.y, button });
|
|
135
|
+
dispatched.push('mousedown');
|
|
136
|
+
|
|
137
|
+
for (let step = 1; step <= steps; step++)
|
|
138
|
+
{
|
|
139
|
+
const t = step / steps;
|
|
140
|
+
|
|
141
|
+
await this.sleep(duration / steps);
|
|
142
|
+
|
|
143
|
+
this.send('mousemove', {
|
|
144
|
+
clientX: from.x + (to.x - from.x) * t,
|
|
145
|
+
clientY: from.y + (to.y - from.y) * t
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
dispatched.push('mousemove');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.send('mouseup', { clientX: to.x, clientY: to.y, button });
|
|
152
|
+
dispatched.push('mouseup');
|
|
153
|
+
|
|
154
|
+
return { dispatched, from: [from.x, from.y], to: [to.x, to.y] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Not async: a wheel event is a single dispatch with nothing to wait on.
|
|
158
|
+
// The Promise return keeps the four methods interchangeable for the caller.
|
|
159
|
+
scroll(request: ScrollRequest): Promise<InputResult>
|
|
160
|
+
{
|
|
161
|
+
const delta = this.number(request.delta);
|
|
162
|
+
|
|
163
|
+
if (delta === null)
|
|
164
|
+
{
|
|
165
|
+
throw this.error('bad_request', 'scroll requires a numeric delta.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const space = this.space(request.space);
|
|
169
|
+
const point = this.point(request.x === undefined ? 0 : request.x, request.y === undefined ? 0 : request.y, space);
|
|
170
|
+
|
|
171
|
+
this.send('wheel', { clientX: point.x, clientY: point.y, deltaY: delta });
|
|
172
|
+
|
|
173
|
+
return Promise.resolve({ dispatched: ['wheel'], at: [point.x, point.y], delta });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async key(request: KeyRequest): Promise<InputResult>
|
|
177
|
+
{
|
|
178
|
+
const code = typeof request.code === 'string' && request.code.length > 0 ? request.code : null;
|
|
179
|
+
|
|
180
|
+
if (code === null)
|
|
181
|
+
{
|
|
182
|
+
throw this.error('bad_request', "key requires a code, for example 'Space' or 'KeyW'.");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const action = this.text(request.action, 'press');
|
|
186
|
+
const hold = Math.min(MAX_HOLD_MS, this.positive(request.hold_ms, DEFAULT_HOLD_MS));
|
|
187
|
+
const dispatched: string[] = [];
|
|
188
|
+
|
|
189
|
+
if (action === 'down' || action === 'press')
|
|
190
|
+
{
|
|
191
|
+
this.send('keydown', { code, key: code });
|
|
192
|
+
dispatched.push('keydown');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (action === 'press')
|
|
196
|
+
{
|
|
197
|
+
await this.sleep(hold);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (action === 'up' || action === 'press')
|
|
201
|
+
{
|
|
202
|
+
this.send('keyup', { code, key: code });
|
|
203
|
+
dispatched.push('keyup');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (dispatched.length === 0)
|
|
207
|
+
{
|
|
208
|
+
throw this.error('bad_request', `Unknown key action '${action}'. Use press, down or up.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { dispatched, code };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private send(type: string, init: Record<string, unknown>): void
|
|
215
|
+
{
|
|
216
|
+
this.dispatch(type, { ...init, bubbles: true, cancelable: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private point(x: unknown, y: unknown, space: string): { x: number; y: number }
|
|
220
|
+
{
|
|
221
|
+
const px = this.number(x);
|
|
222
|
+
const py = this.number(y);
|
|
223
|
+
|
|
224
|
+
if (px === null || py === null)
|
|
225
|
+
{
|
|
226
|
+
throw this.error('bad_request', 'x and y must be numbers.');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this.to_client(px, py, space);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private pair(value: unknown, space: string, label: string): { x: number; y: number }
|
|
233
|
+
{
|
|
234
|
+
if (!Array.isArray(value) || value.length < 2)
|
|
235
|
+
{
|
|
236
|
+
throw this.error('bad_request', `${label} must be [x, y].`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const supplied = value as unknown[];
|
|
240
|
+
|
|
241
|
+
return this.point(supplied[0], supplied[1], space);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private space(value: unknown): string
|
|
245
|
+
{
|
|
246
|
+
return value === 'ndc' ? 'ndc' : 'pixels';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private button(value: unknown): number
|
|
250
|
+
{
|
|
251
|
+
if (typeof value !== 'string')
|
|
252
|
+
{
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const mapped = BUTTONS[value.toLowerCase()];
|
|
257
|
+
|
|
258
|
+
return mapped === undefined ? 0 : mapped;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private text(value: unknown, fallback: string): string
|
|
262
|
+
{
|
|
263
|
+
return typeof value === 'string' && value.length > 0 ? value.toLowerCase() : fallback;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private number(value: unknown): number | null
|
|
267
|
+
{
|
|
268
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private positive(value: unknown, fallback: number): number
|
|
272
|
+
{
|
|
273
|
+
const parsed = this.number(value);
|
|
274
|
+
|
|
275
|
+
return parsed === null || parsed <= 0 ? fallback : parsed;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private error(code: string, message: string): Error
|
|
279
|
+
{
|
|
280
|
+
const error: Error & { code?: string } = new Error(message);
|
|
281
|
+
error.code = code;
|
|
282
|
+
|
|
283
|
+
return error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export { InputSynthesizer };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export interface TimeLike
|
|
2
|
+
{
|
|
3
|
+
delta_time: number;
|
|
4
|
+
smooth_delta_time: number;
|
|
5
|
+
elapsed_time: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScreenLike
|
|
9
|
+
{
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
render_width: number;
|
|
13
|
+
render_height: number;
|
|
14
|
+
dpr: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PerformanceReport
|
|
18
|
+
{
|
|
19
|
+
fps: number;
|
|
20
|
+
smooth_fps: number;
|
|
21
|
+
frame_ms: number;
|
|
22
|
+
elapsed_s: number;
|
|
23
|
+
canvas: ScreenLike;
|
|
24
|
+
draw_calls?: number;
|
|
25
|
+
triangles?: number;
|
|
26
|
+
geometries?: number;
|
|
27
|
+
textures?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Reads frame timing and, when the renderer exposes them, its counters.
|
|
31
|
+
// Counters are omitted rather than zeroed when unavailable, because a reported
|
|
32
|
+
// zero draw calls is a claim, while an absent field is an admission.
|
|
33
|
+
class PerformanceProbe
|
|
34
|
+
{
|
|
35
|
+
read(time: TimeLike, screen: ScreenLike, info: unknown): PerformanceReport
|
|
36
|
+
{
|
|
37
|
+
const report: PerformanceReport = {
|
|
38
|
+
fps: this.rate(time.delta_time),
|
|
39
|
+
smooth_fps: this.rate(time.smooth_delta_time),
|
|
40
|
+
frame_ms: this.round(this.seconds(time.delta_time) * 1000, 100),
|
|
41
|
+
elapsed_s: this.round(this.seconds(time.elapsed_time), 10),
|
|
42
|
+
canvas: {
|
|
43
|
+
width: screen.width,
|
|
44
|
+
height: screen.height,
|
|
45
|
+
render_width: screen.render_width,
|
|
46
|
+
render_height: screen.render_height,
|
|
47
|
+
dpr: screen.dpr
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const render = this.section(info, 'render');
|
|
52
|
+
const memory = this.section(info, 'memory');
|
|
53
|
+
|
|
54
|
+
this.assign(report, 'draw_calls', render, 'drawCalls');
|
|
55
|
+
this.assign(report, 'triangles', render, 'triangles');
|
|
56
|
+
this.assign(report, 'geometries', memory, 'geometries');
|
|
57
|
+
this.assign(report, 'textures', memory, 'textures');
|
|
58
|
+
|
|
59
|
+
return report;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private section(info: unknown, key: string): Record<string, unknown> | null
|
|
63
|
+
{
|
|
64
|
+
if (typeof info !== 'object' || info === null)
|
|
65
|
+
{
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const section = (info as Record<string, unknown>)[key];
|
|
70
|
+
|
|
71
|
+
if (typeof section !== 'object' || section === null)
|
|
72
|
+
{
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return section as Record<string, unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private assign(report: PerformanceReport, field: keyof PerformanceReport, source: Record<string, unknown> | null, key: string): void
|
|
80
|
+
{
|
|
81
|
+
if (source === null)
|
|
82
|
+
{
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const value = source[key];
|
|
87
|
+
|
|
88
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
89
|
+
{
|
|
90
|
+
(report[field] as unknown) = value;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A zero delta on the first frame would otherwise report Infinity fps.
|
|
95
|
+
private rate(delta: unknown): number
|
|
96
|
+
{
|
|
97
|
+
const seconds = this.seconds(delta);
|
|
98
|
+
|
|
99
|
+
if (seconds <= 0)
|
|
100
|
+
{
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return this.round(1 / seconds, 10);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private seconds(value: unknown): number
|
|
108
|
+
{
|
|
109
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private round(value: number, factor: number): number
|
|
113
|
+
{
|
|
114
|
+
return Math.round(value * factor) / factor;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { PerformanceProbe };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type RenderModeFactory = (options: Record<string, unknown>) => unknown;
|
|
2
|
+
|
|
3
|
+
export interface RenderModeDescriptor
|
|
4
|
+
{
|
|
5
|
+
name: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
options?: string[];
|
|
8
|
+
factory: RenderModeFactory;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RenderModeSummary
|
|
12
|
+
{
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
options: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Render modes cannot be constructed from a string, and they do not share a
|
|
19
|
+
// constructor signature: UnrealBloomRender takes three required booleans while
|
|
20
|
+
// NormalRender takes none. The consumer therefore registers a factory per mode
|
|
21
|
+
// carrying that mode's sensible defaults.
|
|
22
|
+
class RenderModeRegistry
|
|
23
|
+
{
|
|
24
|
+
private descriptors: RenderModeDescriptor[];
|
|
25
|
+
|
|
26
|
+
constructor(descriptors: RenderModeDescriptor[])
|
|
27
|
+
{
|
|
28
|
+
this.descriptors = descriptors;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
list(): RenderModeSummary[]
|
|
32
|
+
{
|
|
33
|
+
return this.descriptors.map((descriptor) => ({
|
|
34
|
+
name: descriptor.name,
|
|
35
|
+
description: descriptor.description === undefined ? '' : descriptor.description,
|
|
36
|
+
options: descriptor.options === undefined ? [] : descriptor.options
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
create(name: unknown, options: Record<string, unknown>): unknown
|
|
41
|
+
{
|
|
42
|
+
const wanted = typeof name === 'string' ? name.toLowerCase() : null;
|
|
43
|
+
const descriptor = wanted === null
|
|
44
|
+
? undefined
|
|
45
|
+
: this.descriptors.find((entry) => entry.name.toLowerCase() === wanted);
|
|
46
|
+
|
|
47
|
+
if (descriptor === undefined)
|
|
48
|
+
{
|
|
49
|
+
const available = this.descriptors.map((entry) => entry.name).join(', ');
|
|
50
|
+
|
|
51
|
+
throw this.error('unknown_render_mode', `Unknown render mode. Available modes: ${available}.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return descriptor.factory(options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private error(code: string, message: string): Error
|
|
58
|
+
{
|
|
59
|
+
const error: Error & { code?: string } = new Error(message);
|
|
60
|
+
error.code = code;
|
|
61
|
+
|
|
62
|
+
return error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { RenderModeRegistry };
|