diff --git a/src/core/layout-strategies.ts b/src/core/layout-strategies.ts index 42f7d89..ba63151 100644 --- a/src/core/layout-strategies.ts +++ b/src/core/layout-strategies.ts @@ -1,5 +1,10 @@ import type {GeneratorParams, LayoutType, Point} from '../types'; -import {createPointCollector, getSafeRadius, validateLayoutParams} from './geometry-utils'; +import { + createPointCollector, + getSafeRadius, + isWithinCutoffZone, + validateLayoutParams, +} from './geometry-utils'; export interface LayoutStrategy { calculatePoints(params: GeneratorParams): Point[]; @@ -135,3 +140,13 @@ const strategies: Record = { }; export const getLayoutStrategy = (layout: LayoutType) => strategies[layout]; + +/** + * Full tube-point layout for the given params: the strategy points minus the + * impingement cut-off zones. Shared by the layout worker and its synchronous + * fallback so both paths produce identical results. + */ +export const computeLayoutPoints = (params: GeneratorParams): Point[] => + getLayoutStrategy(params.tubeLayout) + .calculatePoints(params) + .filter((point) => !isWithinCutoffZone(point, params)); diff --git a/src/hooks/useGeneratorState.ts b/src/hooks/useGeneratorState.ts index 9ffc00c..bc616a2 100644 --- a/src/hooks/useGeneratorState.ts +++ b/src/hooks/useGeneratorState.ts @@ -1,10 +1,11 @@ import {useCallback, useDeferredValue, useEffect, useMemo, useRef, useState} from 'react'; import type React from 'react'; import {DEFAULT_PARAMS, MAX_TUBE_POINTS} from '../constants'; -import {getLayoutStrategy} from '../core/layout-strategies'; -import {estimateLayoutPointCount, isWithinCutoffZone} from '../core/geometry-utils'; +import {computeLayoutPoints} from '../core/layout-strategies'; +import {estimateLayoutPointCount} from '../core/geometry-utils'; import type {GeneratorParams, ModifiedHole, Point} from '../types'; import {generateStepInWorker, warmupCadWorker} from '../services/cad-worker-client'; +import {requestLayout} from '../services/layout-worker-client'; import type {CadWorkerProgressMessage} from '../services/cad-worker-protocol'; type WorkerStatus = 'idle' | 'warming' | 'ready' | 'error'; @@ -47,17 +48,29 @@ export default function useGeneratorState(): UseGeneratorStateResult { [deferredParams], ); - // Guard against pathological inputs (huge diameter + tiny pitch) that would - // otherwise spin an O((D/pitch)^2) loop on the main thread and freeze the tab. + // Guard against pathological inputs (huge diameter + tiny pitch). The compute + // itself runs off-thread, but the canvas render and RBush index are still + // main-thread, so this cap protects those. const layoutTooLarge = estimatedPointCount > MAX_TUBE_POINTS; - const tubeCoords = useMemo(() => { + // Layout points are generated in a Web Worker so the UI thread stays free. + // Seed synchronously with the initial params to avoid a first-paint flash, + // then let the worker update on every (deferred) change, ignoring stale + // responses (latest request wins). + const [tubeCoords, setTubeCoords] = useState(() => computeLayoutPoints(DEFAULT_PARAMS)); + const layoutRequestSeq = useRef(0); + + useEffect(() => { if (layoutTooLarge) { - return []; + setTubeCoords([]); + return; } - return getLayoutStrategy(deferredParams.tubeLayout) - .calculatePoints(deferredParams) - .filter((point) => !isWithinCutoffZone(point, deferredParams)); + const seq = ++layoutRequestSeq.current; + void requestLayout(deferredParams).then((points) => { + if (seq === layoutRequestSeq.current) { + setTubeCoords(points); + } + }); }, [deferredParams, layoutTooLarge]); const warmupWorker = useCallback(async () => { diff --git a/src/services/layout-worker-client.ts b/src/services/layout-worker-client.ts new file mode 100644 index 0000000..257d28e --- /dev/null +++ b/src/services/layout-worker-client.ts @@ -0,0 +1,74 @@ +import type {GeneratorParams, Point} from '../types'; +import {computeLayoutPoints} from '../core/layout-strategies'; +import {decodePoints} from './layout-worker-protocol'; +import type {LayoutWorkerRequest, LayoutWorkerResult} from './layout-worker-protocol'; + +const createRequestId = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`; +}; + +type Pending = { + resolve: (points: Point[]) => void; + reject: (error: Error) => void; +}; + +let worker: Worker | null = null; +const pending = new Map(); + +const destroyWorker = (error: Error) => { + const current = worker; + worker = null; + const handlers = Array.from(pending.values()); + pending.clear(); + handlers.forEach((handler) => handler.reject(error)); + current?.terminate(); +}; + +const getWorker = (): Worker | null => { + if (worker) return worker; + if (typeof Worker === 'undefined') return null; + + const instance = new Worker(new URL('./layout-worker.ts', import.meta.url), {type: 'module'}); + instance.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (!message || typeof message !== 'object') return; + const handler = pending.get(message.requestId); + if (!handler) return; + pending.delete(message.requestId); + if (message.ok) { + handler.resolve(decodePoints(message.buffer)); + } else { + handler.reject(new Error(message.message)); + } + }); + instance.addEventListener('error', (event) => { + const error = event.error instanceof Error ? event.error : new Error(event.message || 'Layout worker crashed.'); + if (worker === instance) { + destroyWorker(error); + } + }); + + worker = instance; + return instance; +}; + +/** + * Compute a layout off the main thread. Falls back to a synchronous compute if + * a worker can't be created or the worker rejects, so callers always get points. + */ +export const requestLayout = (params: GeneratorParams): Promise => { + const w = getWorker(); + if (!w) { + return Promise.resolve(computeLayoutPoints(params)); + } + + const requestId = createRequestId(); + return new Promise((resolve, reject) => { + pending.set(requestId, {resolve, reject}); + const request: LayoutWorkerRequest = {requestId, params}; + w.postMessage(request); + }).catch(() => computeLayoutPoints(params)); +}; diff --git a/src/services/layout-worker-protocol.test.ts b/src/services/layout-worker-protocol.test.ts new file mode 100644 index 0000000..c860ec7 --- /dev/null +++ b/src/services/layout-worker-protocol.test.ts @@ -0,0 +1,24 @@ +import {describe, expect, it} from 'vitest'; +import {decodePoints, encodePoints} from './layout-worker-protocol'; +import type {Point} from '../types'; + +describe('layout point codec', () => { + it('round-trips points through the Float64 buffer', () => { + const points: Point[] = [ + {x: 0, y: 0}, + {x: 12.5, y: -3.25}, + {x: -240.125, y: 199.5}, + ]; + expect(decodePoints(encodePoints(points))).toEqual(points); + }); + + it('handles an empty layout', () => { + const buffer = encodePoints([]); + expect(buffer.byteLength).toBe(0); + expect(decodePoints(buffer)).toEqual([]); + }); + + it('produces a buffer of two float64s per point', () => { + expect(encodePoints([{x: 1, y: 2}, {x: 3, y: 4}]).byteLength).toBe(2 * 2 * 8); + }); +}); diff --git a/src/services/layout-worker-protocol.ts b/src/services/layout-worker-protocol.ts new file mode 100644 index 0000000..9183ce0 --- /dev/null +++ b/src/services/layout-worker-protocol.ts @@ -0,0 +1,30 @@ +import type {GeneratorParams, Point} from '../types'; + +export type LayoutWorkerRequest = { + requestId: string; + params: GeneratorParams; +}; + +export type LayoutWorkerResult = + | {requestId: string; ok: true; buffer: ArrayBuffer} + | {requestId: string; ok: false; message: string}; + +/** Pack points into an interleaved [x0,y0,x1,y1,...] Float64Array buffer. */ +export const encodePoints = (points: Point[]): ArrayBuffer => { + const array = new Float64Array(points.length * 2); + for (let i = 0; i < points.length; i++) { + array[i * 2] = points[i].x; + array[i * 2 + 1] = points[i].y; + } + return array.buffer; +}; + +/** Reverse of encodePoints. */ +export const decodePoints = (buffer: ArrayBuffer): Point[] => { + const array = new Float64Array(buffer); + const points: Point[] = new Array(array.length / 2); + for (let i = 0; i < points.length; i++) { + points[i] = {x: array[i * 2], y: array[i * 2 + 1]}; + } + return points; +}; diff --git a/src/services/layout-worker.ts b/src/services/layout-worker.ts new file mode 100644 index 0000000..ba387dd --- /dev/null +++ b/src/services/layout-worker.ts @@ -0,0 +1,28 @@ +/// + +import {computeLayoutPoints} from '../core/layout-strategies'; +import {encodePoints} from './layout-worker-protocol'; +import type {LayoutWorkerRequest, LayoutWorkerResult} from './layout-worker-protocol'; + +const ctx = self as unknown as DedicatedWorkerGlobalScope; + +ctx.onmessage = (event: MessageEvent) => { + const request = event.data; + if (!request || typeof request !== 'object' || typeof request.requestId !== 'string') { + return; + } + + try { + const points = computeLayoutPoints(request.params); + const buffer = encodePoints(points); + const message: LayoutWorkerResult = {requestId: request.requestId, ok: true, buffer}; + ctx.postMessage(message, [buffer]); + } catch (error) { + const message: LayoutWorkerResult = { + requestId: request.requestId, + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + ctx.postMessage(message); + } +};