diff --git a/src/components/cad/CadToolbar.tsx b/src/components/cad/CadToolbar.tsx index 0ee4a3a..34d3dba 100644 --- a/src/components/cad/CadToolbar.tsx +++ b/src/components/cad/CadToolbar.tsx @@ -36,6 +36,8 @@ export function CadToolbar({ viewport }: { viewport: RefObject 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'; @@ -100,12 +102,14 @@ export function CadToolbar({ viewport }: { viewport: RefObject diff --git a/src/lib/cad/portGeometry.ts b/src/lib/cad/portGeometry.ts index 13959d2..2b0301c 100644 --- a/src/lib/cad/portGeometry.ts +++ b/src/lib/cad/portGeometry.ts @@ -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 = @@ -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': @@ -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 @@ -411,7 +417,7 @@ export function buildPort( ); if (shaft) cavity.push(shaft); - bossRadius = large + 0.8; + bossRadius = large + PORT_BOSS_WALL_MM; break; } @@ -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; } @@ -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; } @@ -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; } } diff --git a/src/lib/fluids/fluids.test.ts b/src/lib/fluids/fluids.test.ts index e459c4d..5d5e621 100644 --- a/src/lib/fluids/fluids.test.ts +++ b/src/lib/fluids/fluids.test.ts @@ -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'); diff --git a/src/lib/fluids/properties.ts b/src/lib/fluids/properties.ts index 6ba90f1..12af9ca 100644 --- a/src/lib/fluids/properties.ts +++ b/src/lib/fluids/properties.ts @@ -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', }), diff --git a/src/lib/persistence.ts b/src/lib/persistence.ts index e53dc6b..d6ffab3 100644 --- a/src/lib/persistence.ts +++ b/src/lib/persistence.ts @@ -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 diff --git a/src/lib/ports.test.ts b/src/lib/ports.test.ts index db5fa87..40ab185 100644 --- a/src/lib/ports.test.ts +++ b/src/lib/ports.test.ts @@ -6,6 +6,9 @@ import { PORT_TYPES, bossFilletMm, matchFitting, + portFootprintMm, + portOuterFootprintMm, + PORT_BOSS_WALL_MM, portSpecFor, taperRatioOf, type FittingPreset, @@ -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); diff --git a/src/lib/ports.ts b/src/lib/ports.ts index 2c4e3c5..039a4e2 100644 --- a/src/lib/ports.ts +++ b/src/lib/ports.ts @@ -445,6 +445,23 @@ export function portFootprintMm(portType: PortType, spec: Record = {}): 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, diff --git a/src/lib/validation/manufacturability.ts b/src/lib/validation/manufacturability.ts index ab40cdb..bbc7d81 100644 --- a/src/lib/validation/manufacturability.ts +++ b/src/lib/validation/manufacturability.ts @@ -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'; @@ -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; diff --git a/src/lib/validation/validation.test.ts b/src/lib/validation/validation.test.ts index 811fc36..a1110a0 100644 --- a/src/lib/validation/validation.test.ts +++ b/src/lib/validation/validation.test.ts @@ -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)], diff --git a/src/store/validationStore.test.ts b/src/store/validationStore.test.ts new file mode 100644 index 0000000..5941ec7 --- /dev/null +++ b/src/store/validationStore.test.ts @@ -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); + }); +}); diff --git a/src/store/validationStore.ts b/src/store/validationStore.ts index a9e3c23..28d1e9e 100644 --- a/src/store/validationStore.ts +++ b/src/store/validationStore.ts @@ -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)[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; @@ -26,20 +47,34 @@ interface ValidationState { resetSettings: () => void; } -export const useValidationStore = create()((set) => ({ - focusedId: null, - focusedTarget: null, - focus: (warning) => - set({ - focusedId: warning?.id ?? null, - focusedTarget: warning?.target ?? null, - }), +export const useValidationStore = create()( + 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 | undefined)?.settings), + }), + }, + ), +);