-
Notifications
You must be signed in to change notification settings - Fork 0
Move layout generation into a Web Worker (#7) #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Point[]>(() => { | ||
| // 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<Point[]>(() => 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]); | ||
|
Comment on lines
+61
to
74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a critical race condition in the useEffect(() => {\n let active = true;\n if (layoutTooLarge) {\n setTubeCoords([]);\n return;\n }\n void requestLayout(deferredParams).then((points) => {\n if (active) {\n setTubeCoords(points);\n }\n });\n return () => {\n active = false;\n };\n }, [deferredParams, layoutTooLarge]); |
||
|
|
||
| const warmupWorker = useCallback(async () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, Pending>(); | ||
|
|
||
| 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'}); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| instance.addEventListener('message', (event: MessageEvent<LayoutWorkerResult>) => { | ||
| 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; | ||
| }; | ||
|
Comment on lines
+30
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instantiating a const getWorker = (): Worker | null => {\n if (worker) return worker;\n if (typeof Worker === 'undefined') return null;\n\n try {\n const instance = new Worker(new URL('./layout-worker.ts', import.meta.url), {type: 'module'});\n instance.addEventListener('message', (event: MessageEvent<LayoutWorkerResult>) => {\n const message = event.data;\n if (!message || typeof message !== 'object') return;\n const handler = pending.get(message.requestId);\n if (!handler) return;\n pending.delete(message.requestId);\n if (message.ok) {\n handler.resolve(decodePoints(message.buffer));\n } else {\n handler.reject(new Error(message.message));\n }\n });\n instance.addEventListener('error', (event) => {\n const error = event.error instanceof Error ? event.error : new Error(event.message || 'Layout worker crashed.');\n if (worker === instance) {\n destroyWorker(error);\n }\n });\n\n worker = instance;\n return instance;\n } catch (error) {\n console.error('Failed to initialize layout worker:', error);\n return null;\n }\n}; |
||
|
|
||
| /** | ||
| * 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<Point[]> => { | ||
| const w = getWorker(); | ||
| if (!w) { | ||
| return Promise.resolve(computeLayoutPoints(params)); | ||
| } | ||
|
|
||
| const requestId = createRequestId(); | ||
| return new Promise<Point[]>((resolve, reject) => { | ||
| pending.set(requestId, {resolve, reject}); | ||
| const request: LayoutWorkerRequest = {requestId, params}; | ||
| w.postMessage(request); | ||
| }).catch(() => computeLayoutPoints(params)); | ||
|
Comment on lines
+69
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If return new Promise<Point[]>((resolve, reject) => {\n pending.set(requestId, {resolve, reject});\n const request: LayoutWorkerRequest = {requestId, params};\n try {\n w.postMessage(request);\n } catch (error) {\n pending.delete(requestId);\n reject(error);\n }\n }).catch(() => computeLayoutPoints(params)); |
||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| /// <reference lib="webworker" /> | ||
|
|
||
| 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<LayoutWorkerRequest>) => { | ||
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a valid layout request is still in flight and the user changes to parameters whose estimate exceeds
MAX_TUBE_POINTS, this branch clears the coordinates but leaveslayoutRequestSequnchanged. The old promise still has the current sequence value and can later callsetTubeCoords(points), so the preview and hole counts can repopulate with an out-of-date layout whilelayoutTooLargeis true. Increment the sequence or otherwise cancel/ignore pending requests before returning here.Useful? React with 👍 / 👎.