okgeometry-api 0.5.0 → 0.5.2

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.
@@ -0,0 +1,481 @@
1
+ import {
2
+ MeshBooleanExecutionError,
3
+ type MeshBooleanAsyncOptions,
4
+ type MeshBooleanErrorPayload,
5
+ type MeshBooleanOperation,
6
+ type MeshBooleanOptions,
7
+ type MeshBooleanProgressEvent,
8
+ type MeshBooleanWorkerRequest,
9
+ type MeshBooleanWorkerResponse,
10
+ } from "./mesh-boolean.protocol.js";
11
+
12
+ interface PoolAcquireOptions {
13
+ size?: number;
14
+ workerFactory?: () => Worker;
15
+ }
16
+
17
+ interface BooleanJob {
18
+ id: number;
19
+ operation: MeshBooleanOperation;
20
+ options: MeshBooleanOptions;
21
+ onProgress?: (event: MeshBooleanProgressEvent) => void;
22
+ signal?: AbortSignal;
23
+ timeoutMs?: number;
24
+ bufferA: ArrayBuffer;
25
+ bufferB: ArrayBuffer;
26
+ cancelView?: Int32Array;
27
+ timeoutToken?: number;
28
+ abortHandler?: () => void;
29
+ resolve: (value: Float64Array) => void;
30
+ reject: (reason: Error) => void;
31
+ }
32
+
33
+ interface WorkerSlot {
34
+ id: number;
35
+ worker: Worker;
36
+ currentJobId: number | null;
37
+ onMessage: (event: MessageEvent<MeshBooleanWorkerResponse>) => void;
38
+ onError: (event: ErrorEvent) => void;
39
+ }
40
+
41
+ function defaultPoolSize(): number {
42
+ if (typeof navigator === "undefined" || !Number.isFinite(navigator.hardwareConcurrency)) {
43
+ return 2;
44
+ }
45
+ const cores = Math.max(1, Math.floor(navigator.hardwareConcurrency));
46
+ return Math.min(4, Math.max(1, Math.floor(cores / 2)));
47
+ }
48
+
49
+ function normalizePoolSize(size: number | undefined): number {
50
+ if (!Number.isFinite(size) || size === undefined) return defaultPoolSize();
51
+ return Math.max(1, Math.floor(size));
52
+ }
53
+
54
+ function createWorker(workerFactory?: () => Worker): Worker {
55
+ if (workerFactory) return workerFactory();
56
+ if (typeof Worker === "undefined") {
57
+ throw new MeshBooleanExecutionError({
58
+ code: "worker_unavailable",
59
+ message: "Web Worker is not available in this environment.",
60
+ });
61
+ }
62
+ return new Worker(new URL("./mesh-boolean.worker.js", import.meta.url), { type: "module" });
63
+ }
64
+
65
+ function rejectPayload(code: MeshBooleanErrorPayload["code"], message: string, details?: string): MeshBooleanErrorPayload {
66
+ return { code, message, details };
67
+ }
68
+
69
+ function createCancellationView(): Int32Array | undefined {
70
+ if (typeof SharedArrayBuffer === "undefined") return undefined;
71
+ try {
72
+ return new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ function requestCancellation(job: BooleanJob): void {
79
+ if (!job.cancelView) return;
80
+ try {
81
+ Atomics.store(job.cancelView, 0, 1);
82
+ } catch {
83
+ job.cancelView[0] = 1;
84
+ }
85
+ }
86
+
87
+ export class MeshBooleanWorkerPool {
88
+ readonly size: number;
89
+ private readonly workerFactory?: () => Worker;
90
+ private disposed = false;
91
+ private nextJobId = 1;
92
+ private slots: WorkerSlot[] = [];
93
+ private readonly queue: BooleanJob[] = [];
94
+ private readonly running = new Map<number, BooleanJob>();
95
+
96
+ constructor(options?: PoolAcquireOptions) {
97
+ this.workerFactory = options?.workerFactory;
98
+ this.size = normalizePoolSize(options?.size);
99
+ }
100
+
101
+ get isDisposed(): boolean {
102
+ return this.disposed;
103
+ }
104
+
105
+ run(
106
+ operation: MeshBooleanOperation,
107
+ bufferA: Float64Array,
108
+ bufferB: Float64Array,
109
+ options?: MeshBooleanAsyncOptions,
110
+ ): Promise<Float64Array> {
111
+ if (this.disposed) {
112
+ return Promise.reject(
113
+ new MeshBooleanExecutionError(
114
+ rejectPayload("worker_unavailable", "Boolean worker pool has been disposed."),
115
+ ),
116
+ );
117
+ }
118
+
119
+ this.ensureWorkers();
120
+ const jobId = this.nextJobId++;
121
+
122
+ return new Promise<Float64Array>((resolve, reject) => {
123
+ const job: BooleanJob = {
124
+ id: jobId,
125
+ operation,
126
+ options: {
127
+ allowUnsafe: options?.allowUnsafe ?? true,
128
+ limits: options?.limits,
129
+ backend: options?.backend,
130
+ },
131
+ onProgress: options?.onProgress,
132
+ signal: options?.signal,
133
+ timeoutMs: options?.timeoutMs,
134
+ bufferA: new Float64Array(bufferA).buffer,
135
+ bufferB: new Float64Array(bufferB).buffer,
136
+ cancelView: createCancellationView(),
137
+ resolve,
138
+ reject,
139
+ };
140
+
141
+ if (job.signal?.aborted) {
142
+ this.rejectJob(job, rejectPayload("worker_aborted", `Boolean ${operation} aborted.`));
143
+ return;
144
+ }
145
+
146
+ if (job.signal) {
147
+ job.abortHandler = () => {
148
+ this.cancelJob(job.id, "worker_aborted", `Boolean ${job.operation} aborted.`);
149
+ };
150
+ job.signal.addEventListener("abort", job.abortHandler, { once: true });
151
+ }
152
+
153
+ this.queue.push(job);
154
+ this.emitProgress(job, "queued");
155
+ this.dispatch();
156
+ });
157
+ }
158
+
159
+ dispose(): void {
160
+ if (this.disposed) return;
161
+ this.disposed = true;
162
+
163
+ while (this.queue.length > 0) {
164
+ const job = this.queue.shift()!;
165
+ this.rejectJob(job, rejectPayload("worker_aborted", "Boolean worker pool was disposed."));
166
+ }
167
+
168
+ for (const [jobId, job] of this.running) {
169
+ this.running.delete(jobId);
170
+ this.cleanupJob(job);
171
+ job.reject(new MeshBooleanExecutionError(rejectPayload("worker_aborted", "Boolean worker pool was disposed.")));
172
+ }
173
+
174
+ for (const slot of this.slots) {
175
+ slot.worker.removeEventListener("message", slot.onMessage as EventListener);
176
+ slot.worker.removeEventListener("error", slot.onError as EventListener);
177
+ slot.worker.terminate();
178
+ }
179
+ this.slots = [];
180
+ }
181
+
182
+ private ensureWorkers(): void {
183
+ while (this.slots.length < this.size) {
184
+ this.slots.push(this.spawnWorker(this.slots.length));
185
+ }
186
+ }
187
+
188
+ private spawnWorker(workerId: number): WorkerSlot {
189
+ const worker = createWorker(this.workerFactory);
190
+ const slot: WorkerSlot = {
191
+ id: workerId,
192
+ worker,
193
+ currentJobId: null,
194
+ onMessage: () => {},
195
+ onError: () => {},
196
+ };
197
+
198
+ slot.onMessage = (event: MessageEvent<MeshBooleanWorkerResponse>) => {
199
+ this.onWorkerMessage(slot, event.data);
200
+ };
201
+ slot.onError = (event: ErrorEvent) => {
202
+ const reason = event.message ? `: ${event.message}` : "";
203
+ this.failWorker(slot, rejectPayload("worker_crashed", `Boolean worker crashed${reason}`));
204
+ };
205
+
206
+ worker.addEventListener("message", slot.onMessage as EventListener);
207
+ worker.addEventListener("error", slot.onError as EventListener);
208
+ return slot;
209
+ }
210
+
211
+ private replaceWorker(slot: WorkerSlot): void {
212
+ slot.worker.removeEventListener("message", slot.onMessage as EventListener);
213
+ slot.worker.removeEventListener("error", slot.onError as EventListener);
214
+ slot.worker.terminate();
215
+ const replacement = this.spawnWorker(slot.id);
216
+ const index = this.slots.findIndex(s => s.id === slot.id);
217
+ if (index >= 0) {
218
+ this.slots[index] = replacement;
219
+ }
220
+ }
221
+
222
+ private dispatch(): void {
223
+ if (this.disposed) return;
224
+
225
+ for (;;) {
226
+ const slot = this.slots.find(s => s.currentJobId === null);
227
+ if (!slot) return;
228
+ const job = this.queue.shift();
229
+ if (!job) return;
230
+
231
+ if (job.signal?.aborted) {
232
+ this.rejectJob(job, rejectPayload("worker_aborted", `Boolean ${job.operation} aborted.`));
233
+ continue;
234
+ }
235
+
236
+ slot.currentJobId = job.id;
237
+ this.running.set(job.id, job);
238
+ this.emitProgress(job, "running", slot.id);
239
+
240
+ if (job.timeoutMs !== undefined && Number.isFinite(job.timeoutMs) && job.timeoutMs > 0) {
241
+ job.timeoutToken = setTimeout(() => {
242
+ this.cancelJob(job.id, "worker_timeout", `Boolean ${job.operation} timed out after ${job.timeoutMs}ms.`);
243
+ }, job.timeoutMs) as unknown as number;
244
+ }
245
+
246
+ const request: MeshBooleanWorkerRequest = {
247
+ kind: "mesh-boolean-request",
248
+ jobId: job.id,
249
+ operation: job.operation,
250
+ bufferA: job.bufferA,
251
+ bufferB: job.bufferB,
252
+ cancelBuffer: job.cancelView?.buffer as SharedArrayBuffer | undefined,
253
+ options: job.options,
254
+ };
255
+
256
+ try {
257
+ slot.worker.postMessage(request, [request.bufferA, request.bufferB]);
258
+ } catch (error) {
259
+ const details = error instanceof Error ? error.message : String(error);
260
+ this.finishRunningJob(
261
+ slot,
262
+ job,
263
+ new MeshBooleanExecutionError(
264
+ rejectPayload("worker_protocol", "Failed to post boolean job to worker.", details),
265
+ ),
266
+ undefined,
267
+ );
268
+ }
269
+ }
270
+ }
271
+
272
+ private onWorkerMessage(slot: WorkerSlot, data: MeshBooleanWorkerResponse): void {
273
+ if (!data || data.kind !== "mesh-boolean-result") return;
274
+
275
+ const runningId = slot.currentJobId;
276
+ if (runningId === null) return;
277
+ if (runningId !== data.jobId) {
278
+ this.failWorker(
279
+ slot,
280
+ rejectPayload(
281
+ "worker_protocol",
282
+ `Worker responded with unexpected job id ${data.jobId}; expected ${runningId}.`,
283
+ ),
284
+ );
285
+ return;
286
+ }
287
+
288
+ const job = this.running.get(data.jobId);
289
+ if (!job) {
290
+ slot.currentJobId = null;
291
+ this.dispatch();
292
+ return;
293
+ }
294
+
295
+ if (data.ok) {
296
+ this.finishRunningJob(slot, job, undefined, new Float64Array(data.buffer));
297
+ return;
298
+ }
299
+
300
+ this.finishRunningJob(
301
+ slot,
302
+ job,
303
+ new MeshBooleanExecutionError(data.error),
304
+ undefined,
305
+ );
306
+ }
307
+
308
+ private failWorker(slot: WorkerSlot, payload: MeshBooleanErrorPayload): void {
309
+ const jobId = slot.currentJobId;
310
+ if (jobId !== null) {
311
+ const job = this.running.get(jobId);
312
+ if (job) {
313
+ this.running.delete(jobId);
314
+ this.cleanupJob(job);
315
+ job.reject(new MeshBooleanExecutionError(payload));
316
+ }
317
+ slot.currentJobId = null;
318
+ }
319
+ if (!this.disposed) {
320
+ this.replaceWorker(slot);
321
+ this.dispatch();
322
+ }
323
+ }
324
+
325
+ private cancelJob(jobId: number, code: MeshBooleanErrorPayload["code"], message: string): void {
326
+ const queuedIndex = this.queue.findIndex(job => job.id === jobId);
327
+ if (queuedIndex >= 0) {
328
+ const [job] = this.queue.splice(queuedIndex, 1);
329
+ this.rejectJob(job, rejectPayload(code, message));
330
+ return;
331
+ }
332
+
333
+ const job = this.running.get(jobId);
334
+ if (!job) return;
335
+
336
+ const slot = this.slots.find(s => s.currentJobId === jobId);
337
+ requestCancellation(job);
338
+ this.running.delete(jobId);
339
+ this.cleanupJob(job);
340
+ job.reject(new MeshBooleanExecutionError(rejectPayload(code, message)));
341
+ this.emitProgress(job, "completed", slot?.id);
342
+
343
+ if (slot) {
344
+ try {
345
+ slot.worker.postMessage({ kind: "mesh-boolean-cancel", jobId });
346
+ } catch {
347
+ // Best effort only; fallback behavior below still applies.
348
+ }
349
+
350
+ if (job.cancelView) {
351
+ // Cooperative cancellation: keep the slot occupied until the worker
352
+ // acknowledges completion/abort for this job id.
353
+ this.dispatch();
354
+ return;
355
+ }
356
+
357
+ slot.currentJobId = null;
358
+ if (!this.disposed) {
359
+ this.replaceWorker(slot);
360
+ }
361
+ }
362
+
363
+ this.dispatch();
364
+ }
365
+
366
+ private finishRunningJob(
367
+ slot: WorkerSlot,
368
+ job: BooleanJob,
369
+ error: Error | undefined,
370
+ result: Float64Array | undefined,
371
+ ): void {
372
+ this.running.delete(job.id);
373
+ slot.currentJobId = null;
374
+ this.cleanupJob(job);
375
+
376
+ if (error) {
377
+ job.reject(error);
378
+ } else if (result) {
379
+ job.resolve(result);
380
+ } else {
381
+ job.reject(
382
+ new MeshBooleanExecutionError(
383
+ rejectPayload("worker_protocol", "Boolean worker finished without a valid result."),
384
+ ),
385
+ );
386
+ }
387
+ this.emitProgress(job, "completed", slot.id);
388
+ this.dispatch();
389
+ }
390
+
391
+ private rejectJob(job: BooleanJob, payload: MeshBooleanErrorPayload): void {
392
+ this.cleanupJob(job);
393
+ job.reject(new MeshBooleanExecutionError(payload));
394
+ this.emitProgress(job, "completed");
395
+ }
396
+
397
+ private cleanupJob(job: BooleanJob): void {
398
+ if (job.timeoutToken !== undefined) clearTimeout(job.timeoutToken);
399
+ if (job.signal && job.abortHandler) {
400
+ job.signal.removeEventListener("abort", job.abortHandler);
401
+ }
402
+ }
403
+
404
+ private emitProgress(job: BooleanJob, stage: MeshBooleanProgressEvent["stage"], workerId?: number): void {
405
+ if (!job.onProgress) return;
406
+ job.onProgress({
407
+ stage,
408
+ jobId: job.id,
409
+ workerId,
410
+ queueSize: this.queue.length,
411
+ });
412
+ }
413
+ }
414
+
415
+ let defaultPoolSizeOverride: number | undefined;
416
+ const defaultPools = new Map<number, MeshBooleanWorkerPool>();
417
+ const factoryPools = new WeakMap<() => Worker, Map<number, MeshBooleanWorkerPool>>();
418
+ const customPoolStores = new Set<Map<number, MeshBooleanWorkerPool>>();
419
+
420
+ function getPoolStore(workerFactory?: () => Worker): Map<number, MeshBooleanWorkerPool> {
421
+ if (!workerFactory) return defaultPools;
422
+ let store = factoryPools.get(workerFactory);
423
+ if (!store) {
424
+ store = new Map<number, MeshBooleanWorkerPool>();
425
+ factoryPools.set(workerFactory, store);
426
+ customPoolStores.add(store);
427
+ }
428
+ return store;
429
+ }
430
+
431
+ export function getMeshBooleanWorkerPool(options?: PoolAcquireOptions): MeshBooleanWorkerPool {
432
+ const size = normalizePoolSize(options?.size ?? defaultPoolSizeOverride);
433
+ const store = getPoolStore(options?.workerFactory);
434
+ const existing = store.get(size);
435
+ if (existing && !existing.isDisposed) return existing;
436
+
437
+ const created = new MeshBooleanWorkerPool({
438
+ size,
439
+ workerFactory: options?.workerFactory,
440
+ });
441
+ store.set(size, created);
442
+ return created;
443
+ }
444
+
445
+ export function configureDefaultMeshBooleanWorkerPool(options: { size: number }): void {
446
+ const nextSize = normalizePoolSize(options.size);
447
+ if (defaultPoolSizeOverride === nextSize) return;
448
+ defaultPoolSizeOverride = nextSize;
449
+ for (const pool of defaultPools.values()) {
450
+ pool.dispose();
451
+ }
452
+ defaultPools.clear();
453
+ }
454
+
455
+ export function disposeMeshBooleanWorkerPools(): void {
456
+ for (const pool of defaultPools.values()) {
457
+ pool.dispose();
458
+ }
459
+ defaultPools.clear();
460
+
461
+ for (const store of customPoolStores) {
462
+ for (const pool of store.values()) {
463
+ pool.dispose();
464
+ }
465
+ store.clear();
466
+ }
467
+ customPoolStores.clear();
468
+ }
469
+
470
+ export function runMeshBooleanInWorkerPool(
471
+ operation: MeshBooleanOperation,
472
+ bufferA: Float64Array,
473
+ bufferB: Float64Array,
474
+ options?: MeshBooleanAsyncOptions,
475
+ ): Promise<Float64Array> {
476
+ const pool = getMeshBooleanWorkerPool({
477
+ workerFactory: options?.workerFactory,
478
+ size: options?.poolSize,
479
+ });
480
+ return pool.run(operation, bufferA, bufferB, options);
481
+ }
@@ -0,0 +1,122 @@
1
+ export type MeshBooleanOperation = "union" | "subtraction" | "intersection";
2
+ export type MeshBooleanBackend = "nextgen";
3
+
4
+ export interface MeshBooleanLimits {
5
+ maxInputFacesPerMesh: number;
6
+ maxCombinedInputFaces: number;
7
+ maxFaceProduct: number;
8
+ }
9
+
10
+ export interface MeshBooleanOptions {
11
+ /**
12
+ * Override one or more safety limits for this operation.
13
+ */
14
+ limits?: Partial<MeshBooleanLimits>;
15
+ /**
16
+ * Skip safety limits and force boolean execution.
17
+ */
18
+ allowUnsafe?: boolean;
19
+ /**
20
+ * Boolean backend selector.
21
+ * Defaults to "nextgen" (strict closed-solid topology contract).
22
+ */
23
+ backend?: MeshBooleanBackend;
24
+ }
25
+
26
+ export type MeshBooleanErrorCode =
27
+ | "worker_unavailable"
28
+ | "worker_crashed"
29
+ | "worker_protocol"
30
+ | "worker_aborted"
31
+ | "worker_timeout"
32
+ | "kernel_error";
33
+
34
+ export interface MeshBooleanErrorPayload {
35
+ code: MeshBooleanErrorCode;
36
+ message: string;
37
+ details?: string;
38
+ }
39
+
40
+ export type MeshBooleanJobStage = "queued" | "running" | "completed";
41
+
42
+ export interface MeshBooleanProgressEvent {
43
+ stage: MeshBooleanJobStage;
44
+ jobId: number;
45
+ workerId?: number;
46
+ queueSize: number;
47
+ }
48
+
49
+ export interface MeshBooleanAsyncOptions extends MeshBooleanOptions {
50
+ /**
51
+ * Abort the worker job when this signal is aborted.
52
+ */
53
+ signal?: AbortSignal;
54
+ /**
55
+ * Optional timeout in milliseconds.
56
+ */
57
+ timeoutMs?: number;
58
+ /**
59
+ * Optional custom worker factory (for app-level worker pooling/reuse).
60
+ */
61
+ workerFactory?: () => Worker;
62
+ /**
63
+ * Override worker pool size for this call.
64
+ */
65
+ poolSize?: number;
66
+ /**
67
+ * Observe coarse-grained job stage changes.
68
+ */
69
+ onProgress?: (event: MeshBooleanProgressEvent) => void;
70
+ }
71
+
72
+ export interface MeshBooleanWorkerRequest {
73
+ kind: "mesh-boolean-request";
74
+ jobId: number;
75
+ operation: MeshBooleanOperation;
76
+ bufferA: ArrayBuffer;
77
+ bufferB: ArrayBuffer;
78
+ /**
79
+ * Optional shared cancellation flag (index 0: 0=running, 1=cancel requested).
80
+ * When present, kernel loops can cooperatively abort without waiting for
81
+ * worker message handling.
82
+ */
83
+ cancelBuffer?: SharedArrayBuffer;
84
+ options: MeshBooleanOptions;
85
+ }
86
+
87
+ export interface MeshBooleanWorkerCancel {
88
+ kind: "mesh-boolean-cancel";
89
+ jobId: number;
90
+ }
91
+
92
+ export type MeshBooleanWorkerInbound = MeshBooleanWorkerRequest | MeshBooleanWorkerCancel;
93
+
94
+ export interface MeshBooleanWorkerSuccessResponse {
95
+ kind: "mesh-boolean-result";
96
+ jobId: number;
97
+ ok: true;
98
+ buffer: ArrayBuffer;
99
+ elapsedMs: number;
100
+ }
101
+
102
+ export interface MeshBooleanWorkerErrorResponse {
103
+ kind: "mesh-boolean-result";
104
+ jobId: number;
105
+ ok: false;
106
+ error: MeshBooleanErrorPayload;
107
+ elapsedMs: number;
108
+ }
109
+
110
+ export type MeshBooleanWorkerResponse = MeshBooleanWorkerSuccessResponse | MeshBooleanWorkerErrorResponse;
111
+
112
+ export class MeshBooleanExecutionError extends Error {
113
+ readonly code: MeshBooleanErrorCode;
114
+ readonly details?: string;
115
+
116
+ constructor(payload: MeshBooleanErrorPayload) {
117
+ super(payload.message);
118
+ this.name = "MeshBooleanExecutionError";
119
+ this.code = payload.code;
120
+ this.details = payload.details;
121
+ }
122
+ }