From 994cf37b4bc40a7f6a946fe81bbc124043bedd24 Mon Sep 17 00:00:00 2001 From: biosxxx Date: Sat, 18 Jul 2026 00:00:08 +0300 Subject: [PATCH] Share computed point keys across preview, editing and stats Compute point string keys once per layout (App-level keyPoints) and thread the KeyedPoint[] into the preview, useHoleEditing and computeTubeStats, instead of each recomputing createPointKey over every point. On dense layouts that removes ~2/3 of the per-layout key work (a full pass was ~9ms at 52k points). - add shared KeyedPoint type + keyPoints() helper in core - PreviewCanvas takes keyedPoints (drops its internal key memo) - useHoleEditing builds pointByKey from existing keys - computeTubeStats consumes keyed points (tests updated) No behaviour change; verified in-browser (render, select, stats, tie-rod edit). Closes #11. Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 10 +++++--- src/core/geometry-utils.ts | 10 +++++++- src/core/tube-stats.test.ts | 49 +++++++++++++++++++------------------ src/core/tube-stats.ts | 12 ++++----- src/hooks/useHoleEditing.ts | 11 +++++---- src/types.ts | 3 +++ src/ui/preview/index.tsx | 19 ++++---------- src/ui/preview/types.ts | 6 +---- 8 files changed, 62 insertions(+), 58 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b57da9a..5296800 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import useSyncedTheme from './hooks/useSyncedTheme'; import useHoleEditing from './hooks/useHoleEditing'; import useSession from './hooks/useSession'; import {computeTubeStats} from './core/tube-stats'; +import {keyPoints} from './core/geometry-utils'; import type {ModifiedHole} from './types'; export default function App() { @@ -27,8 +28,11 @@ export default function App() { const [modifiedHoles, setModifiedHoles] = useState>(new Map()); + // Single point-key computation per layout, shared by preview, editing and stats. + const keyedTubes = useMemo(() => keyPoints(tubeCoords), [tubeCoords]); + const editing = useHoleEditing({ - tubeCoords, + keyedTubes, tubeDiameter: params.tubeDiameter, modifiedHoles, setModifiedHoles, @@ -46,7 +50,7 @@ export default function App() { generateStep, }); - const stats = useMemo(() => computeTubeStats(tubeCoords, modifiedHoles, params), [modifiedHoles, params, tubeCoords]); + const stats = useMemo(() => computeTubeStats(keyedTubes, modifiedHoles, params), [keyedTubes, modifiedHoles, params]); return (
@@ -77,7 +81,7 @@ export default function App() {
(Math.abs(value) < 1e-6 ? 0 : value); export const createPointKey = (point: Point) => `${normalizeZero(point.x).toFixed(4)}:${normalizeZero(point.y).toFixed(4)}`; +/** + * Pair each point with its string key in a single pass. Compute this once per + * layout and share the result so preview, editing and stats don't each rebuild + * keys (the dominant per-layout cost on dense sheets). + */ +export const keyPoints = (points: Point[]): KeyedPoint[] => + points.map((point) => ({point, key: createPointKey(point)})); + export const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); diff --git a/src/core/tube-stats.test.ts b/src/core/tube-stats.test.ts index 130d6ee..7193633 100644 --- a/src/core/tube-stats.test.ts +++ b/src/core/tube-stats.test.ts @@ -1,7 +1,7 @@ import {describe, expect, it} from 'vitest'; import {computeTubeStats} from './tube-stats'; import {getLayoutStrategy} from './layout-strategies'; -import {createPointKey} from './geometry-utils'; +import {keyPoints} from './geometry-utils'; import type {GeneratorParams, ModifiedHole, Point} from '../types'; const baseParams = (overrides: Partial = {}): GeneratorParams => ({ @@ -20,54 +20,55 @@ const baseParams = (overrides: Partial = {}): GeneratorParams = ...overrides, }); -const defaultCoords = () => getLayoutStrategy('triangular').calculatePoints(baseParams()); +const defaultKeyed = () => keyPoints(getLayoutStrategy('triangular').calculatePoints(baseParams())); describe('computeTubeStats', () => { it('counts every hole as an active tube with no overrides', () => { - const coords = defaultCoords(); - const stats = computeTubeStats(coords, new Map(), baseParams()); - expect(stats.cutHoles).toBe(coords.length); - expect(stats.activeTubes).toBe(coords.length); + const keyed = defaultKeyed(); + const stats = computeTubeStats(keyed, new Map(), baseParams()); + expect(stats.cutHoles).toBe(keyed.length); + expect(stats.activeTubes).toBe(keyed.length); expect(stats.hidden).toBe(0); expect(stats.tieRods).toBe(0); }); it('uses the nominal outer surface for a uniform sheet', () => { - const coords = defaultCoords(); - const stats = computeTubeStats(coords, new Map(), baseParams()); - expect(stats.heatTransferArea).toBeCloseTo(coords.length * Math.PI * 25 * 3000); + const keyed = defaultKeyed(); + const stats = computeTubeStats(keyed, new Map(), baseParams()); + expect(stats.heatTransferArea).toBeCloseTo(keyed.length * Math.PI * 25 * 3000); }); it('excludes hidden holes and tie rods from the active/area totals', () => { - const coords = defaultCoords(); + const keyed = defaultKeyed(); const mods = new Map(); - mods.set(createPointKey(coords[0]), {hidden: true}); - mods.set(createPointKey(coords[1]), {type: 'tieRod'}); - const stats = computeTubeStats(coords, mods, baseParams()); + mods.set(keyed[0].key, {hidden: true}); + mods.set(keyed[1].key, {type: 'tieRod'}); + const stats = computeTubeStats(keyed, mods, baseParams()); expect(stats.hidden).toBe(1); expect(stats.tieRods).toBe(1); - expect(stats.cutHoles).toBe(coords.length - 1); - expect(stats.activeTubes).toBe(coords.length - 2); - expect(stats.heatTransferArea).toBeCloseTo((coords.length - 2) * Math.PI * 25 * 3000); + expect(stats.cutHoles).toBe(keyed.length - 1); + expect(stats.activeTubes).toBe(keyed.length - 2); + expect(stats.heatTransferArea).toBeCloseTo((keyed.length - 2) * Math.PI * 25 * 3000); }); it('reflects a custom diameter in the heat-transfer area', () => { - const coords: Point[] = [{x: 0, y: 0}]; - const mods = new Map([[createPointKey(coords[0]), {diameter: 50}]]); - const stats = computeTubeStats(coords, mods, baseParams()); + const keyed = keyPoints([{x: 0, y: 0}]); + const mods = new Map([[keyed[0].key, {diameter: 50}]]); + const stats = computeTubeStats(keyed, mods, baseParams()); expect(stats.heatTransferArea).toBeCloseTo(Math.PI * 50 * 3000); }); it('flags holes that overlap the partition band', () => { - const coords = defaultCoords(); - const stats = computeTubeStats(coords, new Map(), baseParams({passCount: 2, partitionWidth: 10})); + const keyed = defaultKeyed(); + const stats = computeTubeStats(keyed, new Map(), baseParams({passCount: 2, partitionWidth: 10})); expect(stats.partitionConflicts).toBeGreaterThan(0); }); it('flags a hole pushed past the sheet edge by a large custom diameter', () => { - const coords: Point[] = [{x: 240, y: 0}]; // board radius 250 - const mods = new Map([[createPointKey(coords[0]), {diameter: 60}]]); - const stats = computeTubeStats(coords, mods, baseParams()); + const points: Point[] = [{x: 240, y: 0}]; // board radius 250 + const keyed = keyPoints(points); + const mods = new Map([[keyed[0].key, {diameter: 60}]]); + const stats = computeTubeStats(keyed, mods, baseParams()); expect(stats.edgeOverflow).toBe(1); }); }); diff --git a/src/core/tube-stats.ts b/src/core/tube-stats.ts index 3b92b92..f494541 100644 --- a/src/core/tube-stats.ts +++ b/src/core/tube-stats.ts @@ -1,5 +1,5 @@ -import type {GeneratorParams, ModifiedHole, Point} from '../types'; -import {createPointKey, isWithinPartitionBand} from './geometry-utils'; +import type {GeneratorParams, KeyedPoint, ModifiedHole} from '../types'; +import {isWithinPartitionBand} from './geometry-utils'; export type TubeStats = { hidden: number; @@ -19,7 +19,7 @@ export type TubeStats = { * hole's per-hole overrides (hidden / custom diameter / tie rod). */ export const computeTubeStats = ( - tubeCoords: Point[], + keyedTubes: KeyedPoint[], modifiedHoles: Map, params: GeneratorParams, ): TubeStats => { @@ -30,8 +30,8 @@ export const computeTubeStats = ( let edgeOverflow = 0; const boardRadius = params.boardDiameter / 2; - tubeCoords.forEach((point) => { - const modified = modifiedHoles.get(createPointKey(point)); + keyedTubes.forEach(({point, key}) => { + const modified = modifiedHoles.get(key); if (modified?.hidden) { hidden += 1; return; @@ -57,7 +57,7 @@ export const computeTubeStats = ( heatTransferArea += Math.PI * diameter * params.tubeLength; }); - const cutHoles = Math.max(0, tubeCoords.length - hidden); + const cutHoles = Math.max(0, keyedTubes.length - hidden); const activeTubes = Math.max(0, cutHoles - tieRods); return {hidden, tieRods, cutHoles, activeTubes, heatTransferArea, partitionConflicts, edgeOverflow}; diff --git a/src/hooks/useHoleEditing.ts b/src/hooks/useHoleEditing.ts index 41cdb4d..64cf52c 100644 --- a/src/hooks/useHoleEditing.ts +++ b/src/hooks/useHoleEditing.ts @@ -2,10 +2,10 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; import type React from 'react'; import {createPointKey} from '../core/geometry-utils'; import {isModifiedHoleDefault} from '../core/modified-hole'; -import type {HoleShape, HoleType, ModifiedHole, Point} from '../types'; +import type {HoleShape, HoleType, KeyedPoint, ModifiedHole, Point} from '../types'; type UseHoleEditingArgs = { - tubeCoords: Point[]; + keyedTubes: KeyedPoint[]; tubeDiameter: number; modifiedHoles: Map; setModifiedHoles: React.Dispatch>>; @@ -16,17 +16,18 @@ type UseHoleEditingArgs = { * per-hole edit operations (type, diameter, visibility, shape, reset). Keeps * the selection in sync with the current layout and wires keyboard shortcuts. */ -export default function useHoleEditing({tubeCoords, tubeDiameter, modifiedHoles, setModifiedHoles}: UseHoleEditingArgs) { +export default function useHoleEditing({keyedTubes, tubeDiameter, modifiedHoles, setModifiedHoles}: UseHoleEditingArgs) { const [selectedHoleKeys, setSelectedHoleKeys] = useState>(new Set()); const [menuDiameter, setMenuDiameter] = useState(''); const [mirrorHorizontal, setMirrorHorizontal] = useState(false); const [mirrorVertical, setMirrorVertical] = useState(false); + // Built from the shared keyed points — no createPointKey pass here. const pointByKey = useMemo(() => { const next = new Map(); - tubeCoords.forEach((point) => next.set(createPointKey(point), point)); + keyedTubes.forEach(({point, key}) => next.set(key, point)); return next; - }, [tubeCoords]); + }, [keyedTubes]); const affectedHoleKeys = useMemo(() => { const next = new Set(); diff --git a/src/types.ts b/src/types.ts index 8bb18a0..25c7612 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,8 @@ export type Point = {x: number; y: number}; +/** A tube point paired with its precomputed string key (see createPointKey). */ +export type KeyedPoint = {point: Point; key: string}; + export type LayoutType = 'triangular30' | 'triangular' | 'square45' | 'square'; export type PartitionOrientation = 'horizontal' | 'vertical'; diff --git a/src/ui/preview/index.tsx b/src/ui/preview/index.tsx index 266ca46..983ef00 100644 --- a/src/ui/preview/index.tsx +++ b/src/ui/preview/index.tsx @@ -1,13 +1,11 @@ import {useEffect, useMemo, useRef, useState} from 'react'; import type React from 'react'; import type {ThemeMode} from '../../hooks/useSyncedTheme'; -import type {ModifiedHole, Point} from '../../types'; -import {createPointKey} from '../../core/geometry-utils'; +import type {KeyedPoint, ModifiedHole, Point} from '../../types'; import {getSheetColors} from './colors'; import {renderScene} from './renderScene'; import type {PreviewParams} from './renderScene'; import {ZOOM_STEP} from './types'; -import type {KeyedPoint} from './types'; import useCanvasSize from './hooks/useCanvasSize'; import useSpatialIndex from './hooks/useSpatialIndex'; import useViewport from './hooks/useViewport'; @@ -16,7 +14,7 @@ import usePointerInteraction from './hooks/usePointerInteraction'; import ZoomControls from './ZoomControls'; export type PreviewCanvasProps = { - points: Point[]; + keyedPoints: KeyedPoint[]; params: PreviewParams; modifiedHoles: Map; selectedHoleKeys: Set; @@ -29,7 +27,7 @@ export type PreviewCanvasProps = { }; export default function PreviewCanvas({ - points, + keyedPoints, params, modifiedHoles, selectedHoleKeys, @@ -41,14 +39,7 @@ export default function PreviewCanvas({ style, }: PreviewCanvasProps) { const canvasRef = useRef(null); - const [visibleCount, setVisibleCount] = useState(points.length); - - // Precompute keys once per points change; the render path and spatial index - // reuse them instead of rebuilding a key per point per frame. - const keyedPoints = useMemo( - () => points.map((point) => ({point, key: createPointKey(point)})), - [points], - ); + const [visibleCount, setVisibleCount] = useState(keyedPoints.length); // View-independent: recompute only when the layout or hidden set changes, not // on every pan/zoom frame. @@ -145,7 +136,7 @@ export default function PreviewCanvas({ zoomToCenter(ZOOM_STEP)} onZoomOut={() => zoomToCenter(1 / ZOOM_STEP)} onZoomChange={(zoom) => setViewport((current) => ({...current, zoom}))} diff --git a/src/ui/preview/types.ts b/src/ui/preview/types.ts index fff14cf..a23b00a 100644 --- a/src/ui/preview/types.ts +++ b/src/ui/preview/types.ts @@ -32,8 +32,4 @@ export type SpatialItem = { key: string; }; -/** A tube point paired with its precomputed key, built once per points change. */ -export type KeyedPoint = { - point: Point; - key: string; -}; +export type {KeyedPoint} from '../../types';