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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ohzi-core",
3
- "version": "13.2.2",
3
+ "version": "14.0.0",
4
4
  "description": "OHZI Interactive Core Library",
5
5
  "type": "module",
6
6
  "source": "src/index.ts",
@@ -21,6 +21,8 @@
21
21
  "build": "rollup -c",
22
22
  "create-tag": "node tasks/app/create_tag.mjs",
23
23
  "fix-syntax": "npx eslint src --fix || echo 'Done.'",
24
+ "test": "vitest run",
25
+ "test-watch": "vitest",
24
26
  "generate-types": "npx -p typescript tsc",
25
27
  "upload": "yarn build && npm publish && yarn create-tag",
26
28
  "create-view": "node ./tasks/create_view/create_view.mjs",
@@ -46,12 +48,12 @@
46
48
  "registry": "https://registry.npmjs.org/"
47
49
  },
48
50
  "dependencies": {
49
- "three": "0.182.0"
51
+ "three": "0.183.0"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@rollup/plugin-typescript": "^12.3.0",
53
55
  "@types/node": "^25.0.3",
54
- "@types/three": "0.182.0",
56
+ "@types/three": "0.183.0",
55
57
  "@typescript-eslint/eslint-plugin": "^8.18.2",
56
58
  "@typescript-eslint/parser": "^8.18.2",
57
59
  "eslint": "^8.57.0",
@@ -62,6 +64,7 @@
62
64
  "rollup-plugin-terser": "^7.0.2",
63
65
  "tslib": "^2.8.1",
64
66
  "typescript": "^5.9.3",
65
- "typescript-eslint": "^8.18.2"
67
+ "typescript-eslint": "^8.18.2",
68
+ "vitest": "^4.1.11"
66
69
  }
67
70
  }
@@ -185,13 +185,15 @@ export class CameraController
185
185
  return this.normalized_zoom < 0.2;
186
186
  }
187
187
 
188
- set_rotation(tilt: number, orientation: number, azimuth = 0)
188
+ // Arguments are nullish-checked, not falsy-checked: a falsy check discards a
189
+ // value of 0, which made it impossible to return any angle to zero.
190
+ set_rotation(tilt?: number, orientation?: number, azimuth?: number)
189
191
  {
190
192
  this.old_orientation = this.current_orientation;
191
193
 
192
- this.current_tilt = tilt || this.current_tilt;
193
- this.current_orientation = orientation || this.current_orientation;
194
- this.current_azimuth = azimuth || this.current_azimuth;
194
+ this.current_tilt = tilt ?? this.current_tilt;
195
+ this.current_orientation = orientation ?? this.current_orientation;
196
+ this.current_azimuth = azimuth ?? this.current_azimuth;
195
197
 
196
198
  this.set_quaternion(this.build_rotation(this.current_tilt, this.current_orientation)); //, this.current_azimuth
197
199
  }
@@ -0,0 +1,363 @@
1
+ import type { InspectableObject } from './SceneInspector';
2
+
3
+ const PRECISION = 10000;
4
+
5
+ export interface OrbitalController
6
+ {
7
+ current_tilt: number;
8
+ current_orientation: number;
9
+ current_azimuth: number;
10
+ normalized_zoom: number;
11
+ min_zoom: number;
12
+ max_zoom: number;
13
+ reference_position: { x: number; y: number; z: number };
14
+ get_current_tilt(): number;
15
+ get_current_orientation(): number;
16
+ get_current_azimuth(): number;
17
+ set_normalized_zoom(zoom: number): void;
18
+ set_rotation(tilt?: number, orientation?: number, azimuth?: number): void;
19
+ focus_on_bounding_box(box: unknown, scale?: number): void;
20
+ }
21
+
22
+ export interface BridgeCamera
23
+ {
24
+ position: { x: number; y: number; z: number };
25
+ fov?: number;
26
+ near?: number;
27
+ far?: number;
28
+ clear_color?: { getHexString?: () => string };
29
+ clear_alpha?: number;
30
+ updateProjectionMatrix?: () => void;
31
+ }
32
+
33
+ export interface ControllerState
34
+ {
35
+ tilt: number;
36
+ orientation: number;
37
+ azimuth: number;
38
+ zoom: number;
39
+ min_zoom: number;
40
+ max_zoom: number;
41
+ target: number[];
42
+ }
43
+
44
+ export interface CameraState
45
+ {
46
+ position: number[];
47
+ fov: number | null;
48
+ near: number | null;
49
+ far: number | null;
50
+ clear_color: string | null;
51
+ clear_alpha: number | null;
52
+ controller: ControllerState | null;
53
+ changed?: string[];
54
+ }
55
+
56
+ // Changes arrive off the dev-bridge wire and are therefore untrusted.
57
+ export interface CameraChanges
58
+ {
59
+ tilt?: unknown;
60
+ orientation?: unknown;
61
+ azimuth?: unknown;
62
+ zoom?: unknown;
63
+ fov?: unknown;
64
+ target?: unknown;
65
+ position?: unknown;
66
+ }
67
+
68
+ export interface FrameRequest
69
+ {
70
+ uuid?: unknown;
71
+ name?: unknown;
72
+ scale?: unknown;
73
+ }
74
+
75
+ // Speaks the CameraController's own idiom. The controller is orbital, driven by
76
+ // tilt, orientation, azimuth and a normalised zoom in degrees, and it rewrites
77
+ // the camera transform every update. Assigning camera.position directly while a
78
+ // controller is active would simply be overwritten on the next frame.
79
+ class CameraBridge
80
+ {
81
+ get(camera: BridgeCamera, controller: OrbitalController | null): CameraState
82
+ {
83
+ return this.describe(this.require_camera(camera), controller);
84
+ }
85
+
86
+ set(camera: BridgeCamera, controller: OrbitalController | null, changes: CameraChanges): CameraState
87
+ {
88
+ const target_camera = this.require_camera(camera);
89
+ const changed: string[] = [];
90
+
91
+ if (controller !== null)
92
+ {
93
+ this.apply_orbit(controller, changes, changed);
94
+ }
95
+
96
+ this.apply_position(target_camera, controller, changes, changed);
97
+ this.apply_fov(target_camera, changes, changed);
98
+
99
+ const state = this.describe(target_camera, controller);
100
+ state.changed = changed;
101
+
102
+ return state;
103
+ }
104
+
105
+ frame(
106
+ camera: BridgeCamera,
107
+ controller: OrbitalController | null,
108
+ root: InspectableObject,
109
+ request: FrameRequest,
110
+ make_bounds: (object: InspectableObject) => unknown
111
+ ): CameraState
112
+ {
113
+ const target_camera = this.require_camera(camera);
114
+
115
+ if (controller === null)
116
+ {
117
+ throw this.error('no_controller', 'The active scene has no camera_controller, so framing is unavailable.');
118
+ }
119
+
120
+ const uuid = typeof request.uuid === 'string' ? request.uuid : null;
121
+ const name = typeof request.name === 'string' ? request.name : null;
122
+
123
+ if (uuid === null && name === null)
124
+ {
125
+ throw this.error('not_found', 'Pass a uuid or a name to identify the object to frame.');
126
+ }
127
+
128
+ const found = this.find(root, uuid, name);
129
+
130
+ if (found === null)
131
+ {
132
+ const label = uuid === null ? `name '${name === null ? '' : name}'` : `uuid '${uuid}'`;
133
+
134
+ throw this.error('not_found', `No object matching ${label} in the current scene.`);
135
+ }
136
+
137
+ const scale = typeof request.scale === 'number' && Number.isFinite(request.scale) ? request.scale : 1;
138
+
139
+ controller.focus_on_bounding_box(make_bounds(found), scale);
140
+
141
+ const state = this.describe(target_camera, controller);
142
+ state.changed = ['framed'];
143
+
144
+ return state;
145
+ }
146
+
147
+ // Angles go through set_rotation, which is nullish-checked, so an omitted
148
+ // value keeps the current angle and an explicit 0 is applied. It also records
149
+ // old_orientation, which assigning the fields directly would skip.
150
+ private apply_orbit(controller: OrbitalController, changes: CameraChanges, changed: string[]): void
151
+ {
152
+ const tilt = this.number(changes.tilt);
153
+ const orientation = this.number(changes.orientation);
154
+ const azimuth = this.number(changes.azimuth);
155
+
156
+ if (tilt !== null || orientation !== null || azimuth !== null)
157
+ {
158
+ if (tilt !== null)
159
+ {
160
+ changed.push('tilt');
161
+ }
162
+
163
+ if (orientation !== null)
164
+ {
165
+ changed.push('orientation');
166
+ }
167
+
168
+ if (azimuth !== null)
169
+ {
170
+ changed.push('azimuth');
171
+ }
172
+
173
+ controller.set_rotation(
174
+ tilt === null ? undefined : tilt,
175
+ orientation === null ? undefined : orientation,
176
+ azimuth === null ? undefined : azimuth
177
+ );
178
+ }
179
+
180
+ const zoom = this.number(changes.zoom);
181
+
182
+ if (zoom !== null)
183
+ {
184
+ controller.set_normalized_zoom(zoom);
185
+ changed.push('zoom');
186
+ }
187
+
188
+ const target = this.triple(changes.target);
189
+
190
+ if (target !== null)
191
+ {
192
+ controller.reference_position.x = target[0];
193
+ controller.reference_position.y = target[1];
194
+ controller.reference_position.z = target[2];
195
+ changed.push('target');
196
+ }
197
+ }
198
+
199
+ private apply_position(
200
+ camera: BridgeCamera,
201
+ controller: OrbitalController | null,
202
+ changes: CameraChanges,
203
+ changed: string[]
204
+ ): void
205
+ {
206
+ const position = this.triple(changes.position);
207
+
208
+ if (position === null)
209
+ {
210
+ return;
211
+ }
212
+
213
+ if (controller !== null)
214
+ {
215
+ throw this.error(
216
+ 'controller_owns_camera',
217
+ 'A CameraController is driving this camera and would overwrite a raw position on the next frame. Use tilt, orientation, azimuth, zoom and target instead.'
218
+ );
219
+ }
220
+
221
+ camera.position.x = position[0];
222
+ camera.position.y = position[1];
223
+ camera.position.z = position[2];
224
+ changed.push('position');
225
+ }
226
+
227
+ private apply_fov(camera: BridgeCamera, changes: CameraChanges, changed: string[]): void
228
+ {
229
+ const fov = this.number(changes.fov);
230
+
231
+ if (fov === null)
232
+ {
233
+ return;
234
+ }
235
+
236
+ camera.fov = fov;
237
+ changed.push('fov');
238
+
239
+ if (typeof camera.updateProjectionMatrix === 'function')
240
+ {
241
+ camera.updateProjectionMatrix();
242
+ }
243
+ }
244
+
245
+ private describe(camera: BridgeCamera, controller: OrbitalController | null): CameraState
246
+ {
247
+ return {
248
+ position: [this.round(camera.position.x), this.round(camera.position.y), this.round(camera.position.z)],
249
+ fov: typeof camera.fov === 'number' ? camera.fov : null,
250
+ near: typeof camera.near === 'number' ? camera.near : null,
251
+ far: typeof camera.far === 'number' ? camera.far : null,
252
+ clear_color: this.hex(camera.clear_color),
253
+ clear_alpha: typeof camera.clear_alpha === 'number' ? camera.clear_alpha : null,
254
+ controller: controller === null ? null : {
255
+ tilt: controller.get_current_tilt(),
256
+ orientation: controller.get_current_orientation(),
257
+ azimuth: controller.get_current_azimuth(),
258
+ zoom: controller.normalized_zoom,
259
+ min_zoom: controller.min_zoom,
260
+ max_zoom: controller.max_zoom,
261
+ target: [
262
+ this.round(controller.reference_position.x),
263
+ this.round(controller.reference_position.y),
264
+ this.round(controller.reference_position.z)
265
+ ]
266
+ }
267
+ };
268
+ }
269
+
270
+ private find(node: InspectableObject, uuid: string | null, name: string | null): InspectableObject | null
271
+ {
272
+ const matches = uuid !== null ? node.uuid === uuid : node.name === name;
273
+
274
+ if (matches)
275
+ {
276
+ return node;
277
+ }
278
+
279
+ const children = Array.isArray(node.children) ? node.children : [];
280
+
281
+ for (const child of children)
282
+ {
283
+ const found = this.find(child, uuid, name);
284
+
285
+ if (found !== null)
286
+ {
287
+ return found;
288
+ }
289
+ }
290
+
291
+ return null;
292
+ }
293
+
294
+ private require_camera(camera: BridgeCamera): BridgeCamera
295
+ {
296
+ if (typeof camera !== 'object' || camera === null || typeof camera.position !== 'object')
297
+ {
298
+ throw this.error('no_camera', 'No camera is active. CameraManager.current is not set yet.');
299
+ }
300
+
301
+ return camera;
302
+ }
303
+
304
+ private hex(color: { getHexString?: () => string } | undefined): string | null
305
+ {
306
+ if (typeof color !== 'object' || color === null || typeof color.getHexString !== 'function')
307
+ {
308
+ return null;
309
+ }
310
+
311
+ return `#${color.getHexString()}`;
312
+ }
313
+
314
+ private number(value: unknown): number | null
315
+ {
316
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
317
+ }
318
+
319
+ private triple(value: unknown): number[] | null
320
+ {
321
+ if (!Array.isArray(value))
322
+ {
323
+ return null;
324
+ }
325
+
326
+ const supplied = value as unknown[];
327
+ const out: number[] = [];
328
+
329
+ for (let i = 0; i < 3; i++)
330
+ {
331
+ const entry = this.number(supplied[i]);
332
+
333
+ if (entry === null)
334
+ {
335
+ return null;
336
+ }
337
+
338
+ out.push(entry);
339
+ }
340
+
341
+ return out;
342
+ }
343
+
344
+ private round(value: unknown): number
345
+ {
346
+ if (typeof value !== 'number' || !Number.isFinite(value))
347
+ {
348
+ return 0;
349
+ }
350
+
351
+ return Math.round(value * PRECISION) / PRECISION;
352
+ }
353
+
354
+ private error(code: string, message: string): Error
355
+ {
356
+ const error: Error & { code?: string } = new Error(message);
357
+ error.code = code;
358
+
359
+ return error;
360
+ }
361
+ }
362
+
363
+ export { CameraBridge };
@@ -0,0 +1,256 @@
1
+ const MAX_DIMENSION = 8192;
2
+ const BASE64_CHUNK_SIZE = 8192;
3
+
4
+ // A retina canvas reports physical pixels, so a fast capture is 4x the pixels
5
+ // the developer is actually looking at. Every one of those bytes is base64'd
6
+ // into the model's context, so cap the long edge unless asked otherwise.
7
+ const DEFAULT_MAX_FAST_SIZE = 1280;
8
+
9
+ export interface CaptureCanvas
10
+ {
11
+ width: number;
12
+ height: number;
13
+ toBlob(callback: (blob: Blob | null) => void, type?: string): void;
14
+ }
15
+
16
+ export interface ScratchContext
17
+ {
18
+ drawImage(source: unknown, x: number, y: number, width: number, height: number): void;
19
+ }
20
+
21
+ export interface ScratchCanvas
22
+ {
23
+ width: number;
24
+ height: number;
25
+ getContext(type: '2d'): ScratchContext | null;
26
+ toBlob(callback: (blob: Blob | null) => void, type?: string): void;
27
+ }
28
+
29
+ export interface CaptureGraphics
30
+ {
31
+ canvas: CaptureCanvas;
32
+ take_screenshot(callback: (blob: Blob | null) => void, width?: number, height?: number): void;
33
+ }
34
+
35
+ // Values arrive straight off the dev-bridge wire, so they are untrusted and
36
+ // every field is validated before use.
37
+ export interface CaptureOptions
38
+ {
39
+ mode?: unknown;
40
+ width?: unknown;
41
+ height?: unknown;
42
+ max_size?: unknown;
43
+ }
44
+
45
+ export interface CaptureResult
46
+ {
47
+ mode: 'fast' | 'hires';
48
+ mime_type: string;
49
+ width: number;
50
+ height: number;
51
+ bytes: number;
52
+ data: string;
53
+ scaled_from?: { width: number; height: number };
54
+ }
55
+
56
+ // Holds no OHZI singletons: the consumer injects the graphics surface and a
57
+ // camera predicate, so this stays testable without a WebGPU context.
58
+ class CaptureService
59
+ {
60
+ private graphics: CaptureGraphics;
61
+ private has_camera: () => boolean;
62
+ private create_canvas: () => ScratchCanvas;
63
+
64
+ constructor(
65
+ graphics: CaptureGraphics,
66
+ has_camera: () => boolean,
67
+ create_canvas: () => ScratchCanvas = () => document.createElement('canvas') as unknown as ScratchCanvas
68
+ )
69
+ {
70
+ this.graphics = graphics;
71
+ this.has_camera = has_camera;
72
+ this.create_canvas = create_canvas;
73
+ }
74
+
75
+ async capture(options: CaptureOptions): Promise<CaptureResult>
76
+ {
77
+ if (options.mode === 'hires')
78
+ {
79
+ return this.capture_hires(options);
80
+ }
81
+
82
+ return this.capture_fast(options);
83
+ }
84
+
85
+ // Reads the live canvas. Valid because the renderer is created with
86
+ // preserveDrawingBuffer: true, so the backbuffer still holds the last frame.
87
+ private async capture_fast(options: CaptureOptions): Promise<CaptureResult>
88
+ {
89
+ const canvas = this.graphics.canvas;
90
+ const target = this.scaled_size(canvas.width, canvas.height, options.max_size);
91
+
92
+ if (target !== null)
93
+ {
94
+ const scratch = this.draw_scaled(canvas, target.width, target.height);
95
+
96
+ if (scratch !== null)
97
+ {
98
+ const scaled_blob = await this.request_blob((callback) => scratch.toBlob(callback, 'image/png'));
99
+ const scaled = await this.build_result('fast', target.width, target.height, scaled_blob);
100
+
101
+ scaled.scaled_from = { width: canvas.width, height: canvas.height };
102
+
103
+ return scaled;
104
+ }
105
+ }
106
+
107
+ const blob = await this.request_blob((callback) => canvas.toBlob(callback, 'image/png'));
108
+
109
+ return this.build_result('fast', canvas.width, canvas.height, blob);
110
+ }
111
+
112
+ // Returns null when no downscale should happen: an explicit opt-out, or a
113
+ // canvas already within the cap. Never upscales.
114
+ private scaled_size(width: number, height: number, max_size: unknown): { width: number; height: number } | null
115
+ {
116
+ const cap = typeof max_size === 'number' ? max_size : DEFAULT_MAX_FAST_SIZE;
117
+
118
+ if (!Number.isFinite(cap) || cap <= 0)
119
+ {
120
+ return null;
121
+ }
122
+
123
+ const long_edge = Math.max(width, height);
124
+
125
+ if (long_edge <= cap)
126
+ {
127
+ return null;
128
+ }
129
+
130
+ const ratio = cap / long_edge;
131
+
132
+ return {
133
+ width: Math.max(1, Math.round(width * ratio)),
134
+ height: Math.max(1, Math.round(height * ratio))
135
+ };
136
+ }
137
+
138
+ // Returns null if no 2D context is available, in which case the caller falls
139
+ // back to a native capture. A large image beats no image.
140
+ private draw_scaled(source: unknown, width: number, height: number): ScratchCanvas | null
141
+ {
142
+ const scratch = this.create_canvas();
143
+
144
+ scratch.width = width;
145
+ scratch.height = height;
146
+
147
+ const context = scratch.getContext('2d');
148
+
149
+ if (context === null)
150
+ {
151
+ return null;
152
+ }
153
+
154
+ context.drawImage(source, 0, 0, width, height);
155
+
156
+ return scratch;
157
+ }
158
+
159
+ private async capture_hires(options: CaptureOptions): Promise<CaptureResult>
160
+ {
161
+ // Graphics.take_screenshot dereferences CameraManager.current directly and
162
+ // would throw a TypeError, so refuse with a code the caller can act on.
163
+ if (!this.has_camera())
164
+ {
165
+ throw this.error('no_camera', 'A hires capture needs an active camera; CameraManager.current is not set yet.');
166
+ }
167
+
168
+ const canvas = this.graphics.canvas;
169
+ const width = this.dimension(options.width, canvas.width);
170
+ const height = this.dimension(options.height, canvas.height);
171
+
172
+ const blob = await this.request_blob((callback) => this.graphics.take_screenshot(callback, width, height));
173
+
174
+ return this.build_result('hires', width, height, blob);
175
+ }
176
+
177
+ private dimension(value: unknown, fallback: number): number
178
+ {
179
+ if (typeof value === 'number')
180
+ {
181
+ return this.clamp(value);
182
+ }
183
+
184
+ return this.clamp(fallback);
185
+ }
186
+
187
+ // take_screenshot renders ceil(w/1024) * ceil(h/1024) tiles, so an unbounded
188
+ // size would stall the frame for a very long time.
189
+ private clamp(value: number): number
190
+ {
191
+ if (!Number.isFinite(value) || value < 1)
192
+ {
193
+ return 1;
194
+ }
195
+
196
+ return Math.min(Math.floor(value), MAX_DIMENSION);
197
+ }
198
+
199
+ private request_blob(request: (callback: (blob: Blob | null) => void) => void): Promise<Blob>
200
+ {
201
+ return new Promise((resolve, reject) =>
202
+ {
203
+ request((blob) =>
204
+ {
205
+ if (blob === null)
206
+ {
207
+ reject(this.error('capture_failed', 'The canvas produced no image data.'));
208
+ return;
209
+ }
210
+
211
+ resolve(blob);
212
+ });
213
+ });
214
+ }
215
+
216
+ private async build_result(mode: 'fast' | 'hires', width: number, height: number, blob: Blob): Promise<CaptureResult>
217
+ {
218
+ const buffer = await blob.arrayBuffer();
219
+ const bytes = new Uint8Array(buffer);
220
+
221
+ return {
222
+ mode,
223
+ // Graphics asks toBlob for the malformed type 'image/png;base64;', which
224
+ // browsers fall back to PNG for. Report what the bytes actually are.
225
+ mime_type: 'image/png',
226
+ width,
227
+ height,
228
+ bytes: bytes.length,
229
+ data: this.to_base64(bytes)
230
+ };
231
+ }
232
+
233
+ private to_base64(bytes: Uint8Array): string
234
+ {
235
+ let binary = '';
236
+
237
+ // Chunked: spreading a full-resolution capture into String.fromCharCode
238
+ // overflows the call stack.
239
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE)
240
+ {
241
+ binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE));
242
+ }
243
+
244
+ return btoa(binary);
245
+ }
246
+
247
+ private error(code: string, message: string): Error
248
+ {
249
+ const error: Error & { code?: string } = new Error(message);
250
+ error.code = code;
251
+
252
+ return error;
253
+ }
254
+ }
255
+
256
+ export { CaptureService };