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
8 changes: 6 additions & 2 deletions src/components/cad/CadToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function CadToolbar({ viewport }: { viewport: RefObject<CadViewportHandle
const splitType = useProjectStore((s) => s.project.chipParams.splitType);

const twoPiece = splitType === 'two-piece';
// A slide chip separates too: Explode lifts the glass off the plate.
const separable = twoPiece || splitType === 'glass-slide';
// Isolate belongs to X-ray — it is the same question (what is inside the
// block?) taken to its end — so the controls appear with it and not before.
const xray = displayMode === 'xray';
Expand Down Expand Up @@ -100,12 +102,14 @@ export function CadToolbar({ viewport }: { viewport: RefObject<CadViewportHandle

<ToolButton
active={exploded}
disabled={!twoPiece}
disabled={!separable}
onClick={toggleExploded}
title={
twoPiece
? 'Separate the two halves to inspect the bonding faces'
: 'A monolithic chip has nothing to separate'
: separable
? 'Lift the glass slide off to inspect the open channels'
: 'A monolithic chip has nothing to separate'
}
>
<Scan size={15} />
Expand Down
20 changes: 13 additions & 7 deletions src/lib/cad/portGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
*/

import type { PortNode } from '../../types/project';
import { bossFilletMm, LUER_LOCK_GEOMETRY, LUER_TAPER, specNumber } from '../ports';
import {
bossFilletMm,
LUER_LOCK_GEOMETRY,
LUER_TAPER,
PORT_BOSS_WALL_MM,
specNumber,
} from '../ports';
import { helixStations, sweepStations, type RawMesh } from './sweep';

export const THREAD_APPROXIMATION_NOTE =
Expand Down Expand Up @@ -332,7 +338,7 @@ export function buildPort(
const cavityBottom = channelZ - 0.01;

let approximated = false;
let bossRadius = bore + 0.8;
let bossRadius = bore + PORT_BOSS_WALL_MM;

switch (node.portType) {
case 'luer':
Expand All @@ -350,7 +356,7 @@ export function buildPort(
const shaft = coneMesh(bore, bore, cavityBottom, taperBottom + 0.01, segments);
if (shaft) cavity.push(shaft);

bossRadius = large + 0.8;
bossRadius = large + PORT_BOSS_WALL_MM;

if (node.portType === 'luer-lock') {
// The taper above stays whole: the seal is the full 6% cone. The boss
Expand Down Expand Up @@ -411,7 +417,7 @@ export function buildPort(
);
if (shaft) cavity.push(shaft);

bossRadius = large + 0.8;
bossRadius = large + PORT_BOSS_WALL_MM;
break;
}

Expand Down Expand Up @@ -441,7 +447,7 @@ export function buildPort(
const shaft = coneMesh(bore, bore, cavityBottom, base + length + 0.01, segments);
if (shaft) cavity.push(shaft);

bossRadius = crest + 0.8;
bossRadius = crest + PORT_BOSS_WALL_MM;
break;
}

Expand All @@ -456,7 +462,7 @@ export function buildPort(
const shaft = coneMesh(bore, bore, cavityBottom, barrelBottom + 0.01, segments);
if (shaft) cavity.push(shaft);

bossRadius = barrel + 0.8;
bossRadius = barrel + PORT_BOSS_WALL_MM;
break;
}

Expand All @@ -471,7 +477,7 @@ export function buildPort(
const shaft = coneMesh(bore, bore, cavityBottom, socketBottom + 0.01, segments);
if (shaft) cavity.push(shaft);

bossRadius = outer + 0.8;
bossRadius = outer + PORT_BOSS_WALL_MM;
break;
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/lib/fluids/fluids.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,10 @@ describe('glycerol/water', () => {
it('gives the blood analog a blood-like viscosity and flags the shear-thinning caveat', () => {
const analog = FLUID_PRESETS.find((f) => f.id === 'preset-blood-analog');
assert.ok(analog);
// Whole blood at high shear is ~3.5 mPa·s.
// Whole blood at high shear is ~3.5 mPa·s, and the preset's note says it is
// matched to that value — so hold it to that, not to "somewhere blood-ish".
assert.ok(
analog.dynamicViscosityPaS > 0.002 && analog.dynamicViscosityPaS < 0.006,
analog.dynamicViscosityPaS > 0.0033 && analog.dynamicViscosityPaS < 0.0037,
`got ${analog.dynamicViscosityPaS} Pa·s`,
);
assert.ok(analog.nonNewtonianNote, 'the shear-thinning caveat must be carried in the data');
Expand Down
4 changes: 3 additions & 1 deletion src/lib/fluids/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,9 @@ export const FLUID_PRESETS: FluidProfile[] = [
note: 'Adjust the glycerol percentage to tune viscosity — the relationship is strongly non-linear.',
},
{
...glycerolWaterProfile(40, 20, {
// 33.5 % by volume is what lands on ~3.5 mPa·s at 20 °C with the Cheng
// model above; 40 % (a common rule of thumb) gives ~4.8.
...glycerolWaterProfile(33.5, 20, {
id: 'preset-blood-analog',
name: 'Blood-mimicking analog',
}),
Expand Down
1 change: 1 addition & 0 deletions src/lib/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { PersistStorage, StateStorage, StorageValue } from 'zustand/middlew

export const SETTINGS_STORAGE_KEY = 'openchip.settings.v1';
export const DRAFT_STORAGE_KEY = 'openchip.draft.v1';
export const CHECK_SETTINGS_STORAGE_KEY = 'openchip.check-settings.v1';

// ---------------------------------------------------------------------------
// safe localStorage
Expand Down
21 changes: 21 additions & 0 deletions src/lib/ports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
PORT_TYPES,
bossFilletMm,
matchFitting,
portFootprintMm,
portOuterFootprintMm,
PORT_BOSS_WALL_MM,
portSpecFor,
taperRatioOf,
type FittingPreset,
Expand Down Expand Up @@ -163,6 +166,24 @@ describe('port presets', () => {
});
});

describe('portOuterFootprintMm', () => {
it('adds the boss wall all round when a boss is printed', () => {
const spec = { bossHeightMm: 3.5 };
expect(portOuterFootprintMm('luer', spec)).toBeCloseTo(
portFootprintMm('luer', spec) + 2 * PORT_BOSS_WALL_MM,
);
});

it('is the opening alone when there is no boss', () => {
expect(portOuterFootprintMm('barb', { bossHeightMm: 0 })).toBe(portFootprintMm('barb', {}));
});

it("leaves a luer lock at its thread, which is already its outermost part", () => {
const spec = { bossHeightMm: 5 };
expect(portOuterFootprintMm('luer-lock', spec)).toBe(portFootprintMm('luer-lock', spec));
});
});

describe('bossFilletMm', () => {
it('takes 0 at its word, where specNumber would treat it as unset', () => {
expect(bossFilletMm('syringe', { bossHeightMm: 2, bossFilletMm: 0 })).toBe(0);
Expand Down
17 changes: 17 additions & 0 deletions src/lib/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,23 @@ export function portFootprintMm(portType: PortType, spec: Record<string, unknown
}
}

/** The wall printed around a port's opening to make its boss or pedestal, mm. */
export const PORT_BOSS_WALL_MM = 0.8;

/**
* The outer diameter of what a port actually prints on the chip face, mm.
*
* `portFootprintMm` is the opening; a port with a boss or pedestal adds
* `PORT_BOSS_WALL_MM` of wall all round it, and that wall is what reaches the
* chip edge first. A luer lock's footprint is already its outermost part, the
* thread, so it is unchanged.
*/
export function portOuterFootprintMm(portType: PortType, spec: Record<string, unknown> = {}): number {
const footprint = portFootprintMm(portType, spec);
if (portType === 'luer-lock') return footprint;
return specNumber(spec, 'bossHeightMm', 0) > 0 ? footprint + 2 * PORT_BOSS_WALL_MM : footprint;
}

/** Fresh `customSpec` for a port type, keeping any values the user already set. */
export function portSpecFor(
portType: PortType,
Expand Down
13 changes: 8 additions & 5 deletions src/lib/validation/manufacturability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { distance } from '../geometry';
import { nodeFootprintMm } from '../graphOps';
import { getLoft } from '../loft';
import { LUER_TAPER, specNumber } from '../ports';
import { LUER_TAPER, portOuterFootprintMm, specNumber } from '../ports';
import type { Warning } from './types';
import type { ChannelNode, Point2 } from '../../types/project';
import type { ValidationInput } from './types';
Expand Down Expand Up @@ -117,16 +117,19 @@ export function checkMinimumFeature(input: ValidationInput): Warning[] {
/**
* Clearance from a channel to the chip boundary.
*
* Ports are measured on their boss footprint, not their bore — a luer boss is
* 4.3 mm across where the bore is 1.6 mm, and it is the boss that runs out of
* material at the edge.
* Ports are measured on everything they print, not their bore: a luer slip's
* boss is 5.9 mm across (its 4.27 mm taper mouth plus the boss wall) where the
* bore is 1.6 mm, and it is the boss that runs out of material at the edge.
*/
export function checkBoundaryClearance(input: ValidationInput): Warning[] {
const { graph, chipParams } = input;
const loft = getLoft(graph, chipParams);
const warnings: Warning[] = [];

const footprintOf = (node: ChannelNode): number => nodeFootprintMm(node, chipParams);
const footprintOf = (node: ChannelNode): number =>
node.type === 'port'
? portOuterFootprintMm(node.portType, node.customSpec)
: nodeFootprintMm(node, chipParams);

for (const node of graph.nodes) {
const radius = footprintOf(node) / 2;
Expand Down
27 changes: 27 additions & 0 deletions src/lib/validation/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,33 @@ describe('acceptance fixture — all three categories appear', () => {
// ---------------------------------------------------------------------------

describe('manufacturability', () => {
it('measures a port to the chip edge by its boss, not just its opening', () => {
// A luer slip opening is 4.27 mm across; its boss adds 0.8 mm all round.
// 5.5 mm in, the opening clears the 3 mm edge spacing and the boss does not.
const withBoss = (bossHeightMm: number): ChannelNode => ({
id: 'a',
x: 5.5,
y: 30,
type: 'port',
portType: 'luer',
diameterMm: 1.6,
customSpec: { bossHeightMm },
});
const run = (a: ChannelNode) =>
runValidation({
graph: { nodes: [a, port('b', 30, 30)], edges: [edge('e', 'a', 'b')] },
chipParams: base(),
printerProfile: printer,
});

const boundary = (warnings: Warning[]) =>
warnings.find((w) => w.kind === 'channel-near-boundary' && w.target.nodeIds.includes('a'));
const flagged = boundary(run(withBoss(3.5)).warnings);
assert.ok(flagged, 'the boss runs within the edge spacing');
assert.ok(Math.abs(flagged.measurement!.value - (5.5 - (4.27 + 1.6) / 2)) < 1e-6);
assert.ok(!boundary(run(withBoss(0)).warnings), 'with no boss, only the opening counts');
});

it('measures the narrowest point, not the average', () => {
const graph: ChannelGraph = {
nodes: [port('a', 10, 10), port('b', 30, 10)],
Expand Down
20 changes: 20 additions & 0 deletions src/store/validationStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_VALIDATION_SETTINGS } from '../lib/validation';
import { restoreSettings } from './validationStore';

describe('restoreSettings', () => {
it('keeps thresholds a user calibrated in an earlier session', () => {
expect(restoreSettings({ ...DEFAULT_VALIDATION_SETTINGS, minPrintableWallMm: 0.3 })).toEqual({
...DEFAULT_VALIDATION_SETTINGS,
minPrintableWallMm: 0.3,
});
});

it('fills anything missing or unusable from the defaults', () => {
expect(
restoreSettings({ minPrintableWallMm: Number.NaN, overhangLimitDeg: -5, taperRatioThreshold: '3' }),
).toEqual(DEFAULT_VALIDATION_SETTINGS);
expect(restoreSettings(undefined)).toEqual(DEFAULT_VALIDATION_SETTINGS);
expect(restoreSettings('garbage')).toEqual(DEFAULT_VALIDATION_SETTINGS);
});
});
65 changes: 50 additions & 15 deletions src/store/validationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,32 @@
* The warnings themselves are derived from project state and live in
* `useValidation` — only the *focus* is stateful, because it has to survive a
* jump between the 2D and 3D views so both can highlight the same element.
*
* The thresholds persist in this browser, like the app settings: a wall value
* measured off a test print should not have to be typed in again every
* session. They are not part of the project file — they describe the
* printer-and-resin you calibrated, not the chip.
*/

import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { CHECK_SETTINGS_STORAGE_KEY, safeStorage } from '../lib/persistence';
import { DEFAULT_VALIDATION_SETTINGS, type ValidationSettings, type Warning } from '../lib/validation';

/**
* Stored thresholds over the defaults, keeping only positive finite numbers —
* a hand-edited or older entry can never feed a check `NaN` or a missing key.
*/
export function restoreSettings(stored: unknown): ValidationSettings {
const restored: ValidationSettings = { ...DEFAULT_VALIDATION_SETTINGS };
if (!stored || typeof stored !== 'object') return restored;
for (const key of Object.keys(restored) as (keyof ValidationSettings)[]) {
const value = (stored as Record<string, unknown>)[key];
if (typeof value === 'number' && Number.isFinite(value) && value > 0) restored[key] = value;
}
return restored;
}

interface ValidationState {
/** Id of the warning the user clicked, or null. */
focusedId: string | null;
Expand All @@ -26,20 +47,34 @@ interface ValidationState {
resetSettings: () => void;
}

export const useValidationStore = create<ValidationState>()((set) => ({
focusedId: null,
focusedTarget: null,
focus: (warning) =>
set({
focusedId: warning?.id ?? null,
focusedTarget: warning?.target ?? null,
}),
export const useValidationStore = create<ValidationState>()(
persist(
(set) => ({
focusedId: null,
focusedTarget: null,
focus: (warning) =>
set({
focusedId: warning?.id ?? null,
focusedTarget: warning?.target ?? null,
}),

panelOpen: false,
setPanelOpen: (panelOpen) => set({ panelOpen }),
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),
panelOpen: false,
setPanelOpen: (panelOpen) => set({ panelOpen }),
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),

settings: DEFAULT_VALIDATION_SETTINGS,
setSettings: (patch) => set((state) => ({ settings: { ...state.settings, ...patch } })),
resetSettings: () => set({ settings: DEFAULT_VALIDATION_SETTINGS }),
}));
settings: DEFAULT_VALIDATION_SETTINGS,
setSettings: (patch) => set((state) => ({ settings: { ...state.settings, ...patch } })),
resetSettings: () => set({ settings: DEFAULT_VALIDATION_SETTINGS }),
}),
{
name: CHECK_SETTINGS_STORAGE_KEY,
storage: createJSONStorage(() => safeStorage),
// Only the thresholds persist; focus and the panel are per session.
partialize: (state) => ({ settings: state.settings }) as ValidationState,
merge: (persisted, current) => ({
...current,
settings: restoreSettings((persisted as Partial<ValidationState> | undefined)?.settings),
}),
},
),
);
Loading