Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -27,8 +28,11 @@ export default function App() {

const [modifiedHoles, setModifiedHoles] = useState<Map<string, ModifiedHole>>(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,
Expand All @@ -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 (
<main className="app-shell">
Expand Down Expand Up @@ -77,7 +81,7 @@ export default function App() {
<section className="preview-panel">
<div className="preview-frame">
<PreviewCanvas
points={tubeCoords}
keyedPoints={keyedTubes}
params={params}
modifiedHoles={modifiedHoles}
selectedHoleKeys={editing.affectedHoleKeys}
Expand Down
10 changes: 9 additions & 1 deletion src/core/geometry-utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import type {GeneratorParams, Point} from '../types';
import type {GeneratorParams, KeyedPoint, Point} from '../types';

export const normalizeZero = (value: number) => (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);

Expand Down
49 changes: 25 additions & 24 deletions src/core/tube-stats.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): GeneratorParams => ({
Expand All @@ -20,54 +20,55 @@ const baseParams = (overrides: Partial<GeneratorParams> = {}): 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<string, ModifiedHole>();
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<string, ModifiedHole>([[createPointKey(coords[0]), {diameter: 50}]]);
const stats = computeTubeStats(coords, mods, baseParams());
const keyed = keyPoints([{x: 0, y: 0}]);
const mods = new Map<string, ModifiedHole>([[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<string, ModifiedHole>([[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<string, ModifiedHole>([[keyed[0].key, {diameter: 60}]]);
const stats = computeTubeStats(keyed, mods, baseParams());
expect(stats.edgeOverflow).toBe(1);
});
});
12 changes: 6 additions & 6 deletions src/core/tube-stats.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, ModifiedHole>,
params: GeneratorParams,
): TubeStats => {
Expand All @@ -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;
Expand All @@ -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};
Expand Down
11 changes: 6 additions & 5 deletions src/hooks/useHoleEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ModifiedHole>;
setModifiedHoles: React.Dispatch<React.SetStateAction<Map<string, ModifiedHole>>>;
Expand All @@ -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<Set<string>>(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<string, Point>();
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<string>();
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
19 changes: 5 additions & 14 deletions src/ui/preview/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +14,7 @@ import usePointerInteraction from './hooks/usePointerInteraction';
import ZoomControls from './ZoomControls';

export type PreviewCanvasProps = {
points: Point[];
keyedPoints: KeyedPoint[];
params: PreviewParams;
modifiedHoles: Map<string, ModifiedHole>;
selectedHoleKeys: Set<string>;
Expand All @@ -29,7 +27,7 @@ export type PreviewCanvasProps = {
};

export default function PreviewCanvas({
points,
keyedPoints,
params,
modifiedHoles,
selectedHoleKeys,
Expand All @@ -41,14 +39,7 @@ export default function PreviewCanvas({
style,
}: PreviewCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(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<KeyedPoint[]>(
() => 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.
Expand Down Expand Up @@ -145,7 +136,7 @@ export default function PreviewCanvas({
<ZoomControls
zoom={viewport.zoom}
visibleCount={visibleCount}
totalCount={points.length}
totalCount={keyedPoints.length}
onZoomIn={() => zoomToCenter(ZOOM_STEP)}
onZoomOut={() => zoomToCenter(1 / ZOOM_STEP)}
onZoomChange={(zoom) => setViewport((current) => ({...current, zoom}))}
Expand Down
6 changes: 1 addition & 5 deletions src/ui/preview/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';