From f901466a60e78f7409bef3ec98867a621081f95a Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Tue, 8 Sep 2026 23:45:54 -0700 Subject: [PATCH 01/57] pid-designer: fix the three bugs that made the canvas feel broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate faults, all reported as "it doesn't work": **The palette dropped nothing, and Take needed pressing twice.** Taking the checkout reloads the diagram before flipping `held`, so the canvas remounts while it is still read-only. Every handler captured `readOnly === true` at that moment and kept it, because none of them listed it as a dependency. Whether a handler ever got a corrected copy came down to whether some unrelated dependency happened to change afterwards -- `onDrop` was rebuilt when `onInit` set the ReactFlow instance, and won or lost that race -- which is why the workaround going round the team was take, release, take again. Guards now read `readOnlyRef.current`, which cannot be a render behind the chip that says you are editing. `onDrop` also bailed on a null `rfInst` for the frame or two after the remount: precisely when someone has just enabled editing and is reaching for the palette. It takes `screenToFlowPosition` from the provider instead, which is there from first render. **Some connections drew nothing -- and were saved anyway.** `ConnectionMode. Loose` relaxes two of the three places React Flow consults a handle's type, not the third: `getEdgePosition` resolves an edge's target end against `target ∪ source` but its source end against `source` alone. Dragging from a `target` port onto another `target` port puts the port you dropped on in the source slot, where it cannot be found, so the edge went into state and into autosave and never rendered. Top-of-tank to top-of-QD was the reported case. Every port is now declared a source (`nodes/Port.tsx`), which deletes the failing quadrant rather than validating against it -- and is what a P&ID means anyway, since a pipe has no direction on a drawing. **New nodes could collide with existing ones.** The id counter was module-level and restarted at 1 each page load while saved ids did not, so adding a node to a diagram holding `node_1…node_9` minted `node_1` again -- two components a reader cannot tell apart, and node ids are the tags feed-twin will key on. `ids.ts` seeds the counters from whatever was loaded, on every path that replaces the canvas. The gating audit gains a third check, that handlers guard on the ref rather than the closure; it fails against the code before this commit. --- .../src/components/pid/BranchableEdge.tsx | 6 +- .../src/components/pid/PIDDesigner.tsx | 86 ++++++++++++------- .../frontend/src/components/pid/ids.ts | 45 ++++++++++ .../components/pid/nodes/CheckValveNode.tsx | 7 +- .../src/components/pid/nodes/JunctionNode.tsx | 11 +-- .../src/components/pid/nodes/PRNode.tsx | 7 +- .../src/components/pid/nodes/Port.tsx | 45 ++++++++++ .../src/components/pid/nodes/QDNode.tsx | 11 +-- .../src/components/pid/nodes/RVNode.tsx | 7 +- .../src/components/pid/nodes/SensorNode.tsx | 11 +-- .../src/components/pid/nodes/TankNode.tsx | 11 +-- .../src/components/pid/nodes/ValveNode.tsx | 7 +- pid-designer/frontend/src/lib/gating.test.ts | 25 +++++- 13 files changed, 209 insertions(+), 70 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/ids.ts create mode 100644 pid-designer/frontend/src/components/pid/nodes/Port.tsx diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx index ac82853c5..72cca3461 100644 --- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx +++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx @@ -9,9 +9,7 @@ import { type Edge, } from '@xyflow/react'; import { FLUID_COLORS, type FluidType } from './types'; - -let _jid = 1; -const jid = () => `junc_${_jid++}`; +import { nextJunctionId } from './ids'; const J_HALF = 5; @@ -65,7 +63,7 @@ export function BranchableEdge(props: EdgeProps) { if (!dot) return; e.stopPropagation(); - const junctionId = jid(); + const junctionId = nextJunctionId(); const junctionNode = { id: junctionId, type: 'JUNCTION', diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index 479771890..f549bf6a2 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -8,6 +8,7 @@ import { addEdge, useNodesState, useEdgesState, + useReactFlow, BackgroundVariant, SelectionMode, ConnectionMode, @@ -29,6 +30,7 @@ import { designApi, keyOf, refOf } from '../../api/diagrams'; import type { DiagramMeta, DocRef, MicroVersion, ReleaseVersion, Snapshot } from '../../api/diagrams'; import { nodeTypes } from './nodes'; import { BranchableEdge } from './BranchableEdge'; +import { nextNodeId, seedIdsFrom } from './ids'; import { FLUID_COLORS, COMPONENT_DEFS } from './types'; import type { PIDNodeData, ComponentType, FluidType } from './types'; @@ -71,8 +73,6 @@ function writeActive(ref: DocRef | null): void { } } -let _idCounter = 1; -const genId = () => `node_${_idCounter++}`; function defaultLabel(type: ComponentType) { return COMPONENT_DEFS.find(d => d.type === type)?.label ?? type; @@ -160,7 +160,7 @@ function PIDCanvas({ const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edgeMenu, setEdgeMenu] = useState<{ id: string; x: number; y: number } | null>(null); - const [rfInst, setRfInst] = useState(null); + const { screenToFlowPosition } = useReactFlow(); const { undo, redo } = useHistory(nodes, edges, setNodes, setEdges); @@ -176,6 +176,20 @@ function PIDCanvas({ // read the same flag through context, because TextNode and DraggableLabel // edit via useReactFlow().setNodes and never touch these props. const readOnly = useReadOnly(); + // Every *guard* below reads the flag through this ref, never through the + // closure. Taking the checkout used to be a coin flip because of that + // difference: `take()` reloads the canvas before it flips `held`, so this + // component remounts while it is still read-only, and each handler captured + // `readOnly === true` at that moment. Whether it ever got a corrected copy + // depended on whether an unrelated dependency happened to change afterwards + // -- `onDrop` was rebuilt when `onInit` set the ReactFlow instance, and won + // or lost the race against the state commit. Hence "you have to take, release, + // then take again", and a palette that dropped nothing on a fresh page. + // + // A ref cannot go stale, so the guards cannot disagree with the chip in the + // diagram bar, and a handler added later inherits that for free. + const readOnlyRef = useRef(readOnly); + readOnlyRef.current = readOnly; // JSON of the last payload actually sent, so a change that survives neither // `toStored` nor a content comparison never reaches the server. Without it the // debounce fires on every ReactFlow state identity change -- including pure @@ -190,6 +204,7 @@ function PIDCanvas({ .then(data => { if (cancelled) return; const loaded = { nodes: data?.nodes ?? [], edges: data?.edges ?? [] }; + seedIdsFrom(loaded.nodes); setNodes(loaded.nodes); setEdges(loaded.edges); // Seed the guard with what we just loaded, so opening a diagram does not @@ -206,7 +221,7 @@ function PIDCanvas({ useEffect(() => { // No checkout, no autosave. The canvas is inert in that state anyway; // this is the belt to that pair of braces. - if (loadedId.current !== diagramKey || readOnly) return; + if (loadedId.current !== diagramKey || readOnlyRef.current) return; const serialized = JSON.stringify(api.toStored({ nodes, edges })); if (serialized === lastSaved.current) return; const t = setTimeout(() => { @@ -230,7 +245,7 @@ function PIDCanvas({ useEffect(() => { const flush = () => { // A beacon cannot read a rejection, so gate it here instead. - if (loadedId.current !== diagramKey || readOnly) return; + if (loadedId.current !== diagramKey || readOnlyRef.current) return; api.flushDiagram(diagramRef, snapshot.current); }; const onVisibility = () => { if (document.visibilityState === 'hidden') flush(); }; @@ -244,7 +259,7 @@ function PIDCanvas({ useEffect(() => { const handler = (e: KeyboardEvent) => { - if (readOnly) return; + if (readOnlyRef.current) return; if (e.key.toLowerCase() === 'r' && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { setNodes(nds => nds.map(n => n.selected @@ -262,17 +277,18 @@ function PIDCanvas({ // belt to that pair of braces, and they also cover the keyboard shortcuts. getRef.current = useCallback(() => ({ nodes, edges }), [nodes, edges]); loadRef.current = useCallback((d) => { - if (readOnly) return; + if (readOnlyRef.current) return; + seedIdsFrom(d.nodes); setNodes(d.nodes); setEdges(d.edges); - }, [readOnly, setNodes, setEdges]); + }, [setNodes, setEdges]); clearRef.current = useCallback(() => { - if (readOnly) return; + if (readOnlyRef.current) return; setNodes([]); setEdges([]); - }, [readOnly, setNodes, setEdges]); - undoRef.current = useCallback(() => { if (!readOnly) undo(); }, [readOnly, undo]); - redoRef.current = useCallback(() => { if (!readOnly) redo(); }, [readOnly, redo]); + }, [setNodes, setEdges]); + undoRef.current = useCallback(() => { if (!readOnlyRef.current) undo(); }, [undo]); + redoRef.current = useCallback(() => { if (!readOnlyRef.current) redo(); }, [redo]); releaseRef.current = useCallback( (label: string) => api.createRelease(diagramRef, label, { nodes, edges }), @@ -290,28 +306,29 @@ function PIDCanvas({ ); restoreMicroRef.current = useCallback(async (versionId: string) => { - if (readOnly) return; + if (readOnlyRef.current) return; const data = await api.getVersion(diagramRef, versionId); + seedIdsFrom(data.nodes); setNodes(data.nodes); setEdges(data.edges); - }, [readOnly, diagramKey, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps + }, [diagramKey, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps restoreReleaseRef.current = useCallback(async (label: string) => { - if (readOnly) return; + if (readOnlyRef.current) return; const data = await api.getRelease(diagramRef, label); + seedIdsFrom(data.nodes); setNodes(data.nodes); setEdges(data.edges); - }, [readOnly, diagramKey, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps + }, [diagramKey, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps - const onInit = useCallback((inst: ReactFlowInstance) => { - setRfInst(inst); - onInstance(inst); - }, [onInstance]); + // Handed up so the toolbar can fitView and export. Nothing in this component + // needs it -- see `screenToFlowPosition` above. + const onInit = useCallback((inst: ReactFlowInstance) => onInstance(inst), [onInstance]); const edgeTypes = useMemo(() => ({ smoothstep: BranchableEdge, default: BranchableEdge }), []); const onConnect = useCallback((params: Connection) => { - if (readOnly) return; + if (readOnlyRef.current) return; setEdges(eds => addEdge({ ...params, type: 'smoothstep', @@ -326,45 +343,50 @@ function PIDCanvas({ }; const onDrop = useCallback((e: React.DragEvent) => { - if (readOnly) return; + if (readOnlyRef.current) return; e.preventDefault(); const type = e.dataTransfer.getData('application/pid-type') as ComponentType; - if (!type || !rfInst) return; + if (!type) return; const nodeH = (type === 'TANK' || type === 'INJECTOR') ? 100 : 60; - const flowPos = rfInst.screenToFlowPosition({ x: e.clientX, y: e.clientY }); + // From the provider, not from the `onInit` instance in state. Taking the + // checkout remounts this canvas, and for the frame or two before `onInit` + // has committed, that state is null -- so the palette silently dropped + // nothing during exactly the moment a user has just enabled editing and is + // reaching for it. The hook is available from first render. + const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); const position = { x: flowPos.x - 30, y: flowPos.y - nodeH / 2 }; const nodeData = type === 'TEXT' ? { text: 'Text' } : type === 'JUNCTION' ? {} : { componentType: type, label: defaultLabel(type), fluidType: 'default' } as PIDNodeData; + // Allocated outside the updater: React invokes updaters twice in + // development, and an id minted inside one is neither pure nor stable. + const id = nextNodeId(); setNodes(nds => [...nds, { - id: genId(), + id, type, position, data: nodeData as unknown as Record, }]); - }, [rfInst, setNodes]); + }, [screenToFlowPosition, setNodes]); const onEdgeContextMenu = useCallback((e: React.MouseEvent, edge: Edge) => { - if (readOnly) return; + if (readOnlyRef.current) return; e.preventDefault(); e.stopPropagation(); setEdgeMenu({ id: edge.id, x: e.clientX, y: e.clientY }); }, []); const setEdgeFluid = useCallback((edgeId: string, fluid: FluidType) => { - if (readOnly) return; // recolouring an edge is an edit to the diagram + if (readOnlyRef.current) return; // recolouring an edge is an edit to the diagram setEdges(eds => eds.map(e => e.id === edgeId ? { ...e, style: { ...e.style, stroke: FLUID_COLORS[fluid], strokeWidth: 2 }, data: { ...e.data, fluidType: fluid } } : e, )); setEdgeMenu(null); - }, [readOnly, setEdges]); - - // Suppress unused warning — rfInst used for onInit side-effect - void rfInst; + }, [setEdges]); return (
setEdgeMenu(null)}> diff --git a/pid-designer/frontend/src/components/pid/ids.ts b/pid-designer/frontend/src/components/pid/ids.ts new file mode 100644 index 000000000..b3216ea1e --- /dev/null +++ b/pid-designer/frontend/src/components/pid/ids.ts @@ -0,0 +1,45 @@ +import type { Node } from '@xyflow/react'; + +/** + * Ids for newly drawn nodes. + * + * These are not cosmetic. A node id is the tag a reader keys on -- `feedtwin`'s + * network nodes are "the tags on the P&ID" -- so two nodes sharing one is not a + * rendering glitch, it is two components that a solver cannot tell apart. + * + * The counters used to be module-level and started at 1 on every page load, + * while the ids already in a saved diagram did not. Open a diagram holding + * `node_1 … node_9`, drop one more, and it was `node_1` again: React Flow keys + * on id, so the new symbol and the old one became the same element, and the + * duplicate went out in the next autosave. + * + * Seeding from what was actually loaded is what makes that impossible. The + * counters are shared across diagrams on purpose -- ids only have to be unique + * within one, and a monotonic counter that never rewinds is the cheapest way to + * stay ahead of every diagram this tab has opened. + */ + +let _node = 0; +let _junction = 0; + +const NODE_RE = /^node_(\d+)$/; +const JUNCTION_RE = /^junc_(\d+)$/; + +function highest(ids: string[], re: RegExp): number { + let max = 0; + for (const id of ids) { + const m = re.exec(id); + if (m) max = Math.max(max, Number(m[1])); + } + return max; +} + +/** Advance the counters past everything in a freshly loaded diagram. */ +export function seedIdsFrom(nodes: Node[]): void { + const ids = nodes.map(n => n.id); + _node = Math.max(_node, highest(ids, NODE_RE)); + _junction = Math.max(_junction, highest(ids, JUNCTION_RE)); +} + +export const nextNodeId = () => `node_${++_node}`; +export const nextJunctionId = () => `junc_${++_junction}`; diff --git a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx index 1c099372e..cb643d8e7 100644 --- a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx @@ -1,4 +1,5 @@ -import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { Position, type NodeProps } from '@xyflow/react'; +import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; @@ -9,8 +10,8 @@ export function CheckValveNode({ id, data, selected }: NodeProps) { const stroke = selected ? '#3b82f6' : '#94a3b8'; return (
- - + + diff --git a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx index 609431514..3e98ba6de 100644 --- a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx @@ -1,4 +1,5 @@ -import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { Position, type NodeProps } from '@xyflow/react'; +import { Port } from './Port'; export function JunctionNode(_props: NodeProps) { const handleStyle = { @@ -21,10 +22,10 @@ export function JunctionNode(_props: NodeProps) { }} className="nodrag" > - - - - + + + +
); } diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index 38b7b7880..f1fb65f3b 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -1,4 +1,5 @@ -import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { Position, type NodeProps } from '@xyflow/react'; +import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; @@ -10,8 +11,8 @@ export function PRNode({ id, data, selected }: NodeProps) { return (
- - + + `. + * + * A pipe has no direction on a drawing -- a tank's top port feeds a vent line + * as readily as it accepts a fill line -- so the canvas runs in + * `ConnectionMode.Loose`, where any port may connect to any other. Loose mode + * relaxes two of the three places the handle type is consulted, and not the + * third: `getEdgePosition` resolves an edge's *target* end against + * `handleBounds.target ∪ handleBounds.source`, but its *source* end against + * `handleBounds.source` alone. + * + * So when a drag starts on a `target` port and ends on another `target` port, + * React Flow builds the edge with the port you dropped on as its `source`, + * fails to find that id among the source handles, and returns null. The edge is + * added to state and autosaved, and never draws. It is not a refused + * connection -- it is an invisible one, which is worse, because the graph a + * reader (or feed-twin) parses then has a branch nobody can see. + * + * Declaring every port a source removes the failing quadrant outright: no edge + * can ever land a target-typed handle in the source slot, because there are no + * target-typed handles. Loose mode covers the other end. + */ +export function Port({ + id, + position, + style, + ...rest +}: { id: string; position: Position } & Omit & { + style?: React.CSSProperties; +}) { + return ( + + ); +} diff --git a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx index c17486b82..58a8b222b 100644 --- a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx @@ -1,4 +1,5 @@ -import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { Position, type NodeProps } from '@xyflow/react'; +import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; @@ -10,10 +11,10 @@ export function QDNode({ id, data, selected }: NodeProps) { return (
- - - - + + + + diff --git a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx index deb9f5f4b..571b5491b 100644 --- a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx @@ -1,4 +1,5 @@ -import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { Position, type NodeProps } from '@xyflow/react'; +import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; @@ -10,8 +11,8 @@ export function RVNode({ id, data, selected }: NodeProps) { return (
- - + + - - - - + + + + - - + + - - + + - - + + {componentType === 'MAN' ? : } diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts index 3c7467b73..f28240e47 100644 --- a/pid-designer/frontend/src/lib/gating.test.ts +++ b/pid-designer/frontend/src/lib/gating.test.ts @@ -16,6 +16,13 @@ * 2. The ReactFlow props that make the canvas interactive, and the handlers * that rewrite the diagram, are all derived from `readOnly` -- because * those are not controls at all and no `disabled` audit would see them. + * 3. Those handlers read it through `readOnlyRef`, not through the closure. + * + * (3) is not style. Taking the checkout remounts the canvas *before* `held` + * flips, so a handler that captured `readOnly` captured `true` and kept it: + * the palette dropped nothing, and the advice going round the team was to take + * the diagram, release it, and take it again. A guard on a ref cannot be one + * render behind the chip that claims you are editing. */ import { describe, expect, it } from 'vitest' @@ -131,7 +138,7 @@ describe('every diagram-editing control is gated on the checkout', () => { if (name in NOT_EDITING) continue for (const tag of ['button', 'input', 'select', 'textarea']) { for (const text of openingTags(src, tag)) { - if (/\breadOnly\b/.test(text)) continue + if (/\breadOnly(Ref\.current)?\b/.test(text)) continue if (excuseFor(name, text)) continue offenders.push(`${name} ${text.replace(/\s+/g, ' ').slice(0, 100)}`) } @@ -150,7 +157,7 @@ describe('every diagram-editing control is gated on the checkout', () => { const at = src!.indexOf(name) if (at === -1) return true const window = src!.slice(at, at + 400) - return !/\breadOnly\b/.test(window) + return !/\breadOnly(Ref\.current)?\b/.test(window) }) expect( ungated, @@ -158,6 +165,20 @@ describe('every diagram-editing control is gated on the checkout', () => { ).toEqual([]) }) + it('guards handlers on the ref, so none can be a render behind the chip', () => { + const src = Object.entries(files).find(([p]) => p.endsWith('/PIDDesigner.tsx'))?.[1] + expect(src, 'PIDDesigner.tsx not found').toBeTruthy() + + // `if (readOnly)` / `if (!readOnly)` / `|| readOnly` inside a handler body. + // The bare value is correct in JSX props, which re-read it every render; + // it is wrong in anything that outlives one. + const stale = [...src!.matchAll(/if\s*\([^)]*\breadOnly\b(?!Ref)[^)]*\)/g)].map((m) => m[0]) + expect( + stale, + `guarded on the closure instead of readOnlyRef.current:\n${stale.join('\n')}`, + ).toEqual([]) + }) + it('keeps every exemption pointing at a real file', () => { const stale = Object.keys(NOT_EDITING).filter( (file) => !Object.keys(files).some((p) => p.endsWith(`/${file}`)), From 37926a5352a0f75a8804ee2aabd5c04dd79d7883 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Tue, 8 Sep 2026 23:54:33 -0700 Subject: [PATCH 02/57] pid-designer: give every component a config, in feed-twin's parameter shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-click a symbol and set what it actually is: tank pressure and temperature, regulator setpoint and droop, relief set and reseat, check valve cracking pressure, chamber pressure and injector drop. The request was "anything that has a pressure setting should have a config", so the answer is a table rather than a dialog per component -- `spec.ts` declares what each kind of hardware has and `ConfigDialog` renders whatever it finds, which is the same decision `feedtwin/model/components.toml` makes and keeps that promise true after the next four components land. A value is stored as `{value, unit, source, reference}`, which is `feedtwin.model.Param` verbatim, so the Phase-11 reader lifts numbers across rather than re-typing them. Two consequences are deliberate: - **Provenance is a field.** A number with no stated source records as `default`, which reads as "nobody has looked", not as agreement. It is what lets a run report separate the nine measured inputs from the two guesses. - **There is no psig.** Gauge is a reference, not a unit, and a psig value stored as psi is one atmosphere low everywhere downstream, silently. The units offered are exactly those `feedtwin.model.units` registers, and every pressure field says "absolute, not gauge" beside it. A blank field stays absent rather than becoming zero, so an unfilled parameter never passes for a measured nought. New in the palette: - **Engine** — injector and chamber as one symbol. Two were the wrong seam: the face is where the feed system ends, the Pc behind it is the boundary the feed runs against, and the pair is built, tested and replaced together. - **Manifold** — one feed in, a configurable number of ports out, so a tank feeding eight things is a block with ports rather than eight edges leaving one pixel. - **Tanks take a port count per end**, for the same reason: a real lid carries pressurant, vent, burst disc and instrumentation. - **Transducers and QDs split into two entries each** — high/low press, and ground/rocket half. Calling both halves "QD" hides the one mistake that matters, and the QD side is what the pairing check will read. Two states are now drawn rather than only stored: a valve's NO/NC, because which way it fails is what a procedure review looks for first, and a dome regulator's control port, because a line run to the dome by mistake is a regulator held at whatever that line happens to be. --- .../src/components/pid/ComponentPalette.tsx | 30 +- .../src/components/pid/ConfigDialog.tsx | 256 ++++++++++++++++++ .../src/components/pid/PIDDesigner.tsx | 70 ++++- .../src/components/pid/nodes/EngineNode.tsx | 57 ++++ .../src/components/pid/nodes/ManifoldNode.tsx | 72 +++++ .../src/components/pid/nodes/PRNode.tsx | 16 +- .../src/components/pid/nodes/TankNode.tsx | 28 +- .../src/components/pid/nodes/ValveNode.tsx | 19 +- .../src/components/pid/nodes/index.ts | 4 + .../frontend/src/components/pid/params.ts | 65 +++++ .../frontend/src/components/pid/spec.ts | 241 +++++++++++++++++ .../frontend/src/components/pid/types.ts | 73 +++-- pid-designer/frontend/src/lib/gating.test.ts | 1 + 13 files changed, 895 insertions(+), 37 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/ConfigDialog.tsx create mode 100644 pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx create mode 100644 pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx create mode 100644 pid-designer/frontend/src/components/pid/params.ts create mode 100644 pid-designer/frontend/src/components/pid/spec.ts diff --git a/pid-designer/frontend/src/components/pid/ComponentPalette.tsx b/pid-designer/frontend/src/components/pid/ComponentPalette.tsx index de2d489f3..bd191b11a 100644 --- a/pid-designer/frontend/src/components/pid/ComponentPalette.tsx +++ b/pid-designer/frontend/src/components/pid/ComponentPalette.tsx @@ -1,6 +1,6 @@ import { COMPONENT_DEFS, type ComponentType } from './types'; -const GROUP_ORDER = ['Sensors', 'Valves', 'Flow Control', 'Hardware'] as const; +const GROUP_ORDER = ['Sensors', 'Valves', 'Flow Control', 'Hardware', 'Annotation'] as const; function PaletteSymbol({ type }: { type: ComponentType }) { switch (type) { @@ -72,6 +72,23 @@ function PaletteSymbol({ type }: { type: ComponentType }) { ); + case 'ENGINE': + return ( + + + + + + + ); + case 'MANIFOLD': + return ( + + + + {[9, 17, 25].map(x => )} + + ); case 'JUNCTION': return ( @@ -96,8 +113,11 @@ export function ComponentPalette() { items: COMPONENT_DEFS.filter(d => d.group === group), })); - const onDragStart = (e: React.DragEvent, type: ComponentType) => { - e.dataTransfer.setData('application/pid-type', type); + // The *entry* id travels, not the component type. "PT (high press)" and + // "PT (low press)" are one component with different presets, and the drop + // handler needs to know which of the two was picked. + const onDragStart = (e: React.DragEvent, id: string) => { + e.dataTransfer.setData('application/pid-entry', id); e.dataTransfer.effectAllowed = 'copy'; }; @@ -111,9 +131,9 @@ export function ComponentPalette() {

{group}

{items.map(def => (
onDragStart(e, def.type)} + onDragStart={e => onDragStart(e, def.id)} title={def.fullName} className="flex items-center gap-2 px-2 py-1.5 rounded cursor-grab active:cursor-grabbing hover:bg-[#1e293b] transition-colors" > diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx new file mode 100644 index 000000000..72ab9c640 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from 'react'; +import { Modal } from '../ui'; +import { btn, primaryBtn } from '../../lib/ui'; +import { COMPONENT_SPECS } from './spec'; +import type { OptionSpec, ParamSpec } from './spec'; +import { PROVENANCE_LABELS, UNITS, ABSOLUTE_NOTE } from './params'; +import type { ParamValue, Provenance } from './params'; +import type { ComponentType, PIDNodeData } from './types'; + +/** + * The config behind a double-click on a symbol. + * + * One dialog for every component, driven entirely by `spec.ts`. Adding a + * pressure setting to a relief valve is a row in that table, not a new + * component here -- which is the only way "everything with a pressure gets a + * config" stays true a term from now. + * + * Two things it insists on, both inherited from what the numbers are for: + * + * - **Provenance is a field, not an afterthought.** A value with no stated + * source is stored as `default`, which reads as "nobody has looked" rather + * than as agreement. It is the field that lets a run report separate the + * measured inputs from the guesses. + * - **Pressures are absolute.** The unit list has no psig, because gauge is a + * reference rather than a unit and a psig value stored as psi is one + * atmosphere low everywhere downstream. The field says so rather than + * silently accepting it. + * + * A blank value is not zero -- it is absent, and stays absent, so an unfilled + * field never masquerades as a measured nought. + */ + +interface Props { + open: boolean; + onClose: () => void; + nodeId: string; + data: PIDNodeData; + readOnly: boolean; + onSave: (patch: { params: Record; options: Record; label: string }) => void; +} + +type Draft = { value: string; unit: string; source: Provenance; reference: string }; + +const EMPTY: Draft = { value: '', unit: '', source: 'default', reference: '' }; + +function toDraft(spec: ParamSpec, existing?: ParamValue): Draft { + const units = UNITS[spec.dimension]; + if (existing) { + return { + value: String(existing.value), + unit: existing.unit || units[0], + source: existing.source, + reference: existing.reference ?? '', + }; + } + return { + ...EMPTY, + unit: spec.suggested?.unit ?? units[0], + value: spec.suggested ? String(spec.suggested.value) : '', + }; +} + +export function ConfigDialog({ open, onClose, data, readOnly, onSave }: Props) { + const type = data.componentType as ComponentType; + const spec = COMPONENT_SPECS[type]; + + const [label, setLabel] = useState(data.label ?? ''); + const [drafts, setDrafts] = useState>({}); + const [options, setOptions] = useState>({}); + + // Reset whenever a different symbol is opened, so a dialog never shows the + // last one's numbers under this one's name. + useEffect(() => { + if (!open || !spec) return; + setLabel(data.label ?? ''); + setDrafts(Object.fromEntries(spec.params.map(p => [p.key, toDraft(p, data.params?.[p.key])]))); + setOptions(Object.fromEntries( + (spec.options ?? []).map(o => [o.key, data.options?.[o.key] ?? o.default]), + )); + }, [open, data, spec]); + + if (!spec) return null; + + const setDraft = (key: string, patch: Partial) => + setDrafts(d => ({ ...d, [key]: { ...d[key], ...patch } })); + + const save = () => { + const params: Record = {}; + for (const p of spec.params) { + const d = drafts[p.key]; + if (!d || d.value.trim() === '') continue; // absent, not zero + const value = Number(d.value); + if (!Number.isFinite(value)) continue; + params[p.key] = { + value, + unit: d.unit, + source: d.source, + ...(d.reference.trim() ? { reference: d.reference.trim() } : {}), + }; + } + onSave({ params, options, label: label.trim() || data.label }); + onClose(); + }; + + return ( + + + +
+ } + > +
+ {spec.summary && ( +

{spec.summary}

+ )} + + + + {(spec.options ?? []).map(o => ( + setOptions(s => ({ ...s, [o.key]: v }))} + /> + ))} + + {spec.params.length > 0 && ( +
+ {spec.params.map(p => ( + setDraft(p.key, patch)} + /> + ))} +
+ )} +
+ + ); +} + +function OptionField({ spec, value, readOnly, onChange }: { + spec: OptionSpec; value: string; readOnly: boolean; onChange: (v: string) => void; +}) { + const free = spec.choices.length === 0; + return ( + + ); +} + +function ParamField({ spec, draft, readOnly, onChange }: { + spec: ParamSpec; draft: Draft; readOnly: boolean; onChange: (p: Partial) => void; +}) { + const units = UNITS[spec.dimension]; + const filled = draft.value.trim() !== ''; + return ( +
+
+ {spec.label} + {spec.dimension === 'pressure' && ( + {ABSOLUTE_NOTE} + )} +
+
+ onChange({ value: e.target.value })} + className="min-w-0 flex-1 rounded border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2 py-1 text-xs outline-none focus:border-[var(--color-accent)]" + /> + +
+ + {filled && ( +
+ + onChange({ reference: e.target.value })} + className="min-w-0 flex-1 rounded border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2 py-1 text-[11px] outline-none focus:border-[var(--color-accent)]" + /> +
+ )} + + {spec.description && ( +

{spec.description}

+ )} +
+ ); +} diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index f549bf6a2..60883e27c 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -31,8 +31,11 @@ import type { DiagramMeta, DocRef, MicroVersion, ReleaseVersion, Snapshot } from import { nodeTypes } from './nodes'; import { BranchableEdge } from './BranchableEdge'; import { nextNodeId, seedIdsFrom } from './ids'; -import { FLUID_COLORS, COMPONENT_DEFS } from './types'; -import type { PIDNodeData, ComponentType, FluidType } from './types'; +import { FLUID_COLORS, defFor } from './types'; +import type { PIDNodeData, FluidType } from './types'; +import { ConfigDialog } from './ConfigDialog'; +import { COMPONENT_SPECS } from './spec'; +import type { ParamValue } from './params'; export type InteractionMode = 'pan' | 'select'; @@ -74,10 +77,6 @@ function writeActive(ref: DocRef | null): void { } -function defaultLabel(type: ComponentType) { - return COMPONENT_DEFS.find(d => d.type === type)?.label ?? type; -} - // ── Undo / redo history ────────────────────────────────────────────────────── const MAX_HISTORY = 100; @@ -160,6 +159,9 @@ function PIDCanvas({ const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edgeMenu, setEdgeMenu] = useState<{ id: string; x: number; y: number } | null>(null); + // Which symbol's config is open. Held as an id rather than the node, so the + // dialog reads live data and a save is never applied to a stale copy. + const [configFor, setConfigFor] = useState(null); const { screenToFlowPosition } = useReactFlow(); const { undo, redo } = useHistory(nodes, edges, setNodes, setEdges); @@ -327,6 +329,8 @@ function PIDCanvas({ const edgeTypes = useMemo(() => ({ smoothstep: BranchableEdge, default: BranchableEdge }), []); + const configNode = configFor ? nodes.find(n => n.id === configFor) ?? null : null; + const onConnect = useCallback((params: Connection) => { if (readOnlyRef.current) return; setEdges(eds => addEdge({ @@ -345,9 +349,11 @@ function PIDCanvas({ const onDrop = useCallback((e: React.DragEvent) => { if (readOnlyRef.current) return; e.preventDefault(); - const type = e.dataTransfer.getData('application/pid-type') as ComponentType; - if (!type) return; - const nodeH = (type === 'TANK' || type === 'INJECTOR') ? 100 : 60; + const entry = e.dataTransfer.getData('application/pid-entry'); + const def = defFor(entry); + if (!def) return; + const type = def.type; + const nodeH = (type === 'TANK' || type === 'INJECTOR' || type === 'ENGINE') ? 100 : 60; // From the provider, not from the `onInit` instance in state. Taking the // checkout remounts this canvas, and for the frame or two before `onInit` // has committed, that state is null -- so the palette silently dropped @@ -359,7 +365,19 @@ function PIDCanvas({ ? { text: 'Text' } : type === 'JUNCTION' ? {} - : { componentType: type, label: defaultLabel(type), fluidType: 'default' } as PIDNodeData; + : { + componentType: type, + label: def.label, + fluidType: 'default', + // The palette entry's preset, plus every option's declared default, + // so a symbol is never drawn in a state its own config disagrees + // with -- a valve reads NC from the moment it lands, not once + // somebody opens the dialog. + options: { + ...Object.fromEntries((COMPONENT_SPECS[type]?.options ?? []).map(o => [o.key, o.default])), + ...(def.preset ?? {}), + }, + } as PIDNodeData; // Allocated outside the updater: React invokes updaters twice in // development, and an id minted inside one is neither pure nor stable. const id = nextNodeId(); @@ -371,6 +389,26 @@ function PIDCanvas({ }]); }, [screenToFlowPosition, setNodes]); + const onNodeDoubleClick = useCallback((_e: React.MouseEvent, node: Node) => { + const type = (node.data as unknown as PIDNodeData)?.componentType; + // Text and junctions have nothing to configure; opening an empty dialog on + // them would only teach people that double-click does nothing. + if (!type || !COMPONENT_SPECS[type]) return; + setConfigFor(node.id); + }, []); + + const saveConfig = useCallback(( + nodeId: string, + patch: { params: Record; options: Record; label: string }, + ) => { + if (readOnlyRef.current) return; + setNodes(nds => nds.map(n => ( + n.id === nodeId + ? { ...n, data: { ...n.data, label: patch.label, params: patch.params, options: patch.options } } + : n + ))); + }, [setNodes]); + const onEdgeContextMenu = useCallback((e: React.MouseEvent, edge: Edge) => { if (readOnlyRef.current) return; e.preventDefault(); @@ -396,6 +434,7 @@ function PIDCanvas({ onConnect={onConnect} onInit={onInit} onDrop={onDrop} onDragOver={onDragOver} onEdgeContextMenu={onEdgeContextMenu} + onNodeDoubleClick={onNodeDoubleClick} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={!readOnly} @@ -423,6 +462,17 @@ function PIDCanvas({ + {configNode && ( + setConfigFor(null)} + onSave={patch => saveConfig(configNode.id, patch)} + /> + )} + {edgeMenu && (
+ + + + + + {/* injector manifold block */} + + {/* injector face */} + + {[18, 27, 36, 45, 54].map(x => ( + + ))} + {/* chamber, throat, bell */} + + + INJ + {pc && ( + + {pc.value}{pc.unit === '-' ? '' : pc.unit} + + )} + + + +
+ ); +} diff --git a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx new file mode 100644 index 000000000..e6db3779d --- /dev/null +++ b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx @@ -0,0 +1,72 @@ +import { Position, type NodeProps } from '@xyflow/react'; +import { Port } from './Port'; +import type { PIDNodeData } from '../types'; +import { FLUID_COLORS } from '../types'; +import { DraggableLabel } from './DraggableLabel'; + +/** + * A manifold: one feed in, several out. + * + * The reason to draw one rather than fan eight lines off a tank port is that + * the hardware *is* a block with a bore through it and ports tapped into the + * side, and a drawing that hides it turns into the mess it was hiding. It is + * also a real node in a solve -- a plenum, plus one branch per port -- rather + * than a drafting convenience. + * + * Port count is per-side and set from the config dialog, because how many + * ports a block has is a property of that block, not of manifolds. + */ + +const BODY = 26; +const PITCH = 26; +const PAD = 14; + +/** Evenly spaced port offsets along a run of `n` ports. */ +export function portOffsets(n: number): number[] { + return Array.from({ length: n }, (_, i) => PAD + i * PITCH); +} + +export function manifoldLength(ports: number): number { + return Math.max(2, ports) * PITCH + PAD; +} + +export function ManifoldNode({ id, data, selected }: NodeProps) { + const { label, labelOffset, fluidType, rotation, options } = data as unknown as PIDNodeData; + const stroke = selected ? '#3b82f6' : '#94a3b8'; + const fluid = FLUID_COLORS[fluidType ?? 'default']; + + const outlets = Math.max(1, Number(options?.outlets ?? 4)); + const vertical = (options?.orientation ?? 'horizontal') === 'vertical'; + + const run = manifoldLength(outlets); + const W = vertical ? BODY : run; + const H = vertical ? run : BODY; + + return ( +
+ {/* The feed in, at the near end. */} + + + {/* One tapped port per outlet, down the long side. */} + {portOffsets(outlets).map((off, i) => ( + + ))} + + + + {/* the bore through it */} + {vertical + ? + : } + + + +
+ ); +} diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index f1fb65f3b..4e7a93723 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -6,13 +6,19 @@ import { DraggableLabel } from './DraggableLabel'; const W = 60, H = 60; export function PRNode({ id, data, selected }: NodeProps) { - const { label, labelOffset, rotation } = data as unknown as PIDNodeData; + const { label, labelOffset, rotation, options } = data as unknown as PIDNodeData; const stroke = selected ? '#3b82f6' : '#94a3b8'; + // A dome-loaded regulator has a third connection, and which one it is + // matters: the dome sets the outlet, so a line run to it by mistake is a + // regulator held at whatever that line happens to be. Marked on the symbol + // rather than left to be inferred from which side a wire arrives on. + const domeLoaded = options?.domeLoaded === 'yes'; return (
+ {domeLoaded && } PR + {domeLoaded && ( + <> + {/* the dome, and the stem tying it to the seat */} + + + DOME + + )} diff --git a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx index c5139bdbd..4d0028521 100644 --- a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx @@ -7,8 +7,30 @@ import { DraggableLabel } from './DraggableLabel'; const TANK_W = 60, TANK_H = 100; const INJ_W = 60, INJ_H = 100; +/** + * Ports across one end of the tank, evenly spaced. + * + * A tank lid carries a pressurant inlet, a vent, a burst disc and whatever + * instrumentation is tapped into it. Drawing one port forces all of that onto a + * single line and a fan of edges leaving the same pixel, which is the mess the + * port count exists to undo. Ids are stable per index (`t1`, `t2`, ...), so + * reducing the count and putting it back does not orphan the edges that were + * already drawn to the ports that remain. + */ +function endPorts(n: number, prefix: 't' | 'b', position: Position, width: number) { + const count = Math.max(1, Math.min(4, n)); + return Array.from({ length: count }, (_, i) => ( + + )); +} + export function TankNode({ id, data, selected }: NodeProps) { - const { componentType, label, labelOffset, fluidType, rotation } = data as unknown as PIDNodeData; + const { componentType, label, labelOffset, fluidType, rotation, options } = data as unknown as PIDNodeData; const stroke = selected ? '#3b82f6' : '#94a3b8'; const fluidColor = FLUID_COLORS[fluidType ?? 'default']; const isInjector = componentType === 'INJECTOR'; @@ -35,8 +57,8 @@ export function TankNode({ id, data, selected }: NodeProps) { return (
- - + {endPorts(Number(options?.portsTop ?? 1), 't', Position.Top, TANK_W)} + {endPorts(Number(options?.portsBottom ?? 1), 'b', Position.Bottom, TANK_W)} @@ -38,6 +43,18 @@ export function ValveNode({ id, data, selected }: NodeProps) { {componentType === 'MAN' ? : } + {componentType !== 'MAN' && ( + + {failOpen ? 'NO' : 'NC'} + + )}
); diff --git a/pid-designer/frontend/src/components/pid/nodes/index.ts b/pid-designer/frontend/src/components/pid/nodes/index.ts index 811c17ccf..25827e60f 100644 --- a/pid-designer/frontend/src/components/pid/nodes/index.ts +++ b/pid-designer/frontend/src/components/pid/nodes/index.ts @@ -8,6 +8,8 @@ import { QDNode } from './QDNode'; import { TankNode } from './TankNode'; import { TextNode } from './TextNode'; import { JunctionNode } from './JunctionNode'; +import { EngineNode } from './EngineNode'; +import { ManifoldNode } from './ManifoldNode'; export const nodeTypes: NodeTypes = { RTD: SensorNode, @@ -24,6 +26,8 @@ export const nodeTypes: NodeTypes = { QD: QDNode, TANK: TankNode, INJECTOR: TankNode, + ENGINE: EngineNode, + MANIFOLD: ManifoldNode, TEXT: TextNode, JUNCTION: JunctionNode, }; diff --git a/pid-designer/frontend/src/components/pid/params.ts b/pid-designer/frontend/src/components/pid/params.ts new file mode 100644 index 000000000..c3d460185 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/params.ts @@ -0,0 +1,65 @@ +/** + * Parameters on a P&ID symbol. + * + * A number describing hardware is stored as a record, not a float: + * + * { value: 850, unit: 'psi', source: 'manufacturer', + * reference: 'Tescom 26-1000 datasheet rev C' } + * + * That shape is not invented here. It is `feedtwin.model.Param`, the parameter + * type of the feed-system physics core, and the P&ID stores it verbatim so the + * Phase-11 reader can lift a value across without re-typing or guessing. The + * unit spellings below are exactly the ones `feedtwin.model.units` registers; + * anything else is rejected there at load, so offering it here would only move + * the failure somewhere less helpful. + * + * `source` has no default on purpose. "Nobody remembers where this came from" + * is the state a propulsion team's numbers drift into by the second year, and + * making it un-writable is cheaper than reconstructing it later. The dialog + * therefore always asks, and `estimated` is a real answer. + * + * One deliberate absence, inherited and worth restating: **there is no psig.** + * Gauge is a reference, not a unit. A pressure typed as psig and stored as psi + * is one atmosphere low, silently, everywhere downstream -- so the dialog says + * "absolute" next to every pressure field rather than accepting the spelling. + */ + +export type Provenance = 'measured' | 'manufacturer' | 'estimated' | 'default'; + +export const PROVENANCE_LABELS: Record = { + measured: 'Measured — on our own hardware', + manufacturer: 'Datasheet — cite document and revision', + estimated: 'Estimated — judgement, correlation, similar part', + default: 'Unchecked — nobody has looked at this yet', +}; + +/** A number, with where it came from. Mirrors `feedtwin.model.Param`. */ +export interface ParamValue { + value: number; + unit: string; + source: Provenance; + reference?: string; +} + +export type Dimension = + | 'pressure' | 'temperature' | 'length' | 'volume' + | 'flow_coefficient' | 'dimensionless' | 'time' | 'mass_flow' | 'angle'; + +/** + * Units offered per dimension, in the spelling `feedtwin.model.units` uses. + * First entry is the default for a new value. + */ +export const UNITS: Record = { + pressure: ['psi', 'bar', 'Pa', 'kPa', 'MPa', 'atm'], + temperature: ['K', 'degC', 'degR'], + length: ['in', 'mm', 'm', 'cm', 'ft'], + volume: ['L', 'mL', 'm^3', 'in^3', 'gal'], + flow_coefficient: ['Cv', 'Kv'], + dimensionless: ['-', '%'], + time: ['s', 'ms', 'min'], + mass_flow: ['kg/s', 'g/s', 'lbm/s'], + angle: ['deg', 'rad'], +}; + +/** Pressures are absolute. Said in the UI, next to the field. */ +export const ABSOLUTE_NOTE = 'absolute, not gauge'; diff --git a/pid-designer/frontend/src/components/pid/spec.ts b/pid-designer/frontend/src/components/pid/spec.ts new file mode 100644 index 000000000..2875d9c9c --- /dev/null +++ b/pid-designer/frontend/src/components/pid/spec.ts @@ -0,0 +1,241 @@ +/** + * What each kind of hardware *has*: its parameters, and its categorical choices. + * + * Data, not code -- the same decision `feedtwin/model/components.toml` makes, + * for the same reason. Giving a component a config is a row in this table, and + * `ConfigDialog` renders whatever it finds. Nothing below knows how to draw + * anything, and nothing that draws knows what a regulator has. + * + * Names match `components.toml` wherever the same quantity exists on both + * sides (`setpoint`, `Cv`, `bore`, `cracking_pressure`, `volume`), so the + * Phase-11 reader is a lookup rather than a translation table. Where this file + * carries something the physics core does not model yet -- a relief valve's + * reseat pressure, a QD's side of the umbilical -- it is named for what it is + * and stays here until there is somewhere for it to go. + * + * `description` is not decoration. It is where the trap goes: which diameter + * `bore` means, which way `supply_coefficient` points. Those cost hours when + * they are wrong and are invisible in a bare label. + */ + +import type { Dimension } from './params'; +import type { ComponentType } from './types'; + +export interface ParamSpec { + key: string; + label: string; + dimension: Dimension; + description?: string; + /** Suggested starting value, in `unit`. Stored as source 'default'. */ + suggested?: { value: number; unit: string }; +} + +export interface OptionSpec { + key: string; + label: string; + choices: { value: string; label: string }[]; + default: string; + description?: string; +} + +export interface ComponentSpec { + /** Shown at the top of the dialog. */ + summary?: string; + params: ParamSpec[]; + options?: OptionSpec[]; +} + +const P = (key: string, label: string, dimension: Dimension, + description?: string, suggested?: { value: number; unit: string }): ParamSpec => + ({ key, label, dimension, description, suggested }); + +export const COMPONENT_SPECS: Partial> = { + TANK: { + summary: 'Run tank or COPV. Pressure and temperature here are the boundary condition a feed solve starts from.', + params: [ + P('pressure', 'Operating pressure', 'pressure', + 'Ullage pressure held during the burn. Absolute.'), + P('temperature', 'Propellant temperature', 'temperature', + 'Bulk liquid temperature. Cryogens sit at their saturation temperature unless subcooled — 90 K for LOX at 1 atm.'), + P('volume', 'Internal volume', 'volume'), + P('MAWP', 'Max allowable working pressure', 'pressure', + 'What the vessel is rated to. The relief valve is sized against this, not against the operating pressure.'), + ], + options: [ + { key: 'portsTop', label: 'Ports on the top end', default: '1', + choices: ['1','2','3','4'].map(n => ({ value: n, label: n })), + description: 'Pressurant in, vent, burst disc, instrumentation — a real tank lid has several, and one port forces them all onto one line.' }, + { key: 'portsBottom', label: 'Ports on the bottom end', default: '1', + choices: ['1','2','3','4'].map(n => ({ value: n, label: n })) }, + ], + }, + + PR: { + summary: 'Pressure regulator. Not a restriction — the drop across it is whatever the inlet gives it minus the setpoint it holds.', + params: [ + P('setpoint', 'Outlet setpoint', 'pressure', + 'Outlet pressure held at the reference inlet and zero flow. Absolute.'), + P('Cv', 'Seat Cv (wide open)', 'flow_coefficient', + 'Sets capacity — where the regulator runs out of authority — not the gradual droop before that.'), + P('supply_coefficient', 'Supply-pressure effect', 'dimensionless', + 'Outlet rise per unit of inlet decay. A dome reg quoted "14.7 psi per 1000 psi" is 0.0147. Positive means the outlet climbs as the bottle empties — the usual sign, and the one people guess wrong.'), + P('inlet_reference', 'Reference inlet pressure', 'pressure', + 'Inlet pressure at which the setpoint was measured. Without it the supply term has no datum and is meaningless.'), + P('flow_droop', 'Droop at rated flow', 'pressure', + 'Outlet sag at rated flow, relative to the zero-flow setpoint. Separate from the supply effect and not derivable from Cv.'), + P('lockup_rise', 'Lockup rise', 'pressure', + 'How far above setpoint the outlet creeps once flow stops. This is what the downstream relief and burst disc actually see between firings.'), + P('min_inlet_differential', 'Dropout differential', 'pressure', + 'Least inlet-to-outlet difference at which it still regulates. Below this it is a hole.'), + ], + options: [ + { key: 'domeLoaded', label: 'Dome loaded', default: 'no', + choices: [{ value: 'no', label: 'No — spring loaded' }, { value: 'yes', label: 'Yes — has a dome/pilot port' }], + description: 'A dome-loaded regulator carries a third connection. Turning this on adds the control port to the symbol.' }, + ], + }, + + RV: { + summary: 'Relief valve. Sized against the vessel MAWP, not the operating pressure.', + params: [ + P('set_pressure', 'Set (cracking) pressure', 'pressure', + 'Where it starts to lift. Absolute.'), + P('reseat_pressure', 'Reseat pressure', 'pressure', + 'Where it closes again. Always below the set pressure; the gap is the blowdown.'), + P('Cv', 'Cv when open', 'flow_coefficient'), + P('bore', 'Orifice diameter', 'length', 'The actual flow diameter, not a nominal port size.'), + ], + }, + + CV: { + summary: 'Check valve. Passes flow one way through a flow coefficient once cracked, and leaks a little backwards.', + params: [ + P('cracking_pressure', 'Cracking pressure', 'pressure', + 'Differential needed to lift the poppet.', { value: 3.0, unit: 'psi' }), + P('Cv', 'Cv when open', 'flow_coefficient'), + P('bore', 'Internal bore', 'length'), + ], + }, + + QD: { + summary: 'Quick disconnect. Every rocket-side half needs a ground-side half to mate with — the checks panel watches for that.', + params: [ + P('Cv', 'Cv when mated', 'flow_coefficient'), + P('bore', 'Internal bore', 'length'), + ], + options: [ + { key: 'side', label: 'Side of the umbilical', default: 'ground', + choices: [ + { value: 'ground', label: 'Ground / GSE half' }, + { value: 'rocket', label: 'Rocket / flight half' }, + ], + description: 'Which half of the pair this symbol is. Pairing is checked on the mate group below.' }, + { key: 'mateGroup', label: 'Mate group', default: '', + choices: [], // free text; see ConfigDialog + description: 'A name shared by the two halves that mate — "LOX fill", "Eth vent". Halves with the same group are treated as a pair.' }, + ], + }, + + MAN: valveSpec('Manual ball valve — handle operated.'), + ROT: valveSpec('Rotary (pneumatic) ball valve.'), + SOL: valveSpec('Solenoid valve.'), + + PT: { + summary: 'Pressure transducer.', + params: [ + P('range_max', 'Full-scale range', 'pressure', + 'Top of the calibrated range. A transducer reading near its ceiling is the usual reason a trace clips.'), + P('accuracy', 'Accuracy, % of full scale', 'dimensionless'), + ], + }, + + PG: { + summary: 'Pressure gauge.', + params: [P('range_max', 'Full-scale range', 'pressure')], + }, + + RTD: sensorSpec('Resistance temperature detector.'), + TC: sensorSpec('Thermocouple.'), + + LC: { + summary: 'Load cell.', + params: [P('capacity', 'Rated capacity', 'dimensionless', 'Full-scale load.')], + }, + + ENGINE: { + summary: 'Injector and chamber as one item — which is how it is built, tested and replaced. The chamber pressure here is the downstream boundary a feed solve runs against.', + params: [ + P('chamber_pressure', 'Chamber pressure', 'pressure', + 'Pc at the design point. Absolute. This is the back pressure the whole feed system works against.'), + P('chamber_temperature', 'Chamber temperature', 'temperature', + 'Combustion temperature at the design point.'), + P('injector_dp', 'Injector pressure drop', 'pressure', + 'Drop across the injector face at design flow. The stiffness that decouples the chamber from the feed system; usually quoted as a fraction of Pc, and below roughly 15% chug becomes a real risk.'), + P('mixture_ratio', 'Mixture ratio (O/F)', 'dimensionless'), + P('throat_diameter', 'Throat diameter', 'length'), + P('expansion_ratio', 'Expansion ratio', 'dimensionless', 'Exit area over throat area.'), + P('mdot_total', 'Total mass flow', 'mass_flow'), + ], + }, + + INJECTOR: { + summary: 'Injector on its own, where the chamber is drawn separately or is out of scope.', + params: [ + P('injector_dp', 'Injector pressure drop', 'pressure', 'Drop across the face at design flow.'), + P('Cd', 'Discharge coefficient', 'dimensionless', undefined, { value: 0.61, unit: '-' }), + P('orifice_diameter', 'Orifice diameter', 'length'), + P('orifice_count', 'Number of orifices', 'dimensionless'), + ], + }, + + MANIFOLD: { + summary: 'A block with a bore through it and ports tapped into the side: one symbol here, a plenum node plus one branch per port in a solve.', + params: [ + P('bore', 'Plenum bore', 'length', + 'Flow diameter of the passage itself. A port bored larger than the plenum feeding it is a real mistake and this is what catches it.'), + P('volume', 'Internal volume', 'volume', + 'Nothing in a steady solve and everything in a transient one — the plenum has to fill before anything downstream sees pressure.'), + ], + options: [ + { key: 'outlets', label: 'Outlet ports', default: '4', + choices: ['1','2','3','4','5','6','7','8'].map(n => ({ value: n, label: n })), + description: 'How many ports are tapped into the side. The block grows to fit them.' }, + { key: 'orientation', label: 'Run direction', default: 'horizontal', + choices: [ + { value: 'horizontal', label: 'Horizontal — feed enters at the left' }, + { value: 'vertical', label: 'Vertical — feed enters at the top' }, + ] }, + ], + }, +}; + +function valveSpec(summary: string): ComponentSpec { + return { + summary, + params: [ + P('Cv', 'Cv at full open', 'flow_coefficient'), + P('bore', 'Internal bore', 'length', + 'The actual bore of the valve body, not the thread size. A 3/8 in. NPT fitting does not have a 3/8 in. bore, and using the thread size under-predicts loss by several times.'), + P('travel_time', 'Travel time, shut to open', 'time', + 'Measure it — actuation time drives the startup transient.', { value: 0.05, unit: 's' }), + ], + options: [ + { key: 'failState', label: 'Position when unpowered', default: 'closed', + choices: [ + { value: 'closed', label: 'Normally closed (NC)' }, + { value: 'open', label: 'Normally open (NO)' }, + ], + description: 'Where the valve sits with no command applied. Drawn on the symbol, because it is the difference between a safe abort and a spill.' }, + ], + }; +} + +function sensorSpec(summary: string): ComponentSpec { + return { + summary, + params: [ + P('range_min', 'Range minimum', 'temperature'), + P('range_max', 'Range maximum', 'temperature'), + ], + }; +} diff --git a/pid-designer/frontend/src/components/pid/types.ts b/pid-designer/frontend/src/components/pid/types.ts index 009fcdc8d..89a1db216 100644 --- a/pid-designer/frontend/src/components/pid/types.ts +++ b/pid-designer/frontend/src/components/pid/types.ts @@ -1,8 +1,10 @@ +import type { ParamValue } from './params'; + export type ComponentType = | 'RTD' | 'PT' | 'PG' | 'LC' | 'TC' | 'MAN' | 'ROT' | 'SOL' | 'PR' | 'RV' | 'CV' | 'QD' - | 'TANK' | 'INJECTOR' + | 'TANK' | 'INJECTOR' | 'ENGINE' | 'MANIFOLD' | 'TEXT' | 'JUNCTION'; @@ -22,29 +24,66 @@ export interface PIDNodeData { notes?: string; labelOffset?: { x: number; y: number }; rotation?: number; + /** + * Hardware numbers, keyed by the names in `spec.ts`. Each carries its unit + * and its provenance -- see `params.ts` -- so a value can cross into + * feed-twin without being re-typed or re-guessed. + */ + params?: Record; + /** + * Categorical choices, keyed by the names in `spec.ts`: a valve's fail + * state, which side of the umbilical a QD is on. Strings, because these are + * enumerations rather than quantities and carry no unit. + */ + options?: Record; } export interface ComponentDef { + /** Palette entry id. Usually the component type, but two entries may drop + * the same type with different presets -- see the HP/LP transducers. */ + id: string; type: ComponentType; label: string; fullName: string; - group: 'Sensors' | 'Valves' | 'Flow Control' | 'Hardware'; + group: 'Sensors' | 'Valves' | 'Flow Control' | 'Hardware' | 'Annotation'; + /** Options stamped onto the node at drop time. */ + preset?: Record; } export const COMPONENT_DEFS: ComponentDef[] = [ - { type: 'RTD', label: 'RTD_#', fullName: 'Resistance Temperature Detector', group: 'Sensors' }, - { type: 'PT', label: 'PT_#', fullName: 'LOX/Eth Pressure Transducer', group: 'Sensors' }, - { type: 'PG', label: 'PG_#', fullName: 'Pressure Gauge', group: 'Sensors' }, - { type: 'LC', label: 'LC_#', fullName: 'Load Cell', group: 'Sensors' }, - { type: 'TC', label: 'TC_#', fullName: 'Thermocouple', group: 'Sensors' }, - { type: 'MAN', label: 'MAN_#', fullName: 'Ball Valve (Manual)', group: 'Valves' }, - { type: 'ROT', label: 'ROT_#', fullName: 'Ball Valve (Rotary)', group: 'Valves' }, - { type: 'SOL', label: 'SOL_#', fullName: 'Solenoid Valve', group: 'Valves' }, - { type: 'PR', label: 'PR_#', fullName: 'Pressure Regulator', group: 'Flow Control' }, - { type: 'RV', label: 'RV_#', fullName: 'Relief Valve', group: 'Flow Control' }, - { type: 'CV', label: 'CV_#', fullName: 'Kero/LOX Check Valve', group: 'Flow Control' }, - { type: 'QD', label: '[F]QD', fullName: 'Quick Disconnect (Face Seal)', group: 'Flow Control' }, - { type: 'TANK', label: 'TANK', fullName: 'Tank / COPV', group: 'Hardware' }, - { type: 'INJECTOR', label: 'INJ', fullName: 'Injector', group: 'Hardware' }, - { type: 'TEXT', label: 'Text', fullName: 'Text Annotation', group: 'Hardware' }, + { id: 'RTD', type: 'RTD', label: 'RTD_#', fullName: 'Resistance Temperature Detector', group: 'Sensors' }, + { id: 'TC', type: 'TC', label: 'TC_#', fullName: 'Thermocouple', group: 'Sensors' }, + // Split because a 10 000 psi bottle transducer and a 500 psi tank + // transducer are different parts, and a drawing that calls both "PT" hides + // the one mistake that matters -- fitting the low one to the high side. + { id: 'PT_HP', type: 'PT', label: 'PT-HP_#', fullName: 'Pressure Transducer (high press)', group: 'Sensors', + preset: { pressureClass: 'high' } }, + { id: 'PT_LP', type: 'PT', label: 'PT-LP_#', fullName: 'Pressure Transducer (low press)', group: 'Sensors', + preset: { pressureClass: 'low' } }, + { id: 'PG', type: 'PG', label: 'PG_#', fullName: 'Pressure Gauge', group: 'Sensors' }, + { id: 'LC', type: 'LC', label: 'LC_#', fullName: 'Load Cell', group: 'Sensors' }, + + { id: 'MAN', type: 'MAN', label: 'MAN_#', fullName: 'Ball Valve (Manual)', group: 'Valves' }, + { id: 'ROT', type: 'ROT', label: 'ROT_#', fullName: 'Ball Valve (Rotary)', group: 'Valves' }, + { id: 'SOL', type: 'SOL', label: 'SOL_#', fullName: 'Solenoid Valve', group: 'Valves' }, + + { id: 'PR', type: 'PR', label: 'PR_#', fullName: 'Pressure Regulator', group: 'Flow Control' }, + { id: 'RV', type: 'RV', label: 'RV_#', fullName: 'Relief Valve', group: 'Flow Control' }, + { id: 'CV', type: 'CV', label: 'CV_#', fullName: 'Check Valve', group: 'Flow Control' }, + { id: 'QD_G', type: 'QD', label: 'QD-G_#', fullName: 'Quick Disconnect (ground half)', group: 'Flow Control', + preset: { side: 'ground' } }, + { id: 'QD_R', type: 'QD', label: 'QD-R_#', fullName: 'Quick Disconnect (rocket half)', group: 'Flow Control', + preset: { side: 'rocket' } }, + + { id: 'TANK', type: 'TANK', label: 'TANK', fullName: 'Tank / COPV', group: 'Hardware' }, + { id: 'MANIFOLD', type: 'MANIFOLD', label: 'MAN-F', fullName: 'Manifold (splits one feed)', group: 'Hardware' }, + { id: 'ENGINE', type: 'ENGINE', label: 'ENG', fullName: 'Injector + chamber', group: 'Hardware' }, + { id: 'INJECTOR', type: 'INJECTOR', label: 'INJ', fullName: 'Injector (alone)', group: 'Hardware' }, + + { id: 'TEXT', type: 'TEXT', label: 'Text', fullName: 'Text Annotation', group: 'Annotation' }, ]; + +/** The palette entry a node was dropped from, for defaulting its label. */ +export function defFor(id: string): ComponentDef | undefined { + return COMPONENT_DEFS.find(d => d.id === id); +} diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts index f28240e47..2ad28bacc 100644 --- a/pid-designer/frontend/src/lib/gating.test.ts +++ b/pid-designer/frontend/src/lib/gating.test.ts @@ -57,6 +57,7 @@ const VIEW_ONLY: Record = { 'PIDToolbar.tsx:setRelLabel': 'the label field inside the release dialog, which Release already gates', 'PIDToolbar.tsx:onClick={submitRelease}': 'inside the release dialog, which Release already gates', 'PIDDesigner.tsx:setUnshared(null)': 'dismisses the "no longer shared" notice', + 'ConfigDialog.tsx:onClick={onClose}': 'Cancel closes the config dialog; Save is what writes, and Save is gated', 'PIDDesigner.tsx:setShowChange(true)': 'opens the Change dialog (rename/share/copy are not gated by design)', } From 1de8d1fc6919d29d6f838ee1b1a382b76a0d8151 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 00:28:46 -0700 Subject: [PATCH 03/57] pid-designer: real fluids, configurable lines, QD pairing **Fluid is a species now, declared once and inherited.** The palette offered fuel, lox, pressurant and default -- four colours, not four fluids, and "fuel" never said which fuel. The species are the ones `feedtwin/props/species.toml` declares, under the same names, so a drawing names something the property layer can resolve. You set ethanol on one tank and LOX on another; every line, valve and fitting downstream inherits it. Sixty entries become four, and the four cannot drift out of step with the drawing because the answer is recomputed from it rather than stored. Getting the propagation rules right took two corrections, both found by drawing the ordinary case: - **A tank is a source, not a junction.** Nitrogen arriving at a LOX tank was reported as fuel and oxidiser meeting. It is the most common arrangement in the system -- the ullage is nitrogen, the outlet is LOX, and separating them is what the tank is *for*. A check that fires on that teaches people to ignore it. - **A tank's top ports are its ullage side.** With the above fixed, the LOX tank pushed LOX back up its own pressurant line and the regulator feeding it came out blue. Ports are keyed on handle id, not screen position, so rotating a tank does not change which is which -- and a bottle connected only by its top is still painted, on a second pass, rather than left blank on a technicality. What is left is a check worth having: two fluids at an ordinary component means a line drawn to the wrong port, and it is reported rather than blended. Meeting them at an engine is not. **Lines carry their hardware.** An edge had a colour and nothing else, so length, bore, roughness and lumped fitting K -- where most of the pressure drop in a feed system actually is -- had nowhere to live, and a reader could import the topology and still not compute a single pressure. Double-click a line and it is a hardline, a flex hose, a bend or a fitting, with the parameters `components.toml` declares for each. A hose is not a rougher pipe: its crimped ends dominate the loss on a short run and its bend radius is usually what constrains the routing. **Every component takes a part number**, and that is the more important half. The catalogue already holds the datasheet and whatever the bench measured, so a drawing that re-types Cv is a second copy that will disagree with the first. Name the part, leave the fields blank, and fill one only to override that part for one installation. **Quick disconnects** pick the half they mate with, or say they need no pair, so the checks panel has something to check rather than something to guess. They come in hydraulic and fluid, drawn differently -- dashed body, and the side marked R or G on the symbol -- because which half is which decides whether a fill line can actually be disconnected. Right-click now sets an explicit colour on anything, from swatches or a hex code, replacing the per-edge fluid menu that fluid inheritance made redundant. Clearing an override hands the symbol back to its fluid colour rather than leaving it grey. --- .../src/components/pid/BranchableEdge.tsx | 13 +- .../frontend/src/components/pid/ColorMenu.tsx | 96 +++++++ .../src/components/pid/ConfigDialog.tsx | 154 ++++++++-- .../src/components/pid/FluidContext.tsx | 56 ++++ .../src/components/pid/PIDDesigner.tsx | 166 +++++++---- .../src/components/pid/fluids.test.ts | 130 +++++++++ .../frontend/src/components/pid/fluids.ts | 270 ++++++++++++++++++ .../src/components/pid/nodes/QDNode.tsx | 37 ++- .../src/components/pid/nodes/TankNode.tsx | 14 +- .../frontend/src/components/pid/spec.ts | 109 ++++++- .../frontend/src/components/pid/types.ts | 45 ++- 11 files changed, 997 insertions(+), 93 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/ColorMenu.tsx create mode 100644 pid-designer/frontend/src/components/pid/FluidContext.tsx create mode 100644 pid-designer/frontend/src/components/pid/fluids.test.ts create mode 100644 pid-designer/frontend/src/components/pid/fluids.ts diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx index 72cca3461..e8a8af7a8 100644 --- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx +++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx @@ -8,8 +8,8 @@ import { type EdgeProps, type Edge, } from '@xyflow/react'; -import { FLUID_COLORS, type FluidType } from './types'; import { nextJunctionId } from './ids'; +import { useEdgeFluidColor } from './FluidContext'; const J_HALF = 5; @@ -44,8 +44,9 @@ export function BranchableEdge(props: EdgeProps) { }); } - const fluidType = (data as { fluidType?: FluidType })?.fluidType ?? 'default'; - const strokeColor = FLUID_COLORS[fluidType]; + // Inherited from the tanks that feed this line, unless somebody has marked + // it up by hand. Neither is stored on the edge -- see FluidContext. + const strokeColor = useEdgeFluidColor(id, (data as { color?: string })?.color); const onMouseMove = useCallback((e: React.MouseEvent) => { const svg = (e.currentTarget as SVGElement).closest('svg'); @@ -82,7 +83,7 @@ export function BranchableEdge(props: EdgeProps) { targetHandle: 't', type: 'smoothstep', style: { stroke: strokeColor, strokeWidth: 2 }, - data: { fluidType, sourcePosition: overrideSourcePos ?? sourcePosition, targetPosition: Position.Top }, + data: { ...data, sourcePosition: overrideSourcePos ?? sourcePosition, targetPosition: Position.Top }, }; const fromJunction: Edge = { id: `${junctionId}-to-${target}`, @@ -91,12 +92,12 @@ export function BranchableEdge(props: EdgeProps) { target, type: 'smoothstep', style: { stroke: strokeColor, strokeWidth: 2 }, - data: { fluidType, sourcePosition: Position.Bottom, targetPosition: overrideTargetPos ?? targetPosition }, + data: { ...data, sourcePosition: Position.Bottom, targetPosition: overrideTargetPos ?? targetPosition }, }; return [...filtered, toJunction, fromJunction]; }); }); - }, [dot, id, source, target, strokeColor, fluidType, + }, [dot, id, source, target, strokeColor, data, overrideSourcePos, overrideTargetPos, sourcePosition, targetPosition, setNodes, setEdges]); diff --git a/pid-designer/frontend/src/components/pid/ColorMenu.tsx b/pid-designer/frontend/src/components/pid/ColorMenu.tsx new file mode 100644 index 000000000..b903c725f --- /dev/null +++ b/pid-designer/frontend/src/components/pid/ColorMenu.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { useReadOnly } from '@stardesign-ui'; + +/** + * Colour override, on right-click. + * + * The menu this replaces set a line's "fluid type" by hand -- four colours + * standing in for four fluids. Fluid is now inherited from whatever tank feeds + * a line (`fluids.ts`), so setting it per edge would only let a drawing + * disagree with itself. What is left is what colour is genuinely for here: + * marking things up. Grouping a GSE panel, flagging a line for a review, doing + * the highlighting a drawing needs and a solver ignores. + * + * So an override is exactly that -- an override. Clearing it hands the symbol + * back to its fluid colour rather than leaving it grey, because the automatic + * colour is the one that stays right when somebody re-plumbs the drawing. + */ + +const SWATCHES = [ + '#ef4444', '#f97316', '#f59e0b', '#eab308', + '#84cc16', '#22c55e', '#14b8a6', '#06b6d4', + '#3b82f6', '#6366f1', '#a855f7', '#ec4899', + '#f43f5e', '#94a3b8', '#e2e8f0', '#0f172a', +]; + +export function ColorMenu({ x, y, current, onPick, onClear, onClose }: { + x: number; y: number; + current?: string; + onPick: (hex: string) => void; + onClear: () => void; + onClose: () => void; +}) { + const [hex, setHex] = useState(current ?? ''); + // Read from context rather than taken as a prop: this menu is reached from + // the canvas's own handlers, which already refuse to open it without the + // checkout, and consulting the context makes that true of the controls too + // rather than only of the thing that opens them. + const readOnly = useReadOnly(); + + const commit = (v: string) => { + const t = v.trim(); + // Accept #abc and #aabbcc, with or without the hash. + const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(t); + if (m) onPick(`#${m[1]}`); + }; + + return ( +
e.stopPropagation()} + onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }} + > +

Colour

+ +
+ {SWATCHES.map(c => ( +
+ +
+ setHex(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { commit(hex); onClose(); } }} + className="min-w-0 flex-1 rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] px-1.5 py-1 font-mono text-[11px] outline-none focus:border-[var(--color-accent)]" + /> + +
+ + +
+ ); +} diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index 72ab9c640..6e1d094bc 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { Modal } from '../ui'; import { btn, primaryBtn } from '../../lib/ui'; -import { COMPONENT_SPECS } from './spec'; -import type { OptionSpec, ParamSpec } from './spec'; +import { COMPONENT_SPECS, LINE_SPECS, LINE_TYPE_LABELS, PEER_CHOICES } from './spec'; +import type { ComponentSpec, OptionSpec, ParamSpec } from './spec'; +import { SPECIES } from './fluids'; import { PROVENANCE_LABELS, UNITS, ABSOLUTE_NOTE } from './params'; import type { ParamValue, Provenance } from './params'; import type { ComponentType, PIDNodeData } from './types'; @@ -30,13 +31,25 @@ import type { ComponentType, PIDNodeData } from './types'; * field never masquerades as a measured nought. */ +export interface ConfigPatch { + params: Record; + options: Record; + label: string; + fluid?: string; + partNumber?: string; + lineType?: string; +} + interface Props { open: boolean; onClose: () => void; - nodeId: string; - data: PIDNodeData; + /** A component, or a line. Lines pick their kind inside the dialog. */ + kind: 'node' | 'edge'; + data: PIDNodeData & { lineType?: string; partNumber?: string; fluid?: string }; + /** Other components this one could reference — used by the QD pair picker. */ + peers?: { id: string; label: string; hint?: string }[]; readOnly: boolean; - onSave: (patch: { params: Record; options: Record; label: string }) => void; + onSave: (patch: ConfigPatch) => void; } type Draft = { value: string; unit: string; source: Provenance; reference: string }; @@ -60,19 +73,33 @@ function toDraft(spec: ParamSpec, existing?: ParamValue): Draft { }; } -export function ConfigDialog({ open, onClose, data, readOnly, onSave }: Props) { +export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSave }: Props) { const type = data.componentType as ComponentType; - const spec = COMPONENT_SPECS[type]; const [label, setLabel] = useState(data.label ?? ''); const [drafts, setDrafts] = useState>({}); const [options, setOptions] = useState>({}); + const [fluid, setFluid] = useState(data.fluid ?? ''); + const [partNumber, setPartNumber] = useState(data.partNumber ?? ''); + // A line picks what it is inside the dialog: a hose is not a rougher pipe. + const [lineType, setLineType] = useState(data.lineType ?? 'pipe'); + + const spec: ComponentSpec | undefined = + kind === 'edge' ? LINE_SPECS[lineType] : COMPONENT_SPECS[type]; - // Reset whenever a different symbol is opened, so a dialog never shows the + // Reset whenever a different subject is opened, so a dialog never shows the // last one's numbers under this one's name. useEffect(() => { - if (!open || !spec) return; + if (!open) return; setLabel(data.label ?? ''); + setFluid(data.fluid ?? ''); + setPartNumber(data.partNumber ?? ''); + setLineType(data.lineType ?? 'pipe'); + }, [open, data]); + + // Drafts follow the spec, which for a line changes when its kind does. + useEffect(() => { + if (!open || !spec) return; setDrafts(Object.fromEntries(spec.params.map(p => [p.key, toDraft(p, data.params?.[p.key])]))); setOptions(Object.fromEntries( (spec.options ?? []).map(o => [o.key, data.options?.[o.key] ?? o.default]), @@ -98,15 +125,26 @@ export function ConfigDialog({ open, onClose, data, readOnly, onSave }: Props) { ...(d.reference.trim() ? { reference: d.reference.trim() } : {}), }; } - onSave({ params, options, label: label.trim() || data.label }); + onSave({ + params, + options, + label: label.trim() || data.label, + fluid: fluid || undefined, + partNumber: partNumber.trim() || undefined, + ...(kind === 'edge' ? { lineType } : {}), + }); onClose(); }; + const title = kind === 'edge' + ? `Line — ${LINE_TYPE_LABELS[lineType] ?? lineType}` + : `${data.label || type} — configuration`; + return ( @@ -122,16 +160,65 @@ export function ConfigDialog({ open, onClose, data, readOnly, onSave }: Props) {

{spec.summary}

)} + {kind === 'edge' && ( + + )} + + {kind === 'node' && ( + + )} + + {kind === 'node' && ( + + )} + @@ -140,8 +227,9 @@ export function ConfigDialog({ open, onClose, data, readOnly, onSave }: Props) { key={o.key} spec={o} value={options[o.key] ?? o.default} + peers={peers} readOnly={readOnly} - onChange={v => setOptions(s => ({ ...s, [o.key]: v }))} + onChange={v => setOptions(s2 => ({ ...s2, [o.key]: v }))} /> ))} @@ -163,9 +251,35 @@ export function ConfigDialog({ open, onClose, data, readOnly, onSave }: Props) { ); } -function OptionField({ spec, value, readOnly, onChange }: { - spec: OptionSpec; value: string; readOnly: boolean; onChange: (v: string) => void; +const inputCls = + 'mt-1 w-full rounded border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2 py-1 text-xs outline-none focus:border-[var(--color-accent)]'; +const selectCls = inputCls; + +function OptionField({ spec, value, peers, readOnly, onChange }: { + spec: OptionSpec; value: string; peers?: { id: string; label: string; hint?: string }[]; + readOnly: boolean; onChange: (v: string) => void; }) { + // An option whose choices are other components on the drawing, resolved at + // render time rather than declared in the spec -- the spec cannot know what + // else somebody has drawn. + if (spec.choices === PEER_CHOICES) { + return ( + + ); + } + const free = spec.choices.length === 0; return (
)} + {kind === 'edge' && ( + + )} + {(spec.portGroups ?? []).map(group => ( ; } + if (spec.choices.length === 0) { + return ( + + onChange(e.target.value)} + className={wide} + /> + + ); + } return ( pickTube(i, e.target.value)} + className={`${field} w-[112px] shrink-0`} + title="Tube size — the bore follows from OD minus two walls" + > + + {TUBE_SIZES.map(t => )} + + setParam(i, 'length', e.target.value, 'm')} + className={`${field} w-[62px] shrink-0`} + /> + m + setParam(i, 'bore', e.target.value, 'mm')} + className={`${field} w-[62px] shrink-0`} + title="Flow diameter — not the thread size" + /> + mm + +
+ + patch(i, { fittings: f })} + /> +
+ + {transitions[i] && ( +

+ ↓ {transitions[i]!.kind === 'contraction' ? 'reducer' : 'expander'}{' '} + {transitions[i]!.fromMm.toFixed(2)} → {transitions[i]!.toMm.toFixed(2)} mm + + derived, K {transitions[i]!.K.toFixed(2)} + +

+ )} +
+ ))} + + +
+ ); +} + +/** The fittings in one segment: kind and count, nothing about placement. */ +function FittingBag({ fittings, readOnly, onChange }: { + fittings: Partial>; + readOnly: boolean; + onChange: (f: Partial>) => void; +}) { + const [adding, setAdding] = useState(false); + const [query, setQuery] = useState(''); + + const present = (Object.keys(fittings) as FittingKind[]).filter(k => (fittings[k] ?? 0) > 0); + const matches = FITTING_KINDS.filter(k => + FITTING_LABELS[k].toLowerCase().includes(query.trim().toLowerCase())); + + const bump = (k: FittingKind, by: number) => { + const n = (fittings[k] ?? 0) + by; + const next = { ...fittings }; + if (n <= 0) delete next[k]; + else next[k] = n; + onChange(next); + }; + + return ( +
+ {present.map(k => ( + + {FITTING_LABELS[k]} + + + + ))} + + {adding ? ( + + setQuery(e.target.value)} + onBlur={() => window.setTimeout(() => { setAdding(false); setQuery(''); }, 120)} + className={`${field} w-[128px]`} + /> + + {matches.map(k => ( + + ))} + {matches.length === 0 && ( + + nothing matches + + )} + + + ) : ( + + )} +
+ ); +} + +export { fittingCount }; diff --git a/pid-designer/frontend/src/components/pid/segments.test.ts b/pid-designer/frontend/src/components/pid/segments.test.ts new file mode 100644 index 000000000..aef1e85e8 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/segments.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { transitionBetween, transitionsOf, fittingCount, FITTING_KINDS, FITTING_LABELS } from './segments'; +import type { LineSegment } from './segments'; +import { boreForTube, suggestBore, THROUGH_BORE, tubeBoreMm } from './tubing'; +import type { ParamValue } from './params'; + +const mm = (v: number): ParamValue => ({ value: v, unit: 'mm', source: 'measured' }); +const seg = (id: string, bore?: ParamValue, fittings = {}): LineSegment => + ({ id, bore, fittings }); + +describe('tube bore is arithmetic', () => { + it('is OD minus two walls', () => { + // 1/2 x 0.035 -> 0.430 in; the number every catalogue prints. + expect(tubeBoreMm(0.5, 0.035)).toBeCloseTo(10.922, 3); + expect(boreForTube('1/2 × 0.035')).toBeCloseTo(10.922, 3); + expect(boreForTube('3/8 × 0.035')).toBeCloseTo(7.747, 3); + }); + + it('carries where it came from, so a report can trace it', () => { + expect(suggestBore('tube', '1/2 × 0.035')!.reference).toContain('OD − 2 × wall'); + }); + + it('says nothing for a size it does not know', () => { + expect(boreForTube('9/16 × 0.042')).toBeNull(); + }); +}); + +describe('fitting standards are catalogue data, not memory', () => { + it('ships empty rather than guessing', () => { + // A number invented here would reach feed-twin wearing a source and a + // reference, looking checked, and be wrong. + for (const std of Object.keys(THROUGH_BORE) as (keyof typeof THROUGH_BORE)[]) { + expect(Object.keys(THROUGH_BORE[std])).toEqual([]); + } + }); + + it('returns null so the dialog asks instead of guessing', () => { + expect(suggestBore('JIC', '-8')).toBeNull(); + }); +}); + +describe('a change of bore is a derived transition', () => { + it('is an expansion when the bore grows, by Borda–Carnot', () => { + const t = transitionBetween(seg('a', mm(7.75)), seg('b', mm(10.92)))!; + expect(t.kind).toBe('expansion'); + const beta2 = (7.75 * 7.75) / (10.92 * 10.92); + expect(t.K).toBeCloseTo((1 - beta2) ** 2, 6); + }); + + it('is a contraction when the bore shrinks', () => { + const t = transitionBetween(seg('a', mm(10.92)), seg('b', mm(7.75)))!; + expect(t.kind).toBe('contraction'); + }); + + it('is nothing at all when the bore is unchanged', () => { + expect(transitionBetween(seg('a', mm(10)), seg('b', mm(10)))).toBeNull(); + }); + + it('is nothing when a bore has not been stated — absent is not zero', () => { + expect(transitionBetween(seg('a'), seg('b', mm(10)))).toBeNull(); + }); + + it('compares across units', () => { + const inches: ParamValue = { value: 0.43, unit: 'in', source: 'measured' }; + expect(transitionBetween(seg('a', inches), seg('b', mm(10.922)))).toBeNull(); + }); + + it('lines transitions up with the gaps between segments', () => { + const segs = [seg('a', mm(12)), seg('b', mm(8)), seg('c', mm(8))]; + const ts = transitionsOf(segs); + expect(ts).toHaveLength(2); + expect(ts[0]!.kind).toBe('contraction'); + expect(ts[1]).toBeNull(); + }); +}); + +describe('the fitting tally', () => { + it('counts the bag and the detailed ones together', () => { + const s: LineSegment = { + id: 'a', + fittings: { elbow_90: 3, tee_run: 1 }, + detailed: [{ kind: 'contraction' }], + }; + expect(fittingCount(s)).toBe(5); + }); + + it('names every kind feed-twin registers, and no others', () => { + // These strings are the contract with correlations.py. A kind that is not + // registered there has no correlation behind it and would price at zero. + expect(FITTING_KINDS).toHaveLength(15); + expect(Object.keys(FITTING_LABELS).sort()).toEqual([...FITTING_KINDS].sort()); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/segments.ts b/pid-designer/frontend/src/components/pid/segments.ts new file mode 100644 index 000000000..83418e5a2 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/segments.ts @@ -0,0 +1,145 @@ +import type { ParamValue } from './params'; + +/** + * What a line is actually made of. + * + * A line used to carry one lumped `K_minor` that somebody guessed, and fittings + * are about half a line's resistance -- so that guess was the largest error in + * an imported system. This is the shape that fixes it without making anybody + * draw every elbow: + * + * **Ordered segments. Unordered tally inside each.** + * + * Segments are ordered because their *bores* are: 1/4→3/8→1/2 and 1/2→1/4→3/8 + * are the same parts in a different order and differ by 2.1× in pressure drop. + * Fittings *within* one bore are a bag with counts, because rearranging them + * changes the answer by 0.003% -- so asking anyone to place them would be + * tedium that buys nothing. + * + * Everything here is optional. A line with no segments behaves exactly as it + * does today. **Absent means "not stated", never "zero"** -- feed-twin fills in + * a default and its run report counts it as unchecked. + */ + +/** + * The fitting kinds feed-twin prices, exactly as registered in + * `feedtwin/comps/correlations.py`. Not a vocabulary of our own: a name that + * is not on this list has no correlation behind it, so it would silently + * contribute nothing. If a union or a cross is needed, feed-twin registers it + * first. + */ +export const FITTING_KINDS = [ + 'elbow_90', 'elbow_45', 'bend', + 'tee_run', 'tee_branch', + 'contraction', 'expansion', + 'entrance_sharp', 'exit', + 'ball_valve_full', 'gate_valve_full', 'globe_valve', 'swing_check', + 'elbow_90_crane', 'elbow_45_crane', +] as const; + +export type FittingKind = (typeof FITTING_KINDS)[number]; + +/** How each reads in the list. The name on the left is the contract. */ +export const FITTING_LABELS: Record = { + elbow_90: 'Elbow 90°', + elbow_45: 'Elbow 45°', + bend: 'Bend', + tee_run: 'Tee (run)', + tee_branch: 'Tee (branch)', + contraction: 'Contraction', + expansion: 'Expansion', + entrance_sharp: 'Entrance (sharp)', + exit: 'Exit', + ball_valve_full: 'Ball valve (full bore)', + gate_valve_full: 'Gate valve (full bore)', + globe_valve: 'Globe valve', + swing_check: 'Swing check', + elbow_90_crane: 'Elbow 90° (Crane f_T)', + elbow_45_crane: 'Elbow 45° (Crane f_T)', +}; + +/** A fitting that needs more than a count — its own bore, or a measured K. */ +export interface FittingInstance { + kind: FittingKind; + /** Overrides the segment bore for this fitting only. */ + bore?: ParamValue; + /** For contraction / expansion: the other side. */ + bore2?: ParamValue; + /** For a bend: r/D. */ + bend_diameters?: ParamValue; + angle?: ParamValue; + partNumber?: string; + /** A measured or published K. Beats every correlation. */ + K?: ParamValue; +} + +export interface LineSegment { + id: string; + /** The FLOW diameter, not the thread size. */ + bore?: ParamValue; + /** Developed length along the centreline. */ + length?: ParamValue; + roughness?: ParamValue; + /** Signed; up is positive. */ + elevation_change?: ParamValue; + /** Unordered: kind → how many. */ + fittings?: Partial>; + detailed?: FittingInstance[]; + /** What the bore was derived from, when it came from a tube size. */ + tubeSize?: string; +} + +let _seg = 0; +export const nextSegmentId = () => `seg_${++_seg}`; + +/** Advance past the ids already in a loaded diagram. */ +export function seedSegmentIds(segments: LineSegment[] | undefined): void { + for (const s of segments ?? []) { + const m = /^seg_(\d+)$/.exec(s.id); + if (m) _seg = Math.max(_seg, Number(m[1])); + } +} + +export const fittingCount = (s: LineSegment): number => + Object.values(s.fittings ?? {}).reduce((n, v) => n + (v ?? 0), 0) + + (s.detailed?.length ?? 0); + +/** + * A change of bore between two segments is a reducer or an expander. + * + * Derived, never typed: two adjacent segments with different bores *are* the + * transition, so making somebody add a row for it is a step they can forget + * and then be wrong about. Returned for display; feed-twin derives its own. + */ +export interface Transition { + kind: 'contraction' | 'expansion'; + fromMm: number; + toMm: number; + /** Borda–Carnot for an expansion; Crane's sudden contraction otherwise. */ + K: number; +} + +const MM: Record = { mm: 1, m: 1000, cm: 10, in: 25.4, ft: 304.8 }; +const toMm = (p?: ParamValue): number | null => + p && MM[p.unit] !== undefined ? p.value * MM[p.unit] : null; + +export function transitionBetween(a: LineSegment, b: LineSegment): Transition | null { + const from = toMm(a.bore); + const to = toMm(b.bore); + if (from === null || to === null || from <= 0 || to <= 0) return null; + if (Math.abs(from - to) < 1e-9) return null; + + // Beta is always small-over-large, and K is referred to the smaller bore. + const beta2 = to > from ? (from * from) / (to * to) : (to * to) / (from * from); + if (to > from) { + // Sudden expansion: Borda-Carnot, from momentum conservation alone. + return { kind: 'expansion', fromMm: from, toMm: to, K: (1 - beta2) ** 2 }; + } + // Sudden contraction, Crane's usual form. + return { kind: 'contraction', fromMm: from, toMm: to, K: 0.5 * (1 - beta2) }; +} + +/** Every derived transition down a run, aligned to the gap after each segment. */ +export function transitionsOf(segments: LineSegment[]): (Transition | null)[] { + return segments.slice(0, -1).map((s, i) => transitionBetween(s, segments[i + 1])); +} diff --git a/pid-designer/frontend/src/components/pid/spec.ts b/pid-designer/frontend/src/components/pid/spec.ts index 47079af24..bd84f8f3a 100644 --- a/pid-designer/frontend/src/components/pid/spec.ts +++ b/pid-designer/frontend/src/components/pid/spec.ts @@ -26,8 +26,10 @@ export interface ParamSpec { export interface OptionSpec { key: string; label: string; + /** Empty renders a text box; `PEER_CHOICES` renders the component picker. */ choices: { value: string; label: string }[]; default: string; + placeholder?: string; } /** Ports whose number is an option, and which can then be named. */ @@ -132,6 +134,11 @@ export const COMPONENT_SPECS: Partial> = { // "0.0147" off a spec sheet -- they read "14.7 psi per 1000 psi". P('supply_effect_out', 'Outlet rise', 'pressure'), P('supply_effect_in', ' per inlet drop', 'pressure'), + // Dome-loaded only. `dome_pressure` is superseded when a loading + // regulator is drawn — feed-twin takes that one's setpoint — so it is + // for a dome set from a panel that is not on the drawing. + P('dome_bias', 'Dome bias', 'pressure'), + P('dome_pressure', 'Dome pressure', 'pressure'), ], options: [ { key: 'domeLoaded', label: 'Dome loaded', default: 'no', @@ -174,6 +181,10 @@ export const COMPONENT_SPECS: Partial> = { SOL: valveSpec(), ENGINE: { + options: [ + { key: 'engineConfig', label: 'Layer-1 config', default: '', + choices: [], placeholder: 'EngineDesign YAML path or id' }, + ], params: [ P('chamber_pressure', 'Chamber pressure', 'pressure'), P('chamber_temperature', 'Chamber temperature', 'temperature'), diff --git a/pid-designer/frontend/src/components/pid/tubing.ts b/pid-designer/frontend/src/components/pid/tubing.ts new file mode 100644 index 000000000..8e6919418 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/tubing.ts @@ -0,0 +1,97 @@ +/** + * Where a bore comes from. + * + * **A 3/8″ NPT fitting does not have a 3/8″ bore.** Thread size is not flow + * diameter, and using the thread size under-predicts loss by several times. + * That is the mistake this file exists to stop, and it is why picking a size + * pre-fills a bore rather than leaving somebody to type the number on the + * label. + * + * Two different kinds of knowledge live here, and they are kept apart on + * purpose: + * + * **Tube is arithmetic.** You order tube by OD and wall, and the bore is + * `OD − 2 × wall`. Nothing is looked up and nothing is remembered: 1/2 × 0.035 + * is 0.430 in, and it is 0.430 in in every catalogue there has ever been. + * + * **Fitting standards are catalogue data.** The through-bore of a JIC 37° −8 or + * an ORB #8 is a manufacturer's number that varies by series, and it is not + * derivable from the size code. `THROUGH_BORE` is therefore declared and + * deliberately **empty**: a number invented here would arrive in feed-twin + * wearing a `source` and a reference, looking checked, and be wrong. Fill it in + * from catalogues, one cited row at a time. Until a row exists the dialog asks + * for the bore instead of guessing it. + */ + +export const MM_PER_IN = 25.4; + +/** Tube sizes we actually run, as ordered: OD × wall, in inches. */ +export const TUBE_SIZES: { od: number; wall: number; label: string }[] = [ + { od: 0.25, wall: 0.028, label: '1/4 × 0.028' }, + { od: 0.25, wall: 0.035, label: '1/4 × 0.035' }, + { od: 0.375, wall: 0.035, label: '3/8 × 0.035' }, + { od: 0.375, wall: 0.049, label: '3/8 × 0.049' }, + { od: 0.5, wall: 0.035, label: '1/2 × 0.035' }, + { od: 0.5, wall: 0.049, label: '1/2 × 0.049' }, + { od: 0.5, wall: 0.065, label: '1/2 × 0.065' }, + { od: 0.75, wall: 0.049, label: '3/4 × 0.049' }, + { od: 0.75, wall: 0.065, label: '3/4 × 0.065' }, + { od: 1.0, wall: 0.065, label: '1 × 0.065' }, +]; + +/** `OD − 2 × wall`, in millimetres. Arithmetic, not a lookup. */ +export const tubeBoreMm = (odIn: number, wallIn: number): number => + (odIn - 2 * wallIn) * MM_PER_IN; + +export function tubeByLabel(label: string) { + return TUBE_SIZES.find(t => t.label === label); +} + +/** The bore a tube size implies, to a sane number of figures. */ +export function boreForTube(label: string): number | null { + const t = tubeByLabel(label); + return t ? Math.round(tubeBoreMm(t.od, t.wall) * 1000) / 1000 : null; +} + +export type Standard = 'NPT' | 'JIC' | 'ORB' | 'AN'; + +/** + * Fitting standard → size code → through-bore, with the catalogue it came + * from. **Every row must cite a source.** Empty until somebody does that work; + * see the note at the top of this file for why it is not seeded from memory. + */ +export const THROUGH_BORE: Record> = { + NPT: {}, + JIC: {}, + ORB: {}, + AN: {}, +}; + +export interface BoreSuggestion { + mm: number; + /** Goes into the parameter's `reference`, so a run report can trace it. */ + reference: string; +} + +/** + * What bore to pre-fill for a size, or null if nothing here can say. + * + * Returned with the reasoning attached rather than as a bare number, so the + * value stored carries where it came from. Stored as `source: 'default'` by the + * caller: derived, not measured, and feed-twin's report should keep saying so + * until somebody puts a caliper on the part. + */ +export function suggestBore(kind: 'tube', size: string): BoreSuggestion | null; +export function suggestBore(kind: Standard, size: string): BoreSuggestion | null; +export function suggestBore(kind: 'tube' | Standard, size: string): BoreSuggestion | null { + if (kind === 'tube') { + const t = tubeByLabel(size); + if (!t) return null; + return { + mm: Math.round(tubeBoreMm(t.od, t.wall) * 1000) / 1000, + reference: `${size} tube, OD − 2 × wall`, + }; + } + const row = THROUGH_BORE[kind]?.[size]; + return row ? { mm: row.mm, reference: `${kind} ${size}, ${row.source}` } : null; +} diff --git a/pid-designer/frontend/src/components/pid/types.ts b/pid-designer/frontend/src/components/pid/types.ts index c9ef886c9..73fb2f2df 100644 --- a/pid-designer/frontend/src/components/pid/types.ts +++ b/pid-designer/frontend/src/components/pid/types.ts @@ -1,4 +1,5 @@ import type { ParamValue } from './params'; +import type { LineSegment } from './segments'; export type ComponentType = | 'RTD' | 'PT' | 'PG' | 'LC' | 'TC' @@ -68,6 +69,12 @@ export interface PIDNodeData { export interface PIDEdgeData { /** Which of feed-twin's branch components this run is. */ lineType?: 'pipe' | 'flex_hose' | 'bend' | 'fitting'; + /** + * What the run is actually made of: ordered by bore, with an unordered bag + * of fittings in each. Optional — a line without them behaves as it always + * has. See `segments.ts`. + */ + segments?: LineSegment[]; params?: Record; options?: Record; partNumber?: string; From 29670a7adc69dc11a1d05b5c439a72b49ef2a26e Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 11:17:21 -0700 Subject: [PATCH 22/57] pid-designer: how a line's loss is known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question behind this is "what does the computer need to calculate a line loss, and what is the quickest honest way to put it in". The answer is one selector, five methods, explicit precedence -- because a feed system's resistance is known *differently* at different stages, and both ends of that have to be first-class. Δp vs ṁ curve > measured K > fittings > estimated K > not stated Higher wins, only one applies, and the panel says which is in force. That is the point: a pile of fields where precedence is a guess is how a measured number and a guessed one get quietly added together. It also answers "must I itemise every elbow" -- no. Flow the line, type the number. Itemising is for what has not been built yet, which is most of a design. **A fitting's K already contains its own friction.** So fitting body length is not a loss input, and adding it to the pipe length counts the fitting twice -- about 4% of L on a 1.6 m run with three elbows, ~2% on Δp, the same order as miscounting one. The friction term takes the *tube* length. Body length and engagement are still worth recording, for the one thing they are actually for: the **cut list**. Measure a run end to end, and the tube to cut is `overall − Σ(body − engagement)`. It refuses to answer unless every fitting has a length, because a partial subtraction is a mis-cut part rather than an approximate one. **Fittings are ordered rows with counts.** Ordered even though same-bore order is worth 0.003%, because a fitting can carry its own bore and then the order is load-bearing -- and because a list matching the run as built is what somebody checks against the hardware. Each row carries bore, body length, engagement and a measured K of its own. **The catalogue keeps three kinds of number apart.** Arithmetic is implemented: a dash size is sixteenths of an inch of tube OD, a tube bores `OD − 2 × wall`. Catalogue data -- through-bore, body length, engagement -- is a library the team fills, and is deliberately **not** seeded: a bore invented here reaches feed-twin wearing a source and a reference, looking checked, and is wrong, which is worse than absent because absent is visible. Pick JIC -8 today and it says "no catalogue bore for JIC -8 — type it. Thread size is not flow diameter." Relief valves draw their set and reseat pressure on the symbol. It is the one component whose number is what a reader is scanning for -- whether the thing protecting a vessel lifts below what the vessel is rated to -- and two clicks into a dialog is two clicks nobody takes. The plan, the reasoning and three asks of feed-twin are in `docs/integration/line-loss-plan.md`. --- .../src/components/pid/SegmentPanel.tsx | 399 +++++++++++------- .../frontend/src/components/pid/catalog.ts | 195 +++++++++ .../src/components/pid/nodes/RVNode.tsx | 33 +- .../src/components/pid/segments.test.ts | 87 +++- .../frontend/src/components/pid/segments.ts | 109 ++++- .../frontend/src/components/pid/tubing.ts | 97 ----- pid-designer/frontend/src/lib/gating.test.ts | 1 + 7 files changed, 640 insertions(+), 281 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/catalog.ts delete mode 100644 pid-designer/frontend/src/components/pid/tubing.ts diff --git a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx index 718ac757d..b8dd88ddb 100644 --- a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx +++ b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx @@ -1,61 +1,61 @@ import { useMemo, useState } from 'react'; import { useReadOnly } from '@stardesign-ui'; -import { FITTING_KINDS, FITTING_LABELS, fittingCount, nextSegmentId, transitionsOf } from './segments'; -import type { FittingKind, LineSegment } from './segments'; -import { TUBE_SIZES, suggestBore } from './tubing'; +import { + FITTING_KINDS, FITTING_LABELS, LOSS_METHODS, + fittingCount, knownK, methodOf, nextRowId, nextSegmentId, transitionsOf, +} from './segments'; +import type { FittingRow, LineSegment, LossMethod } from './segments'; +import { STANDARDS, TUBE_SIZES, DASH_SIZES, NPT_SIZES, cutLength, loadCatalog, suggestBore, tubeOdForSize } from './catalog'; +import type { Standard } from './catalog'; import type { ParamValue } from './params'; /** - * What a line is made of: ordered segments, a bag of fittings in each. + * What a line is made of, and how its loss is known. * - * Flat on purpose -- no accordions, no wizard, no tabs. A segment is a row and - * its fittings are a strip under it, because that is the shape of the thing - * being described and anything cleverer gets in the way of typing four numbers. + * The method selector is the point of this panel. A feed system's resistance is + * known differently at different stages -- itemised while it is being designed, + * measured once it has been flowed -- and both have to be first-class with no + * ambiguity about which is in force. Choosing one hides the others' fields + * rather than leaving a pile of inputs where the precedence is a guess. * - * Two things are deliberately not asked for: - * - * **Where a fitting sits.** At one bore, rearranging fittings moves the answer - * by 0.003%. Adding one is a count. - * - * **The transition between segments.** Two adjacent bores *are* a reducer, so - * it is drawn as a consequence with its K, not as a row somebody can forget. + * Flat: a segment is a row and its fittings are rows under it. No accordions. */ const field = 'rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] px-2 py-1 text-xs text-[var(--color-text-primary)] outline-none focus:border-[var(--color-accent)]'; +const muted = 'text-[10px] text-[var(--color-text-muted)]'; -const num = (p?: ParamValue) => (p === undefined ? '' : String(p.value)); +const numOf = (p?: ParamValue) => (p === undefined ? '' : String(p.value)); export function SegmentPanel({ segments, onChange }: { segments: LineSegment[]; onChange: (next: LineSegment[]) => void; }) { const readOnly = useReadOnly(); + const catalog = useMemo(() => loadCatalog(), []); const transitions = useMemo(() => transitionsOf(segments), [segments]); const patch = (i: number, next: Partial) => onChange(segments.map((s, j) => (j === i ? { ...s, ...next } : s))); - const setParam = (i: number, key: 'bore' | 'length', raw: string, unit: string) => { + const setNum = (i: number, key: 'bore' | 'length' | 'K', raw: string, unit: string) => { const v = raw.trim(); if (v === '') { patch(i, { [key]: undefined } as Partial); return; } const value = Number(v); - if (!Number.isFinite(value)) return; - patch(i, { [key]: { value, unit, source: 'estimated' } } as Partial); + if (Number.isFinite(value)) { + patch(i, { [key]: { value, unit, source: 'estimated' } } as Partial); + } }; const addSegment = () => - onChange([...segments, { id: nextSegmentId(), fittings: {} }]); - - const removeSegment = (i: number) => - onChange(segments.filter((_, j) => j !== i)); + onChange([...segments, { id: nextSegmentId(), method: 'itemised', fittings: [], standard: 'tube' }]); - /** Picking a tube size fills the bore in, and says where it came from. */ - const pickTube = (i: number, label: string) => { - if (!label) { patch(i, { tubeSize: undefined }); return; } - const s = suggestBore('tube', label); + /** Picking a size fills the bore in, and records where it came from. */ + const pickSize = (i: number, standard: Standard, size: string) => { + if (!size) { patch(i, { standard, tubeSize: undefined }); return; } + const s = suggestBore(standard, size, catalog); patch(i, { - tubeSize: label, + standard, tubeSize: size, ...(s ? { bore: { value: s.mm, unit: 'mm', source: 'default', reference: s.reference } } : {}), }); }; @@ -63,175 +63,256 @@ export function SegmentPanel({ segments, onChange }: { return (
- - Segments - + Segments {segments.length === 0 && ( - - optional — the line uses its bore and length without them - + optional — the line uses its own bore and length )}
- {segments.map((seg, i) => ( -
-
-
- - {i + 1} - - - setParam(i, 'length', e.target.value, 'm')} - className={`${field} w-[62px] shrink-0`} - /> - m - setParam(i, 'bore', e.target.value, 'mm')} - className={`${field} w-[62px] shrink-0`} - title="Flow diameter — not the thread size" - /> - mm - + {segments.map((seg, i) => { + const method = methodOf(seg); + const standard = (seg.standard as Standard) ?? 'tube'; + const sizes = + standard === 'tube' ? TUBE_SIZES.map(t => t.label) + : standard === 'NPT' ? [...NPT_SIZES] + : DASH_SIZES.map(d => `-${d}`); + const boreKnown = seg.bore !== undefined; + const od = tubeOdForSize(standard, (seg.tubeSize ?? '').replace(/^-/, '')); + + return ( +
+
+ {/* size · length · bore */} +
+ {i + 1} + + + setNum(i, 'length', e.target.value, 'm')} + className={`${field} w-[62px] shrink-0`} /> + + setNum(i, 'bore', e.target.value, 'mm')} + className={`${field} w-[62px] shrink-0`} + title="Flow diameter — never the thread size" /> + mm + +
+ + {/* Why the bore says what it says, and the trap when it says nothing. */} + {seg.tubeSize && ( +

+ {boreKnown + ? seg.bore?.reference ?? 'bore set by hand' + : od + ? `no catalogue bore for ${standard} ${seg.tubeSize} — type it. Thread size is not flow diameter.` + : 'type the bore'} +

+ )} + + {/* How the loss is known */} +
+ loss from + + {LOSS_METHODS.find(m => m.id === method)?.note} +
+ + {(method === 'measured_K' || method === 'lumped_K') && ( +
+ K + setNum(i, 'K', e.target.value, '-')} + className={`${field} w-[72px]`} /> + {method === 'measured_K' && ( + supersedes any fittings below + )} +
+ )} + + {method === 'curve' && ( +

+ Δp against ṁ, entered in feed-twin against the run that produced it. +

+ )} + + {method === 'itemised' && ( + patch(i, { fittings: rows })} + /> + )} + +
- patch(i, { fittings: f })} - /> + {transitions[i] && ( +

+ ↓ {transitions[i]!.kind === 'contraction' ? 'reducer' : 'expander'}{' '} + {transitions[i]!.fromMm.toFixed(2)} → {transitions[i]!.toMm.toFixed(2)} mm + + derived, K {transitions[i]!.K.toFixed(2)} + +

+ )}
+ ); + })} - {transitions[i] && ( -

- ↓ {transitions[i]!.kind === 'contraction' ? 'reducer' : 'expander'}{' '} - {transitions[i]!.fromMm.toFixed(2)} → {transitions[i]!.toMm.toFixed(2)} mm - - derived, K {transitions[i]!.K.toFixed(2)} - -

- )} -
- ))} - -
); } -/** The fittings in one segment: kind and count, nothing about placement. */ -function FittingBag({ fittings, readOnly, onChange }: { - fittings: Partial>; +/** + * The straight tube to cut, when the run was measured end to end. + * + * Shown only when it can be answered: every fitting needs a length, because a + * partial subtraction is a mis-cut part rather than an approximate one. + */ +function CutList({ seg }: { seg: LineSegment }) { + if (seg.lengthBasis !== 'overall' || !seg.length) return null; + const overallMm = seg.length.value * (seg.length.unit === 'm' ? 1000 : 1); + const flat = (seg.fittings ?? []).flatMap(r => Array.from({ length: r.count }, () => r)); + const cut = cutLength(overallMm, flat); + return ( +

+ {cut === null + ? 'cut length needs a body length on every fitting' + : `cut ${(cut / 1000).toFixed(3)} m of tube · fittings occupy ${((overallMm - cut) / 1000).toFixed(3)} m`} +

+ ); +} + +/** The fittings in a segment: ordered rows, each with a count. */ +function FittingRows({ rows, readOnly, segmentBore, onChange }: { + rows: FittingRow[]; readOnly: boolean; - onChange: (f: Partial>) => void; + segmentBore?: number; + onChange: (rows: FittingRow[]) => void; }) { const [adding, setAdding] = useState(false); const [query, setQuery] = useState(''); + const [openRow, setOpenRow] = useState(null); - const present = (Object.keys(fittings) as FittingKind[]).filter(k => (fittings[k] ?? 0) > 0); const matches = FITTING_KINDS.filter(k => FITTING_LABELS[k].toLowerCase().includes(query.trim().toLowerCase())); - const bump = (k: FittingKind, by: number) => { - const n = (fittings[k] ?? 0) + by; - const next = { ...fittings }; - if (n <= 0) delete next[k]; - else next[k] = n; + const set = (id: string, patch: Partial) => + onChange(rows.map(r => (r.id === id ? { ...r, ...patch } : r))); + + const move = (i: number, by: number) => { + const j = i + by; + if (j < 0 || j >= rows.length) return; + const next = [...rows]; + [next[i], next[j]] = [next[j], next[i]]; onChange(next); }; + const numField = (v: number | undefined, onSet: (n: number | undefined) => void, ph: string) => ( + { + const t = e.target.value.trim(); + onSet(t === '' ? undefined : Number.isFinite(Number(t)) ? Number(t) : undefined); + }} + className={`${field} w-[62px]`} + /> + ); + return ( -
- {present.map(k => ( - - {FITTING_LABELS[k]} - - - +
+ {rows.map((r, i) => ( +
+
+ + + + + + {FITTING_LABELS[r.kind]} + {r.boreMm !== undefined && r.boreMm !== segmentBore && ( + · {r.boreMm} mm + )} + {r.K !== undefined && · K {r.K}} + + + + +
+ + {openRow === r.id && ( +
+ bore + {numField(r.boreMm, v => set(r.id, { boreMm: v }), segmentBore ? String(segmentBore) : 'mm')} + body + {numField(r.lengthMm, v => set(r.id, { lengthMm: v }), 'mm')} + engages + {numField(r.engagementMm, v => set(r.id, { engagementMm: v }), 'mm')} + K + {numField(r.K, v => set(r.id, { K: v }), 'meas.')} +
+ )} +
))} {adding ? ( - - + setQuery(e.target.value)} onBlur={() => window.setTimeout(() => { setAdding(false); setQuery(''); }, 120)} - className={`${field} w-[128px]`} - /> - + className={`${field} w-[150px]`} /> + {matches.map(k => ( - ))} - {matches.length === 0 && ( - - nothing matches - - )} + {matches.length === 0 && nothing matches} ) : ( - )} @@ -239,4 +320,4 @@ function FittingBag({ fittings, readOnly, onChange }: { ); } -export { fittingCount }; +export { fittingCount, knownK }; diff --git a/pid-designer/frontend/src/components/pid/catalog.ts b/pid-designer/frontend/src/components/pid/catalog.ts new file mode 100644 index 000000000..e85097e65 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/catalog.ts @@ -0,0 +1,195 @@ +/** + * The parts library: what a fitting is, dimensionally. + * + * Three kinds of number live here and they are kept apart, because they have + * very different claims to being right. + * + * **Arithmetic.** A dash size is 1/16 in of tube OD, and a tube's bore is + * `OD − 2 × wall`. Nothing is looked up and nothing is remembered. `-8` is 1/2 + * in tube in every catalogue that has ever existed. + * + * **Catalogue.** Through-bore, body length and thread engagement are + * manufacturer's numbers that vary by series. They are **not** seeded here. A + * bore invented in this file would reach feed-twin wearing a `source` and a + * `reference`, looking checked, and be wrong -- which is worse than absent, + * because absent is visible. The team enters their own parts once, from the + * catalogue on the desk, and every diagram uses them. + * + * **Derived.** Cut length, from an overall run length and the fittings in it. + * + * ## Where fitting length does and does not matter + * + * It does **not** go into the friction term. A fitting's K -- from Crane, + * Hooper, or geometry -- already accounts for the fitting's own friction, so + * adding its body length to the pipe length as well counts it twice. For a + * 1.6 m run with three elbows that is a ~4% over-count of L. + * + * It matters for exactly one thing, and it is worth having: **the cut list.** + * If somebody measured the run end to end, the straight tube is that length + * minus what the fittings occupy, plus what they screw in by. Getting the + * fabricator a cut length is the reason to know engagement -- not the pressure + * drop. + */ + +import type { FittingKind } from './segments'; + +export const MM_PER_IN = 25.4; + +/** A dash size is sixteenths of an inch of tube OD. Standard, not catalogue. */ +export const dashToTubeOdIn = (dash: number) => dash / 16; +export const dashToTubeOdMm = (dash: number) => dashToTubeOdIn(dash) * MM_PER_IN; + +export type Standard = 'tube' | 'JIC' | 'ORB' | 'NPT' | 'AN'; + +export const STANDARDS: { id: Standard; label: string; sizing: 'dash' | 'nominal' | 'tube' }[] = [ + { id: 'tube', label: 'Tube (OD × wall)', sizing: 'tube' }, + { id: 'JIC', label: 'JIC 37°', sizing: 'dash' }, + { id: 'AN', label: 'AN', sizing: 'dash' }, + { id: 'ORB', label: 'SAE ORB', sizing: 'dash' }, + { id: 'NPT', label: 'NPT', sizing: 'nominal' }, +]; + +/** Dash sizes we actually run. */ +export const DASH_SIZES = [2, 3, 4, 5, 6, 8, 10, 12, 16] as const; +/** NPT nominal sizes, as written on the part. */ +export const NPT_SIZES = ['1/8', '1/4', '3/8', '1/2', '3/4', '1'] as const; + +/** + * One catalogued part. + * + * `bore` is the only field feed-twin needs. `length` and `engagement` exist for + * the cut list, and both are optional -- a part with only a bore is a perfectly + * good entry. + */ +export interface CatalogPart { + id: string; + /** What it is, for the picker. */ + label: string; + standard: Standard; + /** Dash number, NPT nominal, or a tube label. */ + size: string; + kind?: FittingKind; + partNumber?: string; + /** Through-bore, mm. The flow diameter — never the thread size. */ + boreMm?: number; + /** Centreline length through the fitting body, mm. Cut list only. */ + lengthMm?: number; + /** How far the mating part screws or inserts in, mm. Cut list only. */ + engagementMm?: number; + /** Where these numbers came from. Required — that is the point of the file. */ + source: string; +} + +const STORE_KEY = 'pid.catalog.v1'; + +/** + * The team's parts, as entered. + * + * Held per browser for now, with import/export so a catalogue can be shared as + * a file. Promoting it to a document on the userdata volume -- so it is shared + * the way diagrams are -- is the obvious next step and is deliberately not done + * here: it wants an endpoint and a picker, and this unblocks the work today. + */ +export function loadCatalog(): CatalogPart[] { + try { + const raw = localStorage.getItem(STORE_KEY); + return raw ? (JSON.parse(raw) as CatalogPart[]) : []; + } catch { + return []; + } +} + +export function saveCatalog(parts: CatalogPart[]): void { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(parts)); + } catch { + /* private mode: the catalogue is a convenience, not the drawing */ + } +} + +let _part = 0; +export const nextPartId = () => `part_${Date.now().toString(36)}_${++_part}`; + +/** Tube sizes, as ordered. The bore follows by arithmetic. */ +export const TUBE_SIZES: { od: number; wall: number; label: string }[] = [ + { od: 0.25, wall: 0.028, label: '1/4 × 0.028' }, + { od: 0.25, wall: 0.035, label: '1/4 × 0.035' }, + { od: 0.375, wall: 0.035, label: '3/8 × 0.035' }, + { od: 0.375, wall: 0.049, label: '3/8 × 0.049' }, + { od: 0.5, wall: 0.035, label: '1/2 × 0.035' }, + { od: 0.5, wall: 0.049, label: '1/2 × 0.049' }, + { od: 0.5, wall: 0.065, label: '1/2 × 0.065' }, + { od: 0.75, wall: 0.049, label: '3/4 × 0.049' }, + { od: 0.75, wall: 0.065, label: '3/4 × 0.065' }, + { od: 1.0, wall: 0.065, label: '1 × 0.065' }, +]; + +export const tubeBoreMm = (odIn: number, wallIn: number) => (odIn - 2 * wallIn) * MM_PER_IN; + +export const tubeByLabel = (label: string) => TUBE_SIZES.find(t => t.label === label); + +export function boreForTube(label: string): number | null { + const t = tubeByLabel(label); + return t ? round3(tubeBoreMm(t.od, t.wall)) : null; +} + +const round3 = (n: number) => Math.round(n * 1000) / 1000; + +export interface BoreSuggestion { + mm: number; + /** Goes into the parameter's `reference`. */ + reference: string; + /** Arithmetic is trustworthy; a catalogue row is as good as its entry. */ + basis: 'arithmetic' | 'catalog'; +} + +/** + * What bore to pre-fill, and why — or null when nothing here can honestly say. + * + * Null is a feature. It makes the dialog ask, which is the correct behaviour + * when the alternative is a number nobody can cite. + */ +export function suggestBore( + standard: Standard, + size: string, + catalog: CatalogPart[], +): BoreSuggestion | null { + if (standard === 'tube') { + const t = tubeByLabel(size); + if (!t) return null; + return { + mm: round3(tubeBoreMm(t.od, t.wall)), + reference: `${size} tube, OD − 2 × wall`, + basis: 'arithmetic', + }; + } + const hit = catalog.find(p => p.standard === standard && p.size === size && p.boreMm !== undefined); + return hit + ? { mm: hit.boreMm!, reference: `${standard} ${size}, ${hit.source}`, basis: 'catalog' } + : null; +} + +/** The tube OD a size implies, for the "what does -8 mean" hint. */ +export function tubeOdForSize(standard: Standard, size: string): number | null { + if (standard === 'tube') return tubeByLabel(size)?.od ?? null; + const dash = Number(size); + return Number.isFinite(dash) && dash > 0 ? dashToTubeOdIn(dash) : null; +} + +/** + * Straight tube to cut, given an end-to-end measurement. + * + * `overall − Σ(body length − engagement)`. Returns null unless every fitting in + * the run has a length, because a partial answer here is a mis-cut part. + */ +export function cutLength( + overallMm: number, + fittings: { lengthMm?: number; engagementMm?: number }[], +): number | null { + let occupied = 0; + for (const f of fittings) { + if (f.lengthMm === undefined) return null; + occupied += f.lengthMm - (f.engagementMm ?? 0); + } + return round3(overallMm - occupied); +} diff --git a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx index cd7bbbe72..fd2bc8be7 100644 --- a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx @@ -5,9 +5,20 @@ import { DraggableLabel } from './DraggableLabel'; const W = 60, H = 60; +/** + * A relief valve, with its set pressure on the face of it. + * + * A relief is the one component whose *number* is what a reader is checking: + * whether the thing protecting a vessel lifts below what the vessel is rated + * to. Two clicks away in a dialog is two clicks nobody takes while scanning a + * sheet, so it is drawn. + */ export function RVNode({ id, data, selected }: NodeProps) { - const { label, labelOffset, rotation } = data as unknown as PIDNodeData; + const { label, labelOffset, rotation, params } = data as unknown as PIDNodeData; const stroke = selected ? '#3b82f6' : '#94a3b8'; + const set = params?.set_pressure; + const reseat = params?.reseat_pressure; + const spin = ((((rotation ?? 0) % 360) + 360) % 360); return (
@@ -22,6 +33,26 @@ export function RVNode({ id, data, selected }: NodeProps) { + {(set || reseat) && ( + + {set && <>{set.value} {set.unit}} + {reseat && ( + <> +
+ ↺ {reseat.value} {reseat.unit} + + )} +
+ )} +
); diff --git a/pid-designer/frontend/src/components/pid/segments.test.ts b/pid-designer/frontend/src/components/pid/segments.test.ts index aef1e85e8..b66ee2826 100644 --- a/pid-designer/frontend/src/components/pid/segments.test.ts +++ b/pid-designer/frontend/src/components/pid/segments.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { transitionBetween, transitionsOf, fittingCount, FITTING_KINDS, FITTING_LABELS } from './segments'; +import { transitionBetween, transitionsOf, fittingCount, knownK, methodOf, FITTING_KINDS, FITTING_LABELS } from './segments'; import type { LineSegment } from './segments'; -import { boreForTube, suggestBore, THROUGH_BORE, tubeBoreMm } from './tubing'; +import { boreForTube, cutLength, suggestBore, tubeBoreMm, dashToTubeOdIn } from './catalog'; import type { ParamValue } from './params'; const mm = (v: number): ParamValue => ({ value: v, unit: 'mm', source: 'measured' }); -const seg = (id: string, bore?: ParamValue, fittings = {}): LineSegment => - ({ id, bore, fittings }); +const seg = (id: string, bore?: ParamValue): LineSegment => ({ id, bore, fittings: [] }); describe('tube bore is arithmetic', () => { it('is OD minus two walls', () => { @@ -17,7 +16,7 @@ describe('tube bore is arithmetic', () => { }); it('carries where it came from, so a report can trace it', () => { - expect(suggestBore('tube', '1/2 × 0.035')!.reference).toContain('OD − 2 × wall'); + expect(suggestBore('tube', '1/2 × 0.035', [])!.reference).toContain('OD − 2 × wall'); }); it('says nothing for a size it does not know', () => { @@ -25,17 +24,55 @@ describe('tube bore is arithmetic', () => { }); }); -describe('fitting standards are catalogue data, not memory', () => { - it('ships empty rather than guessing', () => { - // A number invented here would reach feed-twin wearing a source and a - // reference, looking checked, and be wrong. - for (const std of Object.keys(THROUGH_BORE) as (keyof typeof THROUGH_BORE)[]) { - expect(Object.keys(THROUGH_BORE[std])).toEqual([]); - } +describe('what is standard, and what is catalogue', () => { + it('knows a dash size is sixteenths of an inch of tube OD', () => { + expect(dashToTubeOdIn(8)).toBeCloseTo(0.5, 6); + expect(dashToTubeOdIn(4)).toBeCloseTo(0.25, 6); }); - it('returns null so the dialog asks instead of guessing', () => { - expect(suggestBore('JIC', '-8')).toBeNull(); + it('will not invent a through-bore for a fitting standard', () => { + // A number made up here would reach feed-twin wearing a source and a + // reference, looking checked, and be wrong. Null makes the dialog ask. + expect(suggestBore('JIC', '-8', [])).toBeNull(); + expect(suggestBore('NPT', '3/8', [])).toBeNull(); + }); + + it('uses a catalogued part once the team has entered one', () => { + const hit = suggestBore('JIC', '-8', [{ + id: 'p1', label: 'JIC -8', standard: 'JIC', size: '-8', + boreMm: 9.4, source: 'Parker cat. 4300, p.12', + }])!; + expect(hit.mm).toBe(9.4); + expect(hit.basis).toBe('catalog'); + expect(hit.reference).toContain('Parker'); + }); + + it('marks tube arithmetic as arithmetic, not catalogue', () => { + expect(suggestBore('tube', '1/2 × 0.035', [])!.basis).toBe('arithmetic'); + }); +}); + +describe('the cut list', () => { + it('takes the fittings out of an end-to-end measurement', () => { + // 1000 mm overall, two fittings 30 long that each swallow 10 of tube. + expect(cutLength(1000, [ + { lengthMm: 30, engagementMm: 10 }, + { lengthMm: 30, engagementMm: 10 }, + ])).toBeCloseTo(960, 6); + }); + + it('refuses when a fitting has no length — a partial answer is a mis-cut part', () => { + expect(cutLength(1000, [{ lengthMm: 30 }, {}])).toBeNull(); + }); +}); + +describe('how a segment says its loss is known', () => { + it('defaults to itemised', () => { + expect(methodOf({ id: 'a' })).toBe('itemised'); + }); + + it('takes the stated method when there is one', () => { + expect(methodOf({ id: 'a', method: 'measured_K' })).toBe('measured_K'); }); }); @@ -75,13 +112,27 @@ describe('a change of bore is a derived transition', () => { }); describe('the fitting tally', () => { - it('counts the bag and the detailed ones together', () => { + it('counts every row by how many of it there are', () => { + const s: LineSegment = { + id: 'a', + fittings: [ + { id: 'r1', kind: 'elbow_90', count: 3 }, + { id: 'r2', kind: 'tee_run', count: 1 }, + ], + }; + expect(fittingCount(s)).toBe(4); + }); + + it('sums only the K this drawing actually knows', () => { + // Everything else is priced by feed-twin, which has the Reynolds number. const s: LineSegment = { id: 'a', - fittings: { elbow_90: 3, tee_run: 1 }, - detailed: [{ kind: 'contraction' }], + fittings: [ + { id: 'r1', kind: 'elbow_90', count: 2, K: 0.75 }, + { id: 'r2', kind: 'tee_run', count: 1 }, + ], }; - expect(fittingCount(s)).toBe(5); + expect(knownK(s)).toBeCloseTo(1.5, 6); }); it('names every kind feed-twin registers, and no others', () => { diff --git a/pid-designer/frontend/src/components/pid/segments.ts b/pid-designer/frontend/src/components/pid/segments.ts index 83418e5a2..7473502bc 100644 --- a/pid-designer/frontend/src/components/pid/segments.ts +++ b/pid-designer/frontend/src/components/pid/segments.ts @@ -58,6 +58,50 @@ export const FITTING_LABELS: Record = { elbow_45_crane: 'Elbow 45° (Crane f_T)', }; +/** + * How a segment's loss is known. + * + * The single most important thing in this file. A feed system gets built and + * tested, and the way its resistance is *known* changes as that happens: you + * start with an itemised guess, and once you have flowed it you have a number + * that beats every correlation. Both have to be first-class, and which one is + * in force must never be ambiguous. + * + * Ordered by authority. Higher wins, and only one applies: + * + * 1. `curve` — Δp against ṁ from a cold flow. Supersedes everything, and + * feed-twin refuses outside the measured range rather than + * extrapolating. + * 2. `measured_K`— one K fitted from a run. Same authority, less data. + * 3. `itemised` — tube size, length, fittings. feed-twin walks the K ladder. + * 4. `lumped_K` — one K somebody estimated. + * 5. `unstated` — nothing said; feed-twin defaults and reports it unchecked. + * + * This is also the answer to "do I have to itemise every elbow?" — no. Flow the + * line and enter the number. The itemised path is for what has not been built + * yet, which is most of a design. + */ +export type LossMethod = 'curve' | 'measured_K' | 'itemised' | 'lumped_K' | 'unstated'; + +export const LOSS_METHODS: { id: LossMethod; label: string; note: string }[] = [ + { id: 'itemised', label: 'Fittings', note: 'counted, priced by correlation' }, + { id: 'measured_K', label: 'Measured K', note: 'fitted from a flow test' }, + { id: 'curve', label: 'Δp vs ṁ curve', note: 'from a flow bench' }, + { id: 'lumped_K', label: 'Estimated K', note: 'one number, a guess' }, + { id: 'unstated', label: 'Not stated', note: 'feed-twin defaults it' }, +]; + +/** A measured Δp against ṁ table. Mirrors `feedtwin.model.Curve`. */ +export interface DpCurve { + /** Mass flow, ascending. */ + mdot: number[]; + mdotUnit: string; + /** Pressure drop at each flow. */ + dp: number[]; + dpUnit: string; + reference?: string; +} + /** A fitting that needs more than a count — its own bore, or a measured K. */ export interface FittingInstance { kind: FittingKind; @@ -75,6 +119,20 @@ export interface FittingInstance { export interface LineSegment { id: string; + /** How this segment's loss is known. Defaults to `itemised`. */ + method?: LossMethod; + /** For `measured_K` / `lumped_K`. */ + K?: ParamValue; + /** For `curve`. */ + curve?: DpCurve; + /** + * Whether `length` is the straight tube or the whole assembly. + * + * It matters because a fitting's K already contains its own friction, so the + * friction term must use the *tube* length. Measuring a run end to end is + * what people actually do, so both are accepted and the other is derived. + */ + lengthBasis?: 'tube' | 'overall'; /** The FLOW diameter, not the thread size. */ bore?: ParamValue; /** Developed length along the centreline. */ @@ -82,11 +140,35 @@ export interface LineSegment { roughness?: ParamValue; /** Signed; up is positive. */ elevation_change?: ParamValue; - /** Unordered: kind → how many. */ - fittings?: Partial>; - detailed?: FittingInstance[]; - /** What the bore was derived from, when it came from a tube size. */ + /** + * The fittings in this segment, in order. + * + * Ordered even though same-bore order is worth 0.003%, because a fitting can + * carry its own bore and then the order *is* load-bearing -- and because a + * list that matches the run as built is what somebody checks against the + * hardware. Each row still carries a count: three identical elbows are one + * row saying three, not three rows. + */ + fittings?: FittingRow[]; + /** What the bore was derived from, when it came from a size. */ tubeSize?: string; + standard?: string; +} + +/** One kind of fitting in a segment, and how many of it. */ +export interface FittingRow { + id: string; + kind: FittingKind; + count: number; + /** Overrides the segment bore for these. */ + boreMm?: number; + /** Centreline length, for the cut list. Never for the friction term. */ + lengthMm?: number; + engagementMm?: number; + /** A measured or published K for this fitting. Beats the correlation. */ + K?: number; + partId?: string; + partNumber?: string; } let _seg = 0; @@ -101,8 +183,23 @@ export function seedSegmentIds(segments: LineSegment[] | undefined): void { } export const fittingCount = (s: LineSegment): number => - Object.values(s.fittings ?? {}).reduce((n, v) => n + (v ?? 0), 0) + - (s.detailed?.length ?? 0); + (s.fittings ?? []).reduce((n, r) => n + (r.count || 0), 0); + +let _row = 0; +export const nextRowId = () => `fit_${++_row}`; + +/** The method actually in force, with the default made explicit. */ +export const methodOf = (s: LineSegment): LossMethod => s.method ?? 'itemised'; + +/** + * Fittings whose K this drawing already knows, summed. + * + * Only the ones carrying their own measured K -- everything else is priced by + * feed-twin, which has the Reynolds number and the correlation ladder. Shown so + * the header total is honest about what it does and does not include. + */ +export const knownK = (s: LineSegment): number => + (s.fittings ?? []).reduce((n, r) => n + (r.K !== undefined ? r.K * r.count : 0), 0); /** * A change of bore between two segments is a reducer or an expander. diff --git a/pid-designer/frontend/src/components/pid/tubing.ts b/pid-designer/frontend/src/components/pid/tubing.ts deleted file mode 100644 index 8e6919418..000000000 --- a/pid-designer/frontend/src/components/pid/tubing.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Where a bore comes from. - * - * **A 3/8″ NPT fitting does not have a 3/8″ bore.** Thread size is not flow - * diameter, and using the thread size under-predicts loss by several times. - * That is the mistake this file exists to stop, and it is why picking a size - * pre-fills a bore rather than leaving somebody to type the number on the - * label. - * - * Two different kinds of knowledge live here, and they are kept apart on - * purpose: - * - * **Tube is arithmetic.** You order tube by OD and wall, and the bore is - * `OD − 2 × wall`. Nothing is looked up and nothing is remembered: 1/2 × 0.035 - * is 0.430 in, and it is 0.430 in in every catalogue there has ever been. - * - * **Fitting standards are catalogue data.** The through-bore of a JIC 37° −8 or - * an ORB #8 is a manufacturer's number that varies by series, and it is not - * derivable from the size code. `THROUGH_BORE` is therefore declared and - * deliberately **empty**: a number invented here would arrive in feed-twin - * wearing a `source` and a reference, looking checked, and be wrong. Fill it in - * from catalogues, one cited row at a time. Until a row exists the dialog asks - * for the bore instead of guessing it. - */ - -export const MM_PER_IN = 25.4; - -/** Tube sizes we actually run, as ordered: OD × wall, in inches. */ -export const TUBE_SIZES: { od: number; wall: number; label: string }[] = [ - { od: 0.25, wall: 0.028, label: '1/4 × 0.028' }, - { od: 0.25, wall: 0.035, label: '1/4 × 0.035' }, - { od: 0.375, wall: 0.035, label: '3/8 × 0.035' }, - { od: 0.375, wall: 0.049, label: '3/8 × 0.049' }, - { od: 0.5, wall: 0.035, label: '1/2 × 0.035' }, - { od: 0.5, wall: 0.049, label: '1/2 × 0.049' }, - { od: 0.5, wall: 0.065, label: '1/2 × 0.065' }, - { od: 0.75, wall: 0.049, label: '3/4 × 0.049' }, - { od: 0.75, wall: 0.065, label: '3/4 × 0.065' }, - { od: 1.0, wall: 0.065, label: '1 × 0.065' }, -]; - -/** `OD − 2 × wall`, in millimetres. Arithmetic, not a lookup. */ -export const tubeBoreMm = (odIn: number, wallIn: number): number => - (odIn - 2 * wallIn) * MM_PER_IN; - -export function tubeByLabel(label: string) { - return TUBE_SIZES.find(t => t.label === label); -} - -/** The bore a tube size implies, to a sane number of figures. */ -export function boreForTube(label: string): number | null { - const t = tubeByLabel(label); - return t ? Math.round(tubeBoreMm(t.od, t.wall) * 1000) / 1000 : null; -} - -export type Standard = 'NPT' | 'JIC' | 'ORB' | 'AN'; - -/** - * Fitting standard → size code → through-bore, with the catalogue it came - * from. **Every row must cite a source.** Empty until somebody does that work; - * see the note at the top of this file for why it is not seeded from memory. - */ -export const THROUGH_BORE: Record> = { - NPT: {}, - JIC: {}, - ORB: {}, - AN: {}, -}; - -export interface BoreSuggestion { - mm: number; - /** Goes into the parameter's `reference`, so a run report can trace it. */ - reference: string; -} - -/** - * What bore to pre-fill for a size, or null if nothing here can say. - * - * Returned with the reasoning attached rather than as a bare number, so the - * value stored carries where it came from. Stored as `source: 'default'` by the - * caller: derived, not measured, and feed-twin's report should keep saying so - * until somebody puts a caliper on the part. - */ -export function suggestBore(kind: 'tube', size: string): BoreSuggestion | null; -export function suggestBore(kind: Standard, size: string): BoreSuggestion | null; -export function suggestBore(kind: 'tube' | Standard, size: string): BoreSuggestion | null { - if (kind === 'tube') { - const t = tubeByLabel(size); - if (!t) return null; - return { - mm: Math.round(tubeBoreMm(t.od, t.wall) * 1000) / 1000, - reference: `${size} tube, OD − 2 × wall`, - }; - } - const row = THROUGH_BORE[kind]?.[size]; - return row ? { mm: row.mm, reference: `${kind} ${size}, ${row.source}` } : null; -} diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts index b8d857948..d6400fb30 100644 --- a/pid-designer/frontend/src/lib/gating.test.ts +++ b/pid-designer/frontend/src/lib/gating.test.ts @@ -60,6 +60,7 @@ const VIEW_ONLY: Record = { 'ChecksPanel.tsx:onClick={() => setOpen(o => !o)}': 'opens the checks panel; it reads the diagram and writes nothing', 'ChecksPanel.tsx:onClick={() => setOpen(false)}': 'closes the checks panel', 'ChecksPanel.tsx:onSelect(finding.nodeIds': 'selects what a finding is about — selection is view state, stripped by toStored', + 'SegmentPanel.tsx:setOpenRow(openRow === r.id': 'expands a fitting row to show its fields; the fields themselves are gated', 'ConfigDialog.tsx:onClick={onClose}': 'Cancel closes the config dialog; Save is what writes, and Save is gated', 'PIDDesigner.tsx:setShowChange(true)': 'opens the Change dialog (rename/share/copy are not gated by design)', } From 05482d491894844d3e03b2e280acbcf9bc38f6fd Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 11:34:38 -0700 Subject: [PATCH 23/57] pid-designer: draw the flow path, so a tally can be checked A list of fittings is a claim about a piece of hardware, and until you can see it there is no way to tell whether the program read it the way you meant. The line panel now draws the inner wall about the centreline, beside the editor that produces it: where the bore steps, which way a reducer faces, how much tube an engagement swallows, where the run turns. A flow path is axisymmetric, so one radius against distance carries all of it. The exception is a bend, where what matters is not the section but that the path *turns* -- so the centreline turns and the wall follows it round. Three things it is careful about: **The radius is exaggerated, and it says so.** At true scale a 1.6 m run at 10 mm bore is 160:1 and the bore is a hairline. Length and radius carry their own scales, both stated, the way a bore profile is drawn anywhere else. What is preserved exactly is what the picture is for: the ratio between bores, and where along the run each change happens. **Bends alternate direction.** Which way a real run turns is not in the tally and the drawing must not imply it -- and turning every bend the same way spirals the path back over itself, hiding the elements this exists to show. **An assumed number is drawn as assumed.** A fitting with no stated body length still has to occupy something, so it is given a plausible one and marked amber. The picture never shows a guess as though it were measured. Nothing here feeds a calculation, and it is built from the same fields feed-twin reads: if the picture is wrong, the model is wrong. A segment whose loss is a measured K or a curve draws no fittings at all -- they are not what is being modelled there, and showing them would claim the solve uses them. --- .../src/components/pid/BoreProfile.tsx | 150 +++++++++++ .../src/components/pid/ConfigDialog.tsx | 10 +- .../src/components/pid/flowPath.test.ts | 111 ++++++++ .../frontend/src/components/pid/flowPath.ts | 245 ++++++++++++++++++ 4 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 pid-designer/frontend/src/components/pid/BoreProfile.tsx create mode 100644 pid-designer/frontend/src/components/pid/flowPath.test.ts create mode 100644 pid-designer/frontend/src/components/pid/flowPath.ts diff --git a/pid-designer/frontend/src/components/pid/BoreProfile.tsx b/pid-designer/frontend/src/components/pid/BoreProfile.tsx new file mode 100644 index 000000000..857b23563 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/BoreProfile.tsx @@ -0,0 +1,150 @@ +import { useMemo, useState } from 'react'; +import { buildElements, buildWalls, wallPoints } from './flowPath'; +import type { LineSegment } from './segments'; + +/** + * The flow path, drawn. + * + * Its whole job is to let somebody check that the program read their tally the + * way they meant it. A wrong bore, a reducer facing the wrong way, an + * engagement swallowing more tube than expected -- all arithmetic until you see + * the shape, and obvious afterwards. + * + * **The radius is exaggerated, and it says so.** A 1.6 m run at 10 mm bore is + * 160:1; drawn true to scale the bore is a hairline and the picture says + * nothing. Length and radius therefore carry their own scales, both stated, the + * way a bore profile is drawn anywhere else. What is preserved exactly is what + * the picture is *for*: the ratios between bores, and where along the run each + * change happens. + */ + +const BORE = '#38bdf8'; +const BORE_FILL = 'rgba(56,189,248,0.13)'; +const ASSUMED = '#f59e0b'; + +export function BoreProfile({ segments }: { segments: LineSegment[] }) { + const [hover, setHover] = useState(null); + + const model = useMemo(() => { + const elements = buildElements(segments); + return { elements, walls: buildWalls(elements) }; + }, [segments]); + + const { elements, walls } = model; + + if (elements.length === 0 || walls.stations.length === 0) { + return ( +
+

+ Add a segment and the flow path is drawn here, +
so you can see what will be solved. +

+
+ ); + } + + const W = 300, H = 260, PAD = 18; + + // Independent scales: at true scale a 1.6 m run at 10 mm bore is 160:1 and + // the bore is a hairline. Exaggerate the radius, and say by how much. + const maxR = Math.max(...walls.stations.map(s => s.r), 1e-6); + + // Radius blown up until the widest bore reads at a sensible size, capped so + // a short run does not become a balloon. + const rScale = Math.min(24, Math.max(1, (H * 0.22) / maxR)); + const scaled = buildWalls(elements); + const pts = wallPoints(scaled.stations, rScale); + + const all = [...pts.left, ...pts.right]; + const xs = all.map(p => p[0]); + const ys = all.map(p => p[1]); + const minX = Math.min(...xs), maxX = Math.max(...xs); + const minY = Math.min(...ys), maxY = Math.max(...ys); + const k = Math.min((W - PAD * 2) / Math.max(1e-6, maxX - minX), + (H - PAD * 2) / Math.max(1e-6, maxY - minY)); + const tx = (x: number) => PAD + (x - minX) * k; + const ty = (y: number) => PAD + (y - minY) * k; + + const path = + 'M ' + pts.left.map(p => `${tx(p[0]).toFixed(2)},${ty(p[1]).toFixed(2)}`).join(' L ') + + ' L ' + [...pts.right].reverse().map(p => `${tx(p[0]).toFixed(2)},${ty(p[1]).toFixed(2)}`).join(' L ') + + ' Z'; + + const centre = 'M ' + scaled.stations + .map(s => `${tx(s.x).toFixed(2)},${ty(s.y).toFixed(2)}`).join(' L '); + + const anyAssumed = elements.some(e => e.assumed); + const hovered = walls.spans.find(s => s.element.id === hover); + + return ( +
+
+ + Flow path + + + {(walls.length / 1000).toFixed(3)} m + +
+ + + + + + {/* Where each element begins, and what it is. */} + {walls.spans.map(({ element, mid }) => { + const s = scaled.stations.find(p => Math.hypot(p.x - mid.x, p.y - mid.y) < 1e-6) + ?? scaled.stations[0]; + const nx = -Math.sin(s.heading), ny = Math.cos(s.heading); + const on = hover === element.id; + return ( + setHover(element.id)} + onMouseLeave={() => setHover(null)}> + {element.kind !== 'tube' && ( + + )} + {/* A fat invisible target, so hovering a thin fitting works. */} + + {on && ( + + )} + + ); + })} + + +

+ radius ×{rScale.toFixed(1)} · widest bore {(maxR * 2).toFixed(2)} mm + {anyAssumed && · amber = assumed} +

+ +

+ {hovered ? ( + <> + {hovered.element.label} + {' · '}⌀{(hovered.element.rStart * 2).toFixed(2)} + {hovered.element.rEnd !== hovered.element.rStart && + ` → ${(hovered.element.rEnd * 2).toFixed(2)}`} mm + {' · '}{hovered.element.length.toFixed(1)} mm long + {hovered.element.engagement !== undefined && + ` · engages ${hovered.element.engagement} mm`} + {hovered.element.assumed && · assumed} + + ) : ( + Hover a station to read it. + )} +

+
+ ); +} diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index 28f41232b..95183e554 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -9,6 +9,7 @@ import { portIds } from './ports'; import type { PortInfo, PortKind } from './ports'; import { speciesById } from './fluids'; import { SegmentPanel } from './SegmentPanel'; +import { BoreProfile } from './BoreProfile'; import { fittingCount, transitionsOf } from './segments'; import type { LineSegment } from './segments'; import type { ComponentType, PIDNodeData } from './types'; @@ -151,7 +152,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav open={open} onClose={onClose} title={title} - width={kind === 'edge' ? "w-[560px]" : "w-[420px]"} + width={kind === 'edge' ? "w-[900px]" : "w-[420px]"} footer={
@@ -159,6 +160,12 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav
} > +
+ {kind === 'edge' && ( +
+ +
+ )}
{kind === 'edge' && ( @@ -239,6 +246,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav /> ))}
+
); } diff --git a/pid-designer/frontend/src/components/pid/flowPath.test.ts b/pid-designer/frontend/src/components/pid/flowPath.test.ts new file mode 100644 index 000000000..0759ec83b --- /dev/null +++ b/pid-designer/frontend/src/components/pid/flowPath.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { buildElements, buildWalls, wallPoints } from './flowPath'; +import type { LineSegment } from './segments'; + +const P = (v: number, u = 'mm') => ({ value: v, unit: u, source: 'measured' as const }); + +const seg = (over: Partial = {}): LineSegment => ({ + id: 's1', method: 'itemised', bore: P(10), length: P(1, 'm'), fittings: [], ...over, +}); + +describe('what the picture is made of', () => { + it('is a bare run of tube when nothing is fitted', () => { + const els = buildElements([seg()]); + expect(els).toHaveLength(1); + expect(els[0].kind).toBe('tube'); + expect(els[0].rStart).toBe(5); + }); + + it('puts tube between the fittings, in the order they are listed', () => { + const els = buildElements([seg({ + fittings: [{ id: 'a', kind: 'elbow_90', count: 2, lengthMm: 20 }], + })]); + // tube, elbow, tube, elbow, tube + expect(els.map(e => e.kind)).toEqual(['tube', 'fitting', 'tube', 'fitting', 'tube']); + }); + + it('expands a count, so three elbows are three turns', () => { + const els = buildElements([seg({ + fittings: [{ id: 'a', kind: 'elbow_90', count: 3, lengthMm: 20 }], + })]); + expect(els.filter(e => e.turn).length).toBe(3); + }); + + it('draws a bore change between segments as the taper it is', () => { + const els = buildElements([ + seg({ id: 's1', bore: P(10) }), + seg({ id: 's2', bore: P(6) }), + ]); + const t = els.find(e => e.kind === 'transition')!; + expect(t.label).toBe('reducer'); + expect(t.rStart).toBe(5); + expect(t.rEnd).toBe(3); + }); + + it('takes the fittings out of an overall length, but not a tube length', () => { + const fittings = [{ id: 'a', kind: 'elbow_90' as const, count: 2, lengthMm: 50 }]; + const overall = buildElements([seg({ lengthBasis: 'overall', fittings })]); + const tube = buildElements([seg({ lengthBasis: 'tube', fittings })]); + const sum = (els: ReturnType) => + els.filter(e => e.kind === 'tube').reduce((n, e) => n + e.length, 0); + expect(sum(overall)).toBeCloseTo(900, 6); // 1000 − 2 × 50 + expect(sum(tube)).toBeCloseTo(1000, 6); + }); + + it('marks an assumed length rather than pretending it was stated', () => { + const els = buildElements([seg({ + fittings: [{ id: 'a', kind: 'tee_run', count: 1 }], // no lengthMm + })]); + expect(els.find(e => e.kind === 'fitting')!.assumed).toBe(true); + }); + + it('draws nothing from a segment whose loss is a measured number', () => { + // The fittings are not what is being modelled there, so showing them + // would claim the solve uses them. + const els = buildElements([seg({ + method: 'measured_K', + fittings: [{ id: 'a', kind: 'elbow_90', count: 4, lengthMm: 20 }], + })]); + expect(els.filter(e => e.kind === 'fitting')).toEqual([]); + }); +}); + +describe('the centreline', () => { + it('runs straight when nothing turns', () => { + const w = buildWalls(buildElements([seg()])); + expect(w.stations.every(s => Math.abs(s.y) < 1e-9)).toBe(true); + expect(w.length).toBeCloseTo(1000, 6); + }); + + it('turns through a bend, and by its angle', () => { + const w = buildWalls(buildElements([seg({ + fittings: [{ id: 'a', kind: 'elbow_90', count: 1, lengthMm: 20 }], + })])); + const last = w.stations[w.stations.length - 1]; + expect(Math.abs(Math.abs(last.heading) - Math.PI / 2)).toBeLessThan(1e-6); + }); + + it('alternates the way it turns, so the path cannot spiral onto itself', () => { + const w = buildWalls(buildElements([seg({ + fittings: [{ id: 'a', kind: 'elbow_90', count: 2, lengthMm: 20 }], + })])); + // Two opposite 90° turns come back to the original heading. + const last = w.stations[w.stations.length - 1]; + expect(Math.abs(last.heading)).toBeLessThan(1e-6); + }); + + it('offsets both walls by the radius, so a reducer looks like one', () => { + const els = buildElements([seg({ id: 's1', bore: P(10) }), seg({ id: 's2', bore: P(4) })]); + const w = buildWalls(els); + const { left, right } = wallPoints(w.stations); + const width = (i: number) => Math.hypot(left[i][0] - right[i][0], left[i][1] - right[i][1]); + expect(width(0)).toBeCloseTo(10, 6); + expect(width(left.length - 1)).toBeCloseTo(4, 6); + }); + + it('survives a segment with nothing filled in', () => { + const w = buildWalls(buildElements([{ id: 's', fittings: [] }])); + expect(w.stations.length).toBeGreaterThan(0); + expect(Number.isFinite(w.length)).toBe(true); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/flowPath.ts b/pid-designer/frontend/src/components/pid/flowPath.ts new file mode 100644 index 000000000..6ff8e3e88 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/flowPath.ts @@ -0,0 +1,245 @@ +import { FITTING_LABELS, methodOf } from './segments'; +import type { FittingRow, LineSegment } from './segments'; + +/** + * The flow path as a picture: the inner wall, plotted about the centreline. + * + * A tally of fittings is a claim about a piece of hardware, and until you can + * see it there is no way to tell whether the program read it the way you meant. + * This turns the tally back into a shape: where the bore steps, which way a + * reducer goes, how much tube an engagement swallows, where the run turns. + * + * A flow path is axisymmetric, so half of it carries all the information -- + * one radius against distance along the centreline. The exception is a bend, + * where what matters is not the section but that the path *turns*, so the + * centreline turns and the wall follows it round. + * + * Nothing here feeds a calculation. It exists to be looked at, and it is + * deliberately built from the same fields feed-twin reads so that what you see + * is what will be solved -- if the picture is wrong, the model is wrong. + */ + +/** Drawing conventions, not physics. The K comes from the correlation. */ +const DEFAULT_BEND_RD = 1.5; +/** A fitting with no stated body length still has to occupy something. */ +const ASSUMED_BODY_D = 2.5; +/** How long a bore change is drawn as taking. */ +const TRANSITION_D = 0.6; + +/** Which kinds turn the centreline, and by how much. */ +const TURN: Partial> = { + elbow_90: 90, + elbow_45: 45, + elbow_90_crane: 90, + elbow_45_crane: 45, + bend: 90, +}; + +export interface Element { + id: string; + kind: 'tube' | 'fitting' | 'transition'; + label: string; + /** Along the centreline, mm. */ + length: number; + /** Inner radius at each end, mm. Unequal makes a taper. */ + rStart: number; + rEnd: number; + /** Degrees the centreline turns through this element. */ + turn?: number; + /** How far the neighbouring tube is swallowed, mm. Drawn, not subtracted. */ + engagement?: number; + /** True when a length or bore was assumed rather than stated. */ + assumed?: boolean; +} + +const MM: Record = { mm: 1, m: 1000, cm: 10, in: 25.4, ft: 304.8 }; +const toMm = (p?: { value: number; unit: string }): number | null => + p && MM[p.unit] !== undefined ? p.value * MM[p.unit] : null; + +/** + * Flatten segments and their fittings into what the picture is made of. + * + * A segment becomes a run of tube with its fittings in order along it. Fittings + * are drawn where the tally puts them, which is the point: a list that reads + * left to right is what somebody checks against the hardware. + */ +export function buildElements(segments: LineSegment[]): Element[] { + const out: Element[] = []; + + segments.forEach((seg, si) => { + const segBore = toMm(seg.bore); + const r = segBore !== null ? segBore / 2 : null; + const rows = methodOf(seg) === 'itemised' ? seg.fittings ?? [] : []; + + // Fittings, expanded by count so three elbows are three turns. + const fittings: FittingRow[] = rows.flatMap(row => + Array.from({ length: Math.max(0, row.count) }, () => row)); + + const totalMm = toMm(seg.length); + const occupied = fittings.reduce( + (n, f) => n + (f.lengthMm ?? (r ? r * 2 * ASSUMED_BODY_D : 0)), 0); + + // Tube between the fittings. When the length is stated as the whole + // assembly, what is left after the fittings is the tube; when it is the + // tube itself, that is what it is. + const tubeTotal = totalMm === null + ? null + : seg.lengthBasis === 'overall' + ? Math.max(0, totalMm - occupied) + : totalMm; + const pieces = fittings.length + 1; + const each = tubeTotal === null ? null : tubeTotal / pieces; + + const rr = r ?? 5; // something to draw with + const assumedBore = r === null; + + const tube = (n: number) => ({ + id: `${seg.id}-t${n}`, + kind: 'tube' as const, + label: seg.tubeSize ?? 'tube', + length: each ?? rr * 2 * 6, + rStart: rr, rEnd: rr, + assumed: assumedBore || each === null, + }); + + out.push(tube(0)); + fittings.forEach((f, fi) => { + const fr = f.boreMm !== undefined ? f.boreMm / 2 : rr; + const body = f.lengthMm ?? fr * 2 * ASSUMED_BODY_D; + out.push({ + id: `${seg.id}-f${fi}`, + kind: 'fitting', + label: FITTING_LABELS[f.kind] ?? f.kind, + length: body, + rStart: fr, rEnd: fr, + turn: TURN[f.kind], + engagement: f.engagementMm, + assumed: f.lengthMm === undefined, + }); + out.push(tube(fi + 1)); + }); + + // The change of bore into the next segment, drawn as the taper it is. + const next = segments[si + 1]; + if (next) { + const nb = toMm(next.bore); + if (segBore !== null && nb !== null && Math.abs(segBore - nb) > 1e-9) { + out.push({ + id: `${seg.id}-x`, + kind: 'transition', + label: nb < segBore ? 'reducer' : 'expander', + length: Math.max(segBore, nb) * TRANSITION_D, + rStart: segBore / 2, + rEnd: nb / 2, + }); + } + } + }); + + return out; +} + +export interface Station { + x: number; + y: number; + /** Radians. */ + heading: number; + r: number; +} + +export interface Walls { + /** Sampled along the path, in order. */ + stations: Station[]; + /** One entry per element, marking where it starts and ends along the path. */ + spans: { element: Element; from: number; to: number; mid: Station }[]; + bounds: { minX: number; maxX: number; minY: number; maxY: number }; + /** Total developed length, mm. */ + length: number; +} + +/** + * Walk the elements, turning them into a centreline with a radius on it. + * + * Straight elements advance along the heading; a bend swings the heading round + * an arc of `r/D · D` so the turn has a believable radius rather than being a + * corner. The wall is then the centreline offset by ±r, which is what makes a + * reducer look like a reducer. + */ +export function buildWalls(elements: Element[], arcSteps = 10): Walls { + const stations: Station[] = []; + const spans: Walls['spans'] = []; + let x = 0, y = 0, heading = 0, travelled = 0; + // Bends alternate direction. Which way a real run turns is not in the tally + // and the drawing must not imply it -- and turning every bend the same way + // spirals the path back over itself, which hides the very elements this + // picture exists to show. Snaking never self-intersects. + let turnSign = 1; + + const push = (s: Station) => { stations.push(s); return s; }; + + for (const el of elements) { + const from = travelled; + const startIndex = stations.length; + + if (el.turn) { + // An arc, so the picture shows the path turning rather than kinking. + const turn = (el.turn * Math.PI) / 180; + const radius = Math.max(el.rStart * 2 * DEFAULT_BEND_RD, 1e-6); + const sign = turnSign; + turnSign = -turnSign; + const cx = x - Math.sin(heading) * radius * sign; + const cy = y + Math.cos(heading) * radius * sign; + const start = Math.atan2(y - cy, x - cx); + for (let i = 1; i <= arcSteps; i++) { + const t = i / arcSteps; + const a = start + turn * t * sign; + push({ + x: cx + Math.cos(a) * radius, + y: cy + Math.sin(a) * radius, + heading: heading + turn * t * sign, + r: el.rStart + (el.rEnd - el.rStart) * t, + }); + } + const last = stations[stations.length - 1]; + x = last.x; y = last.y; heading = last.heading; + travelled += radius * Math.abs(turn); + } else { + if (stations.length === 0) push({ x, y, heading, r: el.rStart }); + else stations.push({ x, y, heading, r: el.rStart }); + x += Math.cos(heading) * el.length; + y += Math.sin(heading) * el.length; + push({ x, y, heading, r: el.rEnd }); + travelled += el.length; + } + + const mid = stations[Math.max(0, Math.floor((startIndex + stations.length - 1) / 2))]; + spans.push({ element: el, from, to: travelled, mid }); + } + + if (stations.length === 0) { + return { + stations: [], spans: [], + bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, length: 0, + }; + } + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const s of stations) { + minX = Math.min(minX, s.x - s.r); maxX = Math.max(maxX, s.x + s.r); + minY = Math.min(minY, s.y - s.r); maxY = Math.max(maxY, s.y + s.r); + } + return { stations, spans, bounds: { minX, maxX, minY, maxY }, length: travelled }; +} + +/** The two walls, as point lists, offsetting the centreline by ±r. */ +export function wallPoints(stations: Station[], rScale = 1) { + const left: [number, number][] = []; + const right: [number, number][] = []; + for (const s of stations) { + const nx = -Math.sin(s.heading); + const ny = Math.cos(s.heading); + left.push([s.x + nx * s.r * rScale, s.y + ny * s.r * rScale]); + right.push([s.x - nx * s.r * rScale, s.y - ny * s.r * rScale]); + } + return { left, right }; +} From 4c61011f8774dfd903b6e528cd1975cbf8c257da Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 11:46:55 -0700 Subject: [PATCH 24/57] pid-designer: a junction needs the tool, not a click Putting a junction on a line was the plain click handler on that line. So a double-click -- which is two clicks -- inserted two junctions and *then* opened a dialog for an edge that no longer existed, and clicking around a drawing to look at things scattered them everywhere. A gesture that rewrites the graph cannot be the same gesture as "look at this". Junction is a tool now, beside Paint: arm it, click a line, press Escape when done. Same shape as the paint bucket, so there is one idea to learn rather than two, and only one tool can be armed at a time. While a tool is armed double-click does not open a config -- you are placing, not reading. The hover dot that followed the pointer along a line is gone unless the tool is armed. It was advertising a click that should never have been there. The gating audit gains a check that `onClickBranch` returns early unless the tool is armed; it fails against the code before this commit. --- .../src/components/pid/BranchableEdge.tsx | 15 +++-- .../src/components/pid/PIDDesigner.tsx | 61 +++++++++++++------ .../src/components/pid/ToolContext.tsx | 24 ++++++++ pid-designer/frontend/src/lib/gating.test.ts | 17 ++++++ 4 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/ToolContext.tsx diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx index b7763deca..a073404eb 100644 --- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx +++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx @@ -10,6 +10,7 @@ import { import { nextJunctionId } from './ids'; import { useEdgeFluidColor } from './FluidContext'; import { useReadOnly } from '@stardesign-ui'; +import { useTool } from './ToolContext'; const J_HALF = 5; @@ -46,6 +47,8 @@ export function BranchableEdge(props: EdgeProps) { const { setNodes, setEdges, getZoom } = useReactFlow(); const readOnly = useReadOnly(); + // A junction only goes in while the tool is armed. See ToolContext. + const armed = useTool() === 'junction' && !readOnly; const [hoverAt, setHoverAt] = useState<{ x: number; y: number } | null>(null); const [dragging, setDragging] = useState(false); const dragFrom = useRef<{ pointer: number; offset: number } | null>(null); @@ -126,7 +129,7 @@ export function BranchableEdge(props: EdgeProps) { * path is orthogonal, so snapping to it is a clamp per segment. */ const onMouseMove = useCallback((e: React.MouseEvent) => { - if (readOnly || dragging) return; + if (!armed || dragging) return; const svg = (e.currentTarget as SVGElement).closest('svg'); if (!svg) return; const pt = svg.createSVGPoint(); @@ -134,10 +137,10 @@ export function BranchableEdge(props: EdgeProps) { pt.y = e.clientY; const p = pt.matrixTransform(svg.getScreenCTM()!.inverse()); setHoverAt(nearestOnPath(edgePath, p)); - }, [readOnly, dragging, edgePath]); + }, [armed, dragging, edgePath]); const onClickBranch = useCallback((e: React.MouseEvent) => { - if (readOnly || !hoverAt || dragging) return; + if (!armed || !hoverAt || dragging) return; e.stopPropagation(); const junctionId = nextJunctionId(); @@ -166,20 +169,20 @@ export function BranchableEdge(props: EdgeProps) { return [...rest, toJunction, fromJunction]; }); }); - }, [readOnly, hoverAt, dragging, id, source, target, data, setNodes, setEdges]); + }, [armed, hoverAt, dragging, id, source, target, data, setNodes, setEdges]); return ( setHoverAt(null)} onClick={onClickBranch} - style={{ cursor: hoverAt ? 'crosshair' : 'pointer' }} + style={{ cursor: armed ? 'crosshair' : 'pointer' }} > {/* Invisible fat hit area, so a 2 px line can be clicked at all. */} - {hoverAt && !dragging && ( + {armed && hoverAt && !dragging && ( ([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - // The paint bucket: a colour, and whether clicking applies it. - const [paint, setPaint] = useState<{ on: boolean; colour: string | null }>({ on: false, colour: '#22c55e' }); - const paintRef = useRef(paint); - paintRef.current = paint; + // Which tool is armed, and the paint colour it uses. One at a time: two + // tools both claiming a click is how junctions ended up scattered across + // drawings in the first place. + const [tool, setTool] = useState('none'); + const [colour, setColour] = useState('#22c55e'); + const paintRef = useRef({ on: false, colour }); + paintRef.current = { on: tool === 'paint', colour }; + const toolRef = useRef(tool); + toolRef.current = tool; const [page, setPage] = useState(DEFAULT_PAGE); // Pages people made but have not drawn on yet. Everything else is derived @@ -565,21 +572,19 @@ function PIDCanvas({ if (paintIfArmed('edge', edge.id)) { e.stopPropagation(); e.preventDefault(); } }, [paintIfArmed]); - // Escape puts the brush down. + // Escape puts any tool down. useEffect(() => { - if (!paint.on) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setPaint(p => ({ ...p, on: false })); - }; + if (tool === 'none') return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setTool('none'); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [paint.on]); + }, [tool]); const onNodeDoubleClick = useCallback((_e: React.MouseEvent, node: Node) => { const type = (node.data as unknown as PIDNodeData)?.componentType; // Text and junctions have nothing to configure; opening an empty dialog on // them would only teach people that double-click does nothing. - if (!type || !COMPONENT_SPECS[type]) return; + if (!type || !COMPONENT_SPECS[type] || toolRef.current !== 'none') return; setConfigFor({ kind: 'node', id: node.id }); }, []); @@ -587,6 +592,7 @@ function PIDCanvas({ // edge carried a colour and nothing else, so its length, bore and roughness // -- where most of the pressure drop actually is -- had nowhere to live. const onEdgeDoubleClick = useCallback((_e: React.MouseEvent, edge: Edge) => { + if (toolRef.current !== 'none') return; setConfigFor({ kind: 'edge', id: edge.id }); }, []); @@ -647,10 +653,11 @@ function PIDCanvas({ // against the whole area including the tabs.
setColorMenu(null)} >
+ - Drag from sidebar · Connect handles · V=Pan B=Box select · Cmd+click to multi-select · R=Rotate · Double-click to configure · Right-click to colour · Delete removes selection + Drag from sidebar · Connect handles · V=Pan B=Box select · Cmd+click to multi-select · R=Rotate · Double-click to configure · Right-click to colour · Junction tool branches a line · Delete removes selection +
setPaint(p => ({ ...p, colour: c }))} - onToggle={on => setPaint(p => ({ ...p, on }))} + colour={colour} + active={tool === 'paint'} + onColour={setColour} + onToggle={on => setTool(on ? 'paint' : 'none')} /> + +
('none'); + +export function ToolProvider({ tool, children }: { tool: Tool; children: ReactNode }) { + return {children}; +} + +export const useTool = () => useContext(ToolContext); diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts index d6400fb30..46f56a84a 100644 --- a/pid-designer/frontend/src/lib/gating.test.ts +++ b/pid-designer/frontend/src/lib/gating.test.ts @@ -184,6 +184,23 @@ describe('every diagram-editing control is gated on the checkout', () => { ).toEqual([]) }) + it('never puts a destructive edit on a bare click', () => { + // Junction insertion used to be the plain click handler on a line, so a + // double-click -- which is two clicks -- inserted two junctions and then + // opened a dialog for an edge that no longer existed, and clicking around + // scattered them across the drawing. A gesture that rewrites the graph has + // to be armed first. + const src = Object.entries(files).find(([p]) => p.endsWith('/BranchableEdge.tsx'))?.[1] + expect(src, 'BranchableEdge.tsx not found').toBeTruthy() + + const handler = src!.slice(src!.indexOf('const onClickBranch')) + const guard = handler.slice(0, handler.indexOf('\n }')) + expect( + /if \(!armed/.test(guard), + 'onClickBranch must return early unless the junction tool is armed', + ).toBe(true) + }) + it('keeps every exemption pointing at a real file', () => { const stale = Object.keys(NOT_EDITING).filter( (file) => !Object.keys(files).some((p) => p.endsWith(`/${file}`)), From 96654f3b438d533931efb5b6d3db8b905bf6b6a8 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 11:52:52 -0700 Subject: [PATCH 25/57] pid-designer: Clear asks first, and takes only the page you are on It emptied the whole diagram without asking. With the rocket side and the GSE side living in one document that turned "start this page over" into losing the other one, and the only way back was version history -- which helps nobody who does not know it is there. Clear now takes what the page shows and says so first: *"This removes 2 components and 1 line from GSE. Other pages are untouched."* Counted from the diagram rather than described in general, so the sentence is about the thing in front of you. A line goes when either of its ends goes, including one reaching across to another page. Half a pipe is worse than none, and the checks panel already reports a cross-page line as something that should not be there. The confirm button is gated on the checkout as well as the button that opens it -- it destroys work, so it does not rely on the dialog being unreachable. --- .../src/components/pid/PIDDesigner.tsx | 47 +++++++++++++++++-- .../src/components/pid/PIDToolbar.tsx | 46 +++++++++++++++++- pid-designer/frontend/src/lib/gating.test.ts | 1 + 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index d62562b9d..1bbd0c3a5 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -150,6 +150,7 @@ interface CanvasProps { getRef: React.MutableRefObject<() => Snapshot>; loadRef: React.MutableRefObject<(d: Snapshot) => void>; clearRef: React.MutableRefObject<() => void>; + clearCountRef: React.MutableRefObject<() => { page: string; nodes: number; edges: number }>; undoRef: React.MutableRefObject<() => void>; redoRef: React.MutableRefObject<() => void>; releaseRef: React.MutableRefObject<(label: string) => Promise<{ label: string; savedAt: string }>>; @@ -165,7 +166,7 @@ interface CanvasProps { } function PIDCanvas({ - diagramRef, onInstance, getRef, loadRef, clearRef, undoRef, redoRef, + diagramRef, onInstance, getRef, loadRef, clearRef, clearCountRef, undoRef, redoRef, releaseRef, getHistoryRef, getReleasesRef, restoreMicroRef, restoreReleaseRef, onForbidden, onLockLost, mode, }: CanvasProps) { @@ -184,6 +185,10 @@ function PIDCanvas({ toolRef.current = tool; const [page, setPage] = useState(DEFAULT_PAGE); + // Read inside `clearRef`, which is called through a ref from the toolbar and + // would otherwise close over whichever page was current when it was built. + const pageRef = useRef(page); + pageRef.current = page; // Pages people made but have not drawn on yet. Everything else is derived // from where the components actually are, so the two cannot disagree. const [declaredPages, setDeclaredPages] = useState([]); @@ -315,11 +320,43 @@ function PIDCanvas({ setNodes(d.nodes); setEdges(d.edges); }, [setNodes, setEdges]); + /** + * Empty the page you are looking at, and only that page. + * + * It used to empty the whole diagram. With the rocket side and the GSE side + * living in one document, that turned "start this page over" into losing the + * other one -- and the only way back was the version history, which somebody + * has to know exists. What a page shows is what Clear takes. + * + * Lines go when either end goes, including a line that reached across to + * another page: half a pipe is worse than none, and the checks panel already + * says a cross-page line should not be there. + */ clearRef.current = useCallback(() => { if (readOnlyRef.current) return; - setNodes([]); - setEdges([]); + const here = pageRef.current; + setNodes(nds => { + const doomed = new Set( + nds.filter(n => pageOf(n.data as unknown as PIDNodeData) === here).map(n => n.id)); + setEdges(eds => eds.filter(e => !doomed.has(e.source) && !doomed.has(e.target))); + return nds.filter(n => !doomed.has(n.id)); + }); }, [setNodes, setEdges]); + + /** What Clear would take, so the confirmation can say. */ + const clearCount = useCallback(() => { + const here = pageRef.current; + const doomed = new Set( + snapshot.current.nodes + .filter(n => pageOf(n.data as unknown as PIDNodeData) === here).map(n => n.id)); + return { + page: here, + nodes: doomed.size, + edges: snapshot.current.edges + .filter(e => doomed.has(e.source) || doomed.has(e.target)).length, + }; + }, []); + clearCountRef.current = clearCount; undoRef.current = useCallback(() => { if (!readOnlyRef.current) undo(); }, [undo]); redoRef.current = useCallback(() => { if (!readOnlyRef.current) redo(); }, [redo]); @@ -818,6 +855,8 @@ export function PIDDesigner() { const getRef = useRef<() => Snapshot>(() => ({ nodes: [], edges: [] })); const loadRef = useRef<(d: Snapshot) => void>(() => {}); const clearRef = useRef<() => void>(() => {}); + const clearCountRef = useRef<() => { page: string; nodes: number; edges: number }>( + () => ({ page: '', nodes: 0, edges: 0 })); const undoRef = useRef<() => void>(() => {}); const redoRef = useRef<() => void>(() => {}); const releaseRef = useRef<(label: string) => Promise<{ label: string; savedAt: string }>>(() => Promise.resolve({ label: '', savedAt: '' })); @@ -971,6 +1010,7 @@ export function PIDDesigner() { getSnapshot={() => getRef.current()} loadSnapshot={d => loadRef.current(d)} onClear={() => clearRef.current()} + clearSummary={() => clearCountRef.current()} onUndo={() => undoRef.current()} onRedo={() => redoRef.current()} onRelease={label => releaseRef.current(label)} @@ -996,6 +1036,7 @@ export function PIDDesigner() { getRef={getRef} loadRef={loadRef} clearRef={clearRef} + clearCountRef={clearCountRef} undoRef={undoRef} redoRef={redoRef} releaseRef={releaseRef} diff --git a/pid-designer/frontend/src/components/pid/PIDToolbar.tsx b/pid-designer/frontend/src/components/pid/PIDToolbar.tsx index e249bc5c4..9d76baf62 100644 --- a/pid-designer/frontend/src/components/pid/PIDToolbar.tsx +++ b/pid-designer/frontend/src/components/pid/PIDToolbar.tsx @@ -2,12 +2,15 @@ import { useEffect, useState } from 'react'; import type { ReactFlowInstance, Node, Edge } from '@xyflow/react'; import type { InteractionMode, MicroVersion, ReleaseVersion } from './PIDDesigner'; import { useReadOnly } from '@stardesign-ui'; +import { Modal } from '../ui'; interface PIDToolbarProps { rfInstance: ReactFlowInstance | null; getSnapshot: () => { nodes: Node[]; edges: Edge[] }; loadSnapshot: (data: { nodes: Node[]; edges: Edge[] }) => void; onClear: () => void; + /** What Clear would take, for the confirmation. */ + clearSummary: () => { page: string; nodes: number; edges: number }; onUndo: () => void; onRedo: () => void; onRelease: (label: string) => Promise<{ label: string; savedAt: string }>; @@ -31,7 +34,7 @@ function relativeTime(iso: string): string { } export function PIDToolbar({ - rfInstance, getSnapshot, loadSnapshot, onClear, onUndo, onRedo, + rfInstance, getSnapshot, loadSnapshot, onClear, clearSummary, onUndo, onRedo, onRelease, onGetHistory, onGetReleases, onRestoreMicro, onRestoreRelease, canVersion, mode, onModeChange, }: PIDToolbarProps) { @@ -46,6 +49,11 @@ export function PIDToolbar({ const [relStatus, setRelStatus] = useState<'idle' | 'saving' | 'ok' | 'err'>('idle'); const [relError, setRelError] = useState(''); + // Clear is the one button here that destroys work and cannot be reached by + // accident afterwards -- undo covers it, but only if somebody realises in + // time. It asks, and it says exactly what it is about to take. + const [confirmClear, setConfirmClear] = useState<{ page: string; nodes: number; edges: number } | null>(null); + const [showHistory, setShowHistory] = useState(false); const [micro, setMicro] = useState([]); const [releases, setReleases] = useState([]); @@ -238,7 +246,11 @@ export function PIDToolbar({
-
)} + setConfirmClear(null)} + title={`Clear ${confirmClear?.page ?? ''}?`} + footer={ +
+ + +
+ } + > +

+ {confirmClear?.nodes === 0 ? ( + <>There is nothing on this page. + ) : ( + <> + This removes {confirmClear?.nodes} component{confirmClear?.nodes === 1 ? '' : 's'} + {confirmClear?.edges ? <> and {confirmClear.edges} line{confirmClear.edges === 1 ? '' : 's'} : null} + {' '}from {confirmClear?.page}. Other pages are untouched. + + )} +

+
+ {showRelease && (
relStatus !== 'saving' && setShowRelease(false)}>
e.stopPropagation()}> diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts index 46f56a84a..6fddf7cbd 100644 --- a/pid-designer/frontend/src/lib/gating.test.ts +++ b/pid-designer/frontend/src/lib/gating.test.ts @@ -54,6 +54,7 @@ const VIEW_ONLY: Record = { 'PIDToolbar.tsx:onClick={openHistory}': 'opens the history panel', 'PIDToolbar.tsx:setShowHistory(false)': 'closes the history panel', 'PIDToolbar.tsx:setShowRelease(false)': 'closes the release dialog', + 'PIDToolbar.tsx:setConfirmClear(null)': 'cancels the clear confirmation; the Clear button itself is gated', 'PIDToolbar.tsx:setRelLabel': 'the label field inside the release dialog, which Release already gates', 'PIDToolbar.tsx:onClick={submitRelease}': 'inside the release dialog, which Release already gates', 'PIDDesigner.tsx:setUnshared(null)': 'dismisses the "no longer shared" notice', From bac8d7ce5c6468eda1223763fccbee3e067dae73 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 12:01:38 -0700 Subject: [PATCH 26/57] pid-designer: manifold geometry, one-port transducers, rotated tag dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Dragging a tag on a rotated symbol went the wrong way.** The offset lives in the symbol's own frame and the symbol may be turned, but the drag is measured on screen -- so at 90° the tag moved sideways and at 180° it moved opposite the mouse. The delta is rotated back into the symbol's frame before it is applied. **Manifold ports can be put where they actually are.** A real block has its tappings where the machinist put them, and evenly spaced along one face is a drawing that does not match the hardware. The config opens a small editor: set the number of ports, drag each one round the perimeter, set the block's width and height, Save. What is stored per port is a **fraction of the way round the perimeter**, not an (x, y). Resize the block and the ports stay where they were put relative to the shape instead of ending up inside it or off the end -- and 0.25 means a quarter of the way round whatever size the block is. The layout commits on Save rather than on release, because dragging is fiddly and every twitch would otherwise be a version in somebody's history. A manifold with no saved layout draws exactly as it always did. **Transducers and gauges have one port.** They screw into a single tapping and are a dead end; four connection points were three invitations to draw a pipe through an instrument. **A junction is a tee.** It now carries bore, branch bore, tee kind and which leg is the branch -- because once something flows out of its third leg it is a node with a mass balance and a loss on each path, not an anonymous dot. It deliberately carries no K: a tee's K depends on how the flow splits, and that is solved rather than drawn. The reasoning, and four asks of feed-twin, are in `docs/integration/tees-and-branches.md`. --- .../src/components/pid/ConfigDialog.tsx | 20 +- .../src/components/pid/ManifoldEditor.tsx | 199 ++++++++++++++++++ .../src/components/pid/PIDDesigner.tsx | 1 + .../components/pid/manifoldGeometry.test.ts | 85 ++++++++ .../components/pid/nodes/DraggableLabel.tsx | 19 +- .../src/components/pid/nodes/ManifoldNode.tsx | 75 +++++-- .../src/components/pid/nodes/SensorNode.tsx | 8 +- .../frontend/src/components/pid/ports.ts | 5 +- .../frontend/src/components/pid/spec.ts | 34 +++ .../frontend/src/components/pid/types.ts | 5 + 10 files changed, 423 insertions(+), 28 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/ManifoldEditor.tsx create mode 100644 pid-designer/frontend/src/components/pid/manifoldGeometry.test.ts diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index 95183e554..a8f549156 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -10,6 +10,8 @@ import type { PortInfo, PortKind } from './ports'; import { speciesById } from './fluids'; import { SegmentPanel } from './SegmentPanel'; import { BoreProfile } from './BoreProfile'; +import { ManifoldEditor } from './ManifoldEditor'; +import type { ManifoldGeometry } from './ManifoldEditor'; import { fittingCount, transitionsOf } from './segments'; import type { LineSegment } from './segments'; import type { ComponentType, PIDNodeData } from './types'; @@ -39,13 +41,17 @@ export interface ConfigPatch { lineType?: string; ports?: Record; segments?: LineSegment[]; + geometry?: ManifoldGeometry; } interface Props { open: boolean; onClose: () => void; kind: 'node' | 'edge'; - data: PIDNodeData & { lineType?: string; partNumber?: string; fluid?: string; segments?: LineSegment[] }; + data: PIDNodeData & { + lineType?: string; partNumber?: string; fluid?: string; + segments?: LineSegment[]; geometry?: ManifoldGeometry; + }; peers?: { id: string; label: string; hint?: string }[]; readOnly: boolean; onSave: (patch: ConfigPatch) => void; @@ -88,6 +94,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav const [partNumber, setPartNumber] = useState(data.partNumber ?? ''); const [lineType, setLineType] = useState(data.lineType ?? 'pipe'); const [segments, setSegments] = useState([]); + const [geometry, setGeometry] = useState(undefined); const spec: ComponentSpec | undefined = kind === 'edge' ? LINE_SPECS[lineType] : COMPONENT_SPECS[type]; @@ -100,6 +107,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav setLineType(data.lineType ?? 'pipe'); setPorts({ ...(data.ports ?? {}) }); setSegments(data.segments ? structuredClone(data.segments) : []); + setGeometry(data.geometry ? structuredClone(data.geometry) : undefined); }, [open, data]); useEffect(() => { @@ -131,6 +139,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav fluid: fluid || undefined, partNumber: partNumber.trim() || undefined, ...(kind === 'edge' ? { lineType, segments: segments.length ? segments : undefined } : {}), + ...(geometry ? { geometry } : {}), }); onClose(); }; @@ -235,6 +244,15 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav )} + {type === 'MANIFOLD' && ( + + )} + {(spec.portGroups ?? []).map(group => ( ; +} + +const W = 260, H = 190, PAD = 34; + +/** Point on the block's perimeter at fraction `t`, clockwise from top-left. */ +export function perimeterPoint(t: number, w: number, h: number) { + const per = 2 * (w + h); + let d = ((t % 1) + 1) % 1 * per; + if (d <= w) return { x: d, y: 0, side: 'top' as const }; + d -= w; + if (d <= h) return { x: w, y: d, side: 'right' as const }; + d -= h; + if (d <= w) return { x: w - d, y: h, side: 'bottom' as const }; + d -= w; + return { x: 0, y: h - d, side: 'left' as const }; +} + +/** The fraction nearest an arbitrary point — what a drag lands on. */ +export function nearestFraction(px: number, py: number, w: number, h: number): number { + const per = 2 * (w + h); + const cands: [number, number][] = [ + [Math.min(w, Math.max(0, px)) / per, Math.hypot(px - Math.min(w, Math.max(0, px)), py)], + [(w + Math.min(h, Math.max(0, py))) / per, Math.hypot(px - w, py - Math.min(h, Math.max(0, py)))], + [(w + h + (w - Math.min(w, Math.max(0, px)))) / per, Math.hypot(px - Math.min(w, Math.max(0, px)), py - h)], + [(2 * w + h + (h - Math.min(h, Math.max(0, py)))) / per, Math.hypot(px, py - Math.min(h, Math.max(0, py)))], + ]; + cands.sort((a, b) => a[1] - b[1]); + return ((cands[0][0] % 1) + 1) % 1; +} + +/** Evenly round the perimeter — what a fresh manifold looks like. */ +export function defaultPositions(ids: string[]): Record { + const out: Record = {}; + ids.forEach((id, i) => { out[id] = (i + 0.5) / Math.max(1, ids.length); }); + return out; +} + +export function ManifoldEditor({ outlets, geometry, ports, onSave }: { + outlets: number; + geometry: ManifoldGeometry | undefined; + ports: Record; + onSave: (g: ManifoldGeometry) => void; +}) { + const readOnly = useReadOnly(); + const svgRef = useRef(null); + const [drag, setDrag] = useState(null); + + const ids = ['in', ...portIds('p', outlets)]; + void portId; + + const [draft, setDraft] = useState(() => ({ + width: geometry?.width ?? 120, + height: geometry?.height ?? 26, + positions: { ...defaultPositions(ids), ...(geometry?.positions ?? {}) }, + })); + + // A port that has appeared since last time needs somewhere to be. + useEffect(() => { + setDraft(d => { + const next = { ...d.positions }; + let changed = false; + const spare = defaultPositions(ids); + for (const id of ids) if (next[id] === undefined) { next[id] = spare[id]; changed = true; } + for (const id of Object.keys(next)) if (!ids.includes(id)) { delete next[id]; changed = true; } + return changed ? { ...d, positions: next } : d; + }); + }, [outlets]); // eslint-disable-line react-hooks/exhaustive-deps + + const dirty = + draft.width !== (geometry?.width ?? 120) || + draft.height !== (geometry?.height ?? 26) || + ids.some(id => draft.positions[id] !== geometry?.positions?.[id]); + + // Block drawn centred in the panel, scaled to fit. + const k = Math.min((W - PAD * 2) / Math.max(1, draft.width), (H - PAD * 2) / Math.max(1, draft.height), 2.2); + const bw = draft.width * k, bh = draft.height * k; + const ox = (W - bw) / 2, oy = (H - bh) / 2; + + const onMove = useCallback((e: React.PointerEvent) => { + if (!drag || readOnly || !svgRef.current) return; + const r = svgRef.current.getBoundingClientRect(); + const px = ((e.clientX - r.left) / r.width) * W - ox; + const py = ((e.clientY - r.top) / r.height) * H - oy; + const t = nearestFraction(px, py, bw, bh); + setDraft(d => ({ ...d, positions: { ...d.positions, [drag]: t } })); + }, [drag, readOnly, ox, oy, bw, bh]); + + const size = (key: 'width' | 'height') => ( + { + const v = Number(e.target.value); + if (Number.isFinite(v) && v > 0) setDraft(d => ({ ...d, [key]: v })); + }} + className="w-[54px] rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] px-1.5 py-0.5 text-[11px] outline-none focus:border-[var(--color-accent)]" + /> + ); + + return ( +
+
+ Geometry + + w {size('width')} h {size('height')} px + +
+ + setDrag(null)} + onPointerLeave={() => setDrag(null)} + > + + + + {ids.map(id => { + const p = perimeterPoint(draft.positions[id] ?? 0, bw, bh); + const kind: PortKind = ports[id]?.kind ?? 'flow'; + if (kind === 'plug') return null; + const cx = ox + p.x, cy = oy + p.y; + const on = drag === id; + const colour = id === 'in' ? '#38bdf8' : kind === 'instrument' ? '#a78bfa' : '#94a3b8'; + return ( + { if (!readOnly) { e.stopPropagation(); setDrag(id); } }} + style={{ cursor: readOnly ? 'default' : 'grab' }}> + + + + {ports[id]?.label || id} + + + ); + })} + + +
+ + Drag a port round the block. + + +
+
+ ); +} diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index 1bbd0c3a5..e492e2eac 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -642,6 +642,7 @@ function PIDCanvas({ options: patch.options, partNumber: patch.partNumber, ...(patch.ports ? { ports: patch.ports } : {}), + ...(patch.geometry ? { geometry: patch.geometry } : {}), }; if (subject.kind === 'node') { setNodes(nds => nds.map(n => ( diff --git a/pid-designer/frontend/src/components/pid/manifoldGeometry.test.ts b/pid-designer/frontend/src/components/pid/manifoldGeometry.test.ts new file mode 100644 index 000000000..69279a68d --- /dev/null +++ b/pid-designer/frontend/src/components/pid/manifoldGeometry.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { perimeterPoint, nearestFraction, defaultPositions } from './ManifoldEditor'; + +describe('a port is a fraction of the way round the block', () => { + const w = 100, h = 40; + + it('walks clockwise from the top-left', () => { + const per = 2 * (w + h); + expect(perimeterPoint(0, w, h)).toMatchObject({ x: 0, y: 0, side: 'top' }); + // Sampled mid-edge, not on a corner: a corner belongs to both sides and + // which one it reports is a float's business, not a behaviour to pin. + expect(perimeterPoint((w / 2) / per, w, h).side).toBe('top'); + expect(perimeterPoint((w + h / 2) / per, w, h)).toMatchObject({ x: w, side: 'right' }); + expect(perimeterPoint((w + h + w / 2) / per, w, h).side).toBe('bottom'); + expect(perimeterPoint((2 * w + h + h / 2) / per, w, h)).toMatchObject({ x: 0, side: 'left' }); + }); + + it('puts a corner on the block, whichever side it claims', () => { + const per = 2 * (w + h); + for (const d of [0, w, w + h, 2 * w + h]) { + const p = perimeterPoint(d / per, w, h); + expect(Number.isFinite(p.x) && Number.isFinite(p.y)).toBe(true); + expect(p.x).toBeGreaterThanOrEqual(-1e-6); + expect(p.x).toBeLessThanOrEqual(w + 1e-6); + expect(p.y).toBeGreaterThanOrEqual(-1e-6); + expect(p.y).toBeLessThanOrEqual(h + 1e-6); + } + }); + + it('wraps rather than running off the end', () => { + expect(perimeterPoint(1.25, w, h)).toEqual(perimeterPoint(0.25, w, h)); + expect(perimeterPoint(-0.25, w, h)).toEqual(perimeterPoint(0.75, w, h)); + }); + + it('survives a resize with the ports still on the block', () => { + // The reason a fraction is stored rather than an (x, y): halve the block + // and a port is still a quarter of the way round it, not hanging off. + const t = 0.3; + for (const [ww, hh] of [[100, 40], [50, 20], [220, 80]]) { + const p = perimeterPoint(t, ww, hh); + expect(p.x).toBeGreaterThanOrEqual(-1e-9); + expect(p.x).toBeLessThanOrEqual(ww + 1e-9); + expect(p.y).toBeGreaterThanOrEqual(-1e-9); + expect(p.y).toBeLessThanOrEqual(hh + 1e-9); + } + }); +}); + +describe('dragging a port', () => { + const w = 100, h = 40; + + it('snaps to the nearest edge, so it never lands inside the block', () => { + const t = nearestFraction(50, 5, w, h); // near the top edge + expect(perimeterPoint(t, w, h).side).toBe('top'); + }); + + it('lands on the side the pointer is nearest', () => { + expect(perimeterPoint(nearestFraction(98, 20, w, h), w, h).side).toBe('right'); + expect(perimeterPoint(nearestFraction(50, 38, w, h), w, h).side).toBe('bottom'); + expect(perimeterPoint(nearestFraction(2, 20, w, h), w, h).side).toBe('left'); + }); + + it('clamps a pointer dragged outside the block back onto it', () => { + const t = nearestFraction(-40, -40, w, h); + const p = perimeterPoint(t, w, h); + expect(p.x).toBeGreaterThanOrEqual(0); + expect(p.y).toBeGreaterThanOrEqual(0); + }); + + it('round-trips: a point on an edge maps back to itself', () => { + const t = 0.42; + const p = perimeterPoint(t, w, h); + expect(nearestFraction(p.x, p.y, w, h)).toBeCloseTo(t, 6); + }); +}); + +describe('the default layout', () => { + it('spaces every port evenly, and gives each a distinct place', () => { + const pos = defaultPositions(['in', 'p', 'p2', 'p3']); + const values = Object.values(pos); + expect(new Set(values).size).toBe(4); + expect(Math.min(...values)).toBeGreaterThan(0); + expect(Math.max(...values)).toBeLessThan(1); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx b/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx index a11781fd5..bee8f9024 100644 --- a/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx @@ -58,11 +58,24 @@ export function DraggableLabel({ nodeId, label, offset, defaultOffset, rotation const onMove = (e: MouseEvent) => { if (!dragStart.current) return; const { zoom } = getViewport(); + const dx = (e.clientX - dragStart.current.mouseX) / zoom; + const dy = (e.clientY - dragStart.current.mouseY) / zoom; + + // The offset lives in the symbol's own frame, and the symbol may be + // turned. A drag is measured on screen, so it has to be rotated *back* + // into that frame before it is added -- otherwise dragging a tag on a + // symbol rotated 90 degrees moves it sideways, and on one rotated 180 it + // moves the opposite way to the mouse. + const a = (spun * Math.PI) / 180; + const cos = Math.cos(a), sin = Math.sin(a); + const localDx = dx * cos + dy * sin; + const localDy = -dx * sin + dy * cos; + setNodes(nds => nds.map(n => n.id === nodeId ? { ...n, data: { ...n.data, labelOffset: { - x: dragStart.current!.ox + (e.clientX - dragStart.current!.mouseX) / zoom, - y: dragStart.current!.oy + (e.clientY - dragStart.current!.mouseY) / zoom, + x: dragStart.current!.ox + localDx, + y: dragStart.current!.oy + localDy, }}} : n, )); @@ -76,7 +89,7 @@ export function DraggableLabel({ nodeId, label, offset, defaultOffset, rotation window.removeEventListener('mousemove', onMove, true); window.removeEventListener('mouseup', onUp, true); }; - }, [dragging, nodeId, setNodes, getViewport]); + }, [dragging, nodeId, setNodes, getViewport, spun]); return (
`${k}:${v.kind ?? 'flow'}`).sort().join(','); useEffect(() => { updateNodeInternals(id); }, [id, portSignature, updateNodeInternals]); const vertical = (options?.orientation ?? 'horizontal') === 'vertical'; + // A saved layout wins; without one the block is the even default it always + // was, so nothing that exists changes shape. + const geom = (data as unknown as PIDNodeData).geometry; const run = manifoldLength(outlets); - const W = vertical ? BODY : run; - const H = vertical ? run : BODY; + const W = geom ? geom.width : vertical ? BODY : run; + const H = geom ? geom.height : vertical ? run : BODY; return (
- {/* The feed in, at the near end. */} - + {geom ? ( + // Placed by hand: each port sits where its perimeter fraction puts it. + (() => { + const ids = ['in', ...portIds('p', outlets)]; + const spare = defaultPositions(ids); + return ids.map(pid => { + const kind = portKind(data as unknown as PIDNodeData, pid); + if (kind === 'plug') return null; + const pt = perimeterPoint(geom.positions[pid] ?? spare[pid], W, H); + // The side decides which way React Flow thinks the port faces, + // which is what makes a line leave it in a sensible direction. + const position = + pt.side === 'top' ? Position.Top + : pt.side === 'bottom' ? Position.Bottom + : pt.side === 'left' ? Position.Left + : Position.Right; + const style: React.CSSProperties = + pt.side === 'top' || pt.side === 'bottom' + ? { left: pt.x, transform: 'translate(-50%, -50%)' } + : { top: pt.y, transform: 'translate(-50%, -50%)' }; + return ; + }); + })() + ) : ( + <> + {/* The feed in, at the near end. */} + - {/* One tapping per outlet, down the long side. A plugged one is not - drawn at all -- a P&ID does not draw plugs, and a port nothing can - attach to is exactly what a plug is. */} - {portOffsets(outlets).map((off, i) => { - const pid = portId('p', i); - const kind = portKind(data as unknown as PIDNodeData, pid); - if (kind === 'plug') return null; - return ( - - ); - })} + {/* One tapping per outlet, down the long side. A plugged one is not + drawn at all -- a P&ID does not draw plugs. */} + {portOffsets(outlets).map((off, i) => { + const pid = portId('p', i); + const kind = portKind(data as unknown as PIDNodeData, pid); + if (kind === 'plug') return null; + return ( + + ); + })} + + )} + {/* One tapping, at the bottom. Rotate the symbol to point it elsewhere. */} + {tapped && } + > = { P('bore', 'Port bore', 'length'), ], }, + + /** + * A junction is a tee. + * + * It stops being an anonymous dot the moment something flows out of its third + * leg, because then it is a node in the network with a mass balance and a + * loss on each path -- and those depend on its bore and which leg is the + * branch. What it deliberately does *not* carry is a K: a tee's K is a + * function of how the flow splits, and that is solved, not drawn. + */ + JUNCTION: { + params: [ + P('bore', 'Bore', 'length'), + P('branch_bore', 'Branch bore', 'length'), + ], + options: [ + { key: 'teeKind', label: 'Tee', default: 'equal', + choices: [ + { value: 'equal', label: 'Equal tee' }, + { value: 'reducing', label: 'Reducing tee' }, + { value: 'cross', label: 'Cross' }, + { value: 'weldolet', label: 'Weldolet / branch fitting' }, + ] }, + { key: 'branchPort', label: 'Branch leg', default: 'auto', + choices: [ + { value: 'auto', label: 'Work it out from the drawing' }, + { value: 't', label: 'Top' }, + { value: 'r', label: 'Right' }, + { value: 'b', label: 'Bottom' }, + { value: 'l', label: 'Left' }, + ] }, + ], + }, + }; function instrumentSpec(): ComponentSpec { diff --git a/pid-designer/frontend/src/components/pid/types.ts b/pid-designer/frontend/src/components/pid/types.ts index 73fb2f2df..bcdcfaab3 100644 --- a/pid-designer/frontend/src/components/pid/types.ts +++ b/pid-designer/frontend/src/components/pid/types.ts @@ -48,6 +48,11 @@ export interface PIDNodeData { * plain outlets stores nothing. See `ports.ts`. */ ports?: Record; + /** + * For a manifold: the block's size and where each port sits, as a fraction + * of the way round its perimeter. Absent means the even default. + */ + geometry?: { width: number; height: number; positions: Record }; notes?: string; labelOffset?: { x: number; y: number }; rotation?: number; From 96e8a6e46c48dfd3dfc1c23bc007ab1024e56999 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 12:46:11 -0700 Subject: [PATCH 27/57] Junctions appear when you branch, and go without taking the pipe Six things, all of them the drawing arguing with what somebody meant. **A branch does not need to be placed.** Drag from a relief valve, let go on a line, and the junction appears where you let go. A branch does need a node -- three flows meeting need a mass balance, which is the whole reason a tee that carries flow is a node and not a fitting -- but needing one is not a reason to make anybody think about one. The Junction tool stays for placing one deliberately on a line nothing is connected to yet. **Deleting a junction gives the run back.** It is a point *in* a line, so taking it out should leave the line, exactly as putting it in left it. React Flow took both halves with it, which is never what "remove that junction" meant. Built from what was deleted rather than from what is left, because by then the halves are already gone from state; a chain of junctions rejoins as one line, and a junction with a third leg on it has no single run to give back, so that one behaves as it always did. **Splitting a line no longer doubles the pipe.** Both halves used to inherit the whole run -- three feet and two elbows each -- so a junction silently doubled a line's pressure drop. Bore and roughness are true of both halves and copy; length, lumped K and the fitting tally stay with the upstream half and the downstream one starts unstated, which in this codebase means not stated rather than zero. Split then delete is now exactly a round trip. **A junction on a corner stopped breaking the line.** Each half meets the face pointing at where it came from instead of always top-in bottom-out, which sent a horizontal run up and over the junction and back down. **Lettering stays upright when a symbol turns.** PT, S, HYD, INJ, TANK, N2 -- rotating a part is about pointing it somewhere, not about reading sideways. The annotation swings round to stay beside what it labels. **Manifold ports sit on the manifold, and take paint.** The port style overrode the transform React Flow uses to seat a handle on an edge, so ports floated half their width outside the block; and the symbol still read the retired `fluidType`, which is why the paint bucket appeared to miss the one component people most want coloured. The Junction tool and the connect-drop now share one `splitEdgeAt` rather than two implementations that had already drifted -- one of them stamped a junction the delete could recognise and the other did not. --- .../src/components/pid/BranchableEdge.tsx | 64 ++--- .../src/components/pid/PIDDesigner.tsx | 77 ++++++ .../src/components/pid/nodes/EngineNode.tsx | 15 +- .../src/components/pid/nodes/ManifoldNode.tsx | 22 +- .../src/components/pid/nodes/PRNode.tsx | 9 +- .../src/components/pid/nodes/QDNode.tsx | 5 +- .../src/components/pid/nodes/SensorNode.tsx | 11 +- .../src/components/pid/nodes/SupplyNode.tsx | 41 ++-- .../src/components/pid/nodes/TankNode.tsx | 15 +- .../src/components/pid/nodes/Upright.tsx | 19 ++ .../src/components/pid/nodes/ValveNode.tsx | 11 +- .../src/components/pid/splitEdge.test.ts | 191 +++++++++++++++ .../frontend/src/components/pid/splitEdge.ts | 220 ++++++++++++++++++ 13 files changed, 628 insertions(+), 72 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/nodes/Upright.tsx create mode 100644 pid-designer/frontend/src/components/pid/splitEdge.test.ts create mode 100644 pid-designer/frontend/src/components/pid/splitEdge.ts diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx index a073404eb..55282d5c5 100644 --- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx +++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx @@ -5,15 +5,12 @@ import { Position, useReactFlow, type EdgeProps, - type Edge, } from '@xyflow/react'; -import { nextJunctionId } from './ids'; +import { splitEdgeAt } from './splitEdge'; import { useEdgeFluidColor } from './FluidContext'; import { useReadOnly } from '@stardesign-ui'; import { useTool } from './ToolContext'; -const J_HALF = 5; - /** * A pipe: orthogonal, with a middle segment you can move, and a junction you * can drop anywhere along it. @@ -39,13 +36,13 @@ const J_HALF = 5; */ export function BranchableEdge(props: EdgeProps) { const { - id, source, target, + id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data, } = props; - const { setNodes, setEdges, getZoom } = useReactFlow(); + const { setNodes, setEdges, getNodes, getEdges, getZoom } = useReactFlow(); const readOnly = useReadOnly(); // A junction only goes in while the tool is armed. See ToolContext. const armed = useTool() === 'junction' && !readOnly; @@ -143,33 +140,24 @@ export function BranchableEdge(props: EdgeProps) { if (!armed || !hoverAt || dragging) return; e.stopPropagation(); - const junctionId = nextJunctionId(); + // The same operation dropping a connection on a line performs -- see + // splitEdge.ts. This used to be a second copy of it here, and the two had + // already drifted: one stamped a junction the delete-rejoin could + // recognise and the other did not. + const split = splitEdgeAt( + getNodes(), getEdges(), id, hoverAt, (data as { page?: string })?.page, + // The exact handle positions, which this edge knows and a caller working + // from the node boxes does not. + { from: { x: sourceX, y: sourceY }, to: { x: targetX, y: targetY } }, + ); + if (!split) return; + flushSync(() => { - setNodes(nds => [...nds, { - id: junctionId, - type: 'JUNCTION', - position: { x: hoverAt.x - J_HALF, y: hoverAt.y - J_HALF }, - // Junctions inherit the page of the line they are dropped on, so one - // never lands on a page its own pipe is not drawn on. - data: { page: (data as { page?: string })?.page }, - }]); - setEdges(eds => { - const rest = eds.filter(x => x.id !== id); - const carried = { ...(data as Record), offset: 0 }; - const toJunction: Edge = { - id: `${id}-to-${junctionId}`, - source, target: junctionId, targetHandle: 't', - type: 'smoothstep', data: carried, - }; - const fromJunction: Edge = { - id: `${junctionId}-to-${target}`, - source: junctionId, sourceHandle: 'b', target, - type: 'smoothstep', data: carried, - }; - return [...rest, toJunction, fromJunction]; - }); + setNodes(split.nodes); + setEdges(split.edges); }); - }, [armed, hoverAt, dragging, id, source, target, data, setNodes, setEdges]); + }, [armed, hoverAt, dragging, id, data, getNodes, getEdges, setNodes, setEdges, + sourceX, sourceY, targetX, targetY]); return ( = Math.abs(dy)) return dx >= 0 ? 'r' : 'l'; + return dy >= 0 ? 'b' : 't'; +} diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index e492e2eac..1f39b26f5 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -48,6 +48,7 @@ import { VentLayer } from './VentLayer'; import { PageBar } from './PageBar'; import { DEFAULT_PAGE, applyPage, listPages, moveToPage, pageOf } from './pages'; import { clearOfHost, dragAttached, isInstrument, targetAt } from './attach'; +import { rejoinAfterDelete, splitEdgeAt } from './splitEdge'; import { COMPONENT_SPECS } from './spec'; export type InteractionMode = 'pan' | 'select'; @@ -516,6 +517,59 @@ function PIDCanvas({ ); }, [setCenter, getZoom]); + /** + * Dropping a connection on a line branches it. + * + * The answer to "must I place a junction for every tap": no. Drag from the + * relief valve, let go on the line, and the junction appears where you let + * go. A branch needs a node -- three flows meeting need a mass balance -- + * but needing one is not a reason to make somebody think about one. + * + * The Junction tool stays for placing one deliberately, on a line you have + * not connected anything to yet. + */ + const connectingFrom = useRef<{ nodeId: string; handleId: string | null } | null>(null); + + const onConnectStart = useCallback(( + _e: unknown, params: { nodeId: string | null; handleId: string | null }, + ) => { + connectingFrom.current = params.nodeId ? { nodeId: params.nodeId, handleId: params.handleId } : null; + }, []); + + const onConnectEnd = useCallback((event: MouseEvent | TouchEvent) => { + const from = connectingFrom.current; + connectingFrom.current = null; + if (!from || readOnlyRef.current) return; + + const point = 'clientX' in event + ? { x: event.clientX, y: event.clientY } + : { x: event.changedTouches[0]?.clientX ?? 0, y: event.changedTouches[0]?.clientY ?? 0 }; + const flow = screenToFlowPosition(point); + + const { nodes: ns, edges: es } = snapshot.current; + // Only when it landed on a line and not on a component -- React Flow has + // already made the connection in that case. + const hit = targetAt(flow, ns, es, from.nodeId); + if (!hit || hit.kind !== 'edge') return; + + const split = splitEdgeAt(ns, es, hit.id, flow, pageRef.current); + if (!split) return; + + setNodes(split.nodes); + setEdges([ + ...split.edges, + { + id: `${from.nodeId}-${split.junctionId}`, + source: from.nodeId, + sourceHandle: from.handleId ?? undefined, + target: split.junctionId, + targetHandle: undefined, + type: 'smoothstep', + data: {}, + }, + ]); + }, [screenToFlowPosition, setNodes, setEdges]); + const onDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; @@ -601,6 +655,27 @@ function PIDCanvas({ return true; }, [setNodes, setEdges]); + /** + * Deleting a junction rejoins the line it was on. + * + * A junction is a point *in* a run, not a component of its own -- so removing + * one should leave the run, exactly as inserting one left it. Letting React + * Flow take the two edges with it deleted the pipe as well, which is never + * what somebody meant by "take that junction out". + * + * Built from what React Flow says it deleted rather than from the edges that + * are left: by the time this runs the two halves are already gone from state, + * so an updater reading the current list finds nothing to rejoin. + * + * Only for junctions that are genuinely mid-line -- one edge in, one out. A + * junction with a third leg on it has no single run to rejoin, so the + * ordinary behaviour stands and everything attached goes with it. + */ + const onDelete = useCallback(({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) => { + const rejoined = rejoinAfterDelete(nodes, edges); + if (rejoined.length) setEdges(eds => [...eds, ...rejoined]); + }, [setEdges]); + const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => { if (paintIfArmed('node', node.id)) { e.stopPropagation(); e.preventDefault(); } }, [paintIfArmed]); @@ -701,10 +776,12 @@ function PIDCanvas({ nodes={view.nodes} edges={view.edges} onNodesChange={handleNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onInit={onInit} + onConnectStart={onConnectStart} onConnectEnd={onConnectEnd} onDrop={onDrop} onDragOver={onDragOver} onEdgeContextMenu={onEdgeContextMenu} onNodeContextMenu={onNodeContextMenu} onNodeClick={onNodeClick} + onDelete={onDelete} onEdgeClick={onEdgeClick} onNodeDoubleClick={onNodeDoubleClick} onEdgeDoubleClick={onEdgeDoubleClick} diff --git a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx index fc7e053e4..527da2151 100644 --- a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx @@ -2,6 +2,7 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Upright } from './Upright'; const W = 72, H = 120; @@ -43,12 +44,14 @@ export function EngineNode({ id, data, selected }: NodeProps) { fill="#1e293b" stroke={stroke} strokeWidth={selected ? 2.5 : 1.5} /> - INJ - {pc && ( - - {pc.value}{pc.unit === '-' ? '' : pc.unit} - - )} + + INJ + {pc && ( + + {pc.value}{pc.unit === '-' ? '' : pc.unit} + + )} + diff --git a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx index dd2d71d07..39be0eb67 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx @@ -2,7 +2,8 @@ import { Position, useUpdateNodeInternals, type NodeProps } from '@xyflow/react' import { useEffect } from 'react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; -import { FLUID_COLORS } from '../types'; +import { colorForSpecies, speciesById, UNSET_COLOR } from '../fluids'; +import { useNodeFluid } from '../FluidContext'; import { DraggableLabel } from './DraggableLabel'; import { portId, portIds, portKind } from '../ports'; import { perimeterPoint, defaultPositions } from '../ManifoldEditor'; @@ -34,9 +35,14 @@ function manifoldLength(ports: number): number { } export function ManifoldNode({ id, data, selected }: NodeProps) { - const { label, labelOffset, fluidType, rotation, options } = data as unknown as PIDNodeData; - const stroke = selected ? '#3b82f6' : '#94a3b8'; - const fluid = FLUID_COLORS[fluidType ?? 'default']; + const { label, labelOffset, rotation, options, color } = data as unknown as PIDNodeData; + // Paint beats the inherited fluid colour, and both beat nothing. This read + // the old `fluidType` field and never looked at `color` at all, which is why + // a manifold was the one symbol the paint bucket appeared to miss. + const assigned = useNodeFluid(id); + const species = speciesById(assigned?.species ?? undefined); + const fluid = color ?? (species ? colorForSpecies(species.id) : UNSET_COLOR); + const stroke = selected ? '#3b82f6' : (color ?? '#94a3b8'); const outlets = Math.max(1, Number(options?.outlets ?? 4)); @@ -75,10 +81,14 @@ export function ManifoldNode({ id, data, selected }: NodeProps) { : pt.side === 'bottom' ? Position.Bottom : pt.side === 'left' ? Position.Left : Position.Right; + // Only the coordinate along the edge. React Flow uses `transform` + // to sit a handle *on* its edge, so overriding it pushes the port + // off the block by half its own width -- which is what the ports + // floating outside the outline were. const style: React.CSSProperties = pt.side === 'top' || pt.side === 'bottom' - ? { left: pt.x, transform: 'translate(-50%, -50%)' } - : { top: pt.y, transform: 'translate(-50%, -50%)' }; + ? { left: pt.x } + : { top: pt.y }; return ; }); })() diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index ff829a02e..b798d6ebe 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Upright } from './Upright'; const W = 60, H = 60; @@ -28,13 +29,17 @@ export function PRNode({ id, data, selected }: NodeProps) { fill="#1e293b" stroke={stroke} strokeWidth={selected ? 2.5 : 1.5} /> - PR + + PR + {domeLoaded && ( <> {/* the dome, and the stem tying it to the seat */} - DOME + + DOME + )} diff --git a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx index 55f9bb26c..5bf537db9 100644 --- a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx @@ -2,6 +2,7 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Upright } from './Upright'; const W = 60, H = 60; @@ -38,7 +39,9 @@ export function QDNode({ id, data, selected }: NodeProps) { {hydraulic && ( - HYD + + HYD + )} diff --git a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx index e6e7b2fb8..de0909595 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx @@ -2,6 +2,7 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Upright } from './Upright'; /** * An instrument: a circle with its type in it. @@ -35,10 +36,12 @@ export function SensorNode({ id, data, selected }: NodeProps) { stroke={stroke} strokeWidth={selected ? 2.5 : 1.5} /> - - {componentType} - + + + {componentType} + + diff --git a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx index e1003bcf5..fd8e7f5bf 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx @@ -4,6 +4,7 @@ import type { PIDNodeData } from '../types'; import { speciesById, colorForSpecies, UNSET_COLOR } from '../fluids'; import { useNodeFluid } from '../FluidContext'; import { DraggableLabel } from './DraggableLabel'; +import { Upright } from './Upright'; /** * Where the propellant and the pressurant come from: a K-bottle, or a dewar. @@ -47,10 +48,12 @@ export function SupplyNode({ id, data, selected }: NodeProps) { {/* the vacuum jacket, which is what makes it a dewar and not a drum */} - - {species?.short ?? 'DEWAR'} - + + + {species?.short ?? 'DEWAR'} + + @@ -69,18 +72,30 @@ export function SupplyNode({ id, data, selected }: NodeProps) { {/* the bottle: domed shoulder, straight body */} - - {species?.short ?? 'KB'} - - {p && ( - - {p.value}{p.unit === '-' ? '' : p.unit} + + + {species?.short ?? 'KB'} - )} + - + {/* Under the bottle, not inside it: "6000 psi" is wider than the body + and was running off both sides of it. */} + {p && ( + + {p.value} {p.unit} + + )} + +
); } diff --git a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx index a6a34da88..018c5c8da 100644 --- a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx @@ -4,6 +4,7 @@ import type { PIDNodeData } from '../types'; import { speciesById, colorForSpecies, UNSET_COLOR } from '../fluids'; import { useNodeFluid } from '../FluidContext'; import { DraggableLabel } from './DraggableLabel'; +import { Upright } from './Upright'; import { portId, portKind } from '../ports'; import { useEffect } from 'react'; @@ -72,7 +73,9 @@ export function TankNode({ id, data, selected }: NodeProps) { - INJ + + INJ + @@ -95,10 +98,12 @@ export function TankNode({ id, data, selected }: NodeProps) { fill={fluidColor + '22'} stroke={stroke} strokeWidth={selected ? 2.5 : 1.5} /> - - {species?.short ?? 'TANK'} - + + + {species?.short ?? 'TANK'} + + diff --git a/pid-designer/frontend/src/components/pid/nodes/Upright.tsx b/pid-designer/frontend/src/components/pid/nodes/Upright.tsx new file mode 100644 index 000000000..bc0d7a2db --- /dev/null +++ b/pid-designer/frontend/src/components/pid/nodes/Upright.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +/** + * Lettering inside a symbol that has been turned. + * + * Rotating a part is about pointing it somewhere. Its lettering should still + * read left to right afterwards, so this undoes the symbol's rotation about + * the same centre: an annotation swings round to stay beside the thing it + * labels, but never ends up sideways or upside down. + */ +export function Upright({ rotation = 0, cx, cy, children }: { + rotation?: number; + cx: number; + cy: number; + children: ReactNode; +}) { + if (!rotation) return <>{children}; + return {children}; +} diff --git a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx index 50b2e0903..c52b7d53e 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx @@ -2,6 +2,7 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Upright } from './Upright'; const W = 60, H = 60; @@ -13,8 +14,8 @@ const W = 60, H = 60; * looks for first, and a two-letter tag in the corner was not enough to see it * across a sheet. */ -function BowtieWithActuator({ selected, actuatorLabel, failOpen }: { - selected: boolean; actuatorLabel: string; failOpen: boolean; +function BowtieWithActuator({ selected, actuatorLabel, failOpen, rotation }: { + selected: boolean; actuatorLabel: string; failOpen: boolean; rotation?: number; }) { const stroke = selected ? '#3b82f6' : '#94a3b8'; return ( @@ -22,7 +23,9 @@ function BowtieWithActuator({ selected, actuatorLabel, failOpen }: { - {actuatorLabel} + + {actuatorLabel} + ); @@ -53,7 +56,7 @@ export function ValveNode({ id, data, selected }: NodeProps) { {componentType === 'MAN' ? - : } + : } {componentType !== 'MAN' && ( ({ + id, type: 'MAN', position: { x, y }, + measured: { width: 60, height: 60 }, + data: { componentType: 'MAN', label: id }, +}); + +/** A → B, left to right, with a stated run in it. */ +function line() { + const nodes = [node('A', 0, 0), node('B', 400, 0)]; + const edges: Edge[] = [{ + id: 'A-B', source: 'A', sourceHandle: 'r', target: 'B', targetHandle: 'l', + type: 'smoothstep', + data: { + lineType: 'pipe', + params: { + length: { value: 3, unit: 'ft', source: 'estimate' }, + bore: { value: 0.5, unit: 'in', source: 'verified' }, + roughness: { value: 1.5e-3, unit: 'mm', source: 'estimate' }, + }, + segments: [{ id: 'seg_1', fittings: [{ id: 'f1', kind: 'elbow_90', count: 2 }] }], + }, + }]; + return { nodes, edges }; +} + +describe('the face a split line meets', () => { + it('points at where the line came from', () => { + expect(faceTowards(0, 30, 200, 30)).toBe('l'); + expect(faceTowards(400, 30, 200, 30)).toBe('r'); + expect(faceTowards(200, 0, 200, 200)).toBe('t'); + expect(faceTowards(200, 400, 200, 200)).toBe('b'); + }); +}); + +describe('dropping a junction into a line', () => { + it('leaves two halves that meet it head-on', () => { + const { nodes, edges } = line(); + const split = splitEdgeAt(nodes, edges, 'A-B', { x: 200, y: 30 })!; + + expect(split.nodes).toHaveLength(3); + expect(split.edges).toHaveLength(2); + + const [up, down] = split.edges; + // A horizontal run stays horizontal: it enters the left face and leaves + // the right one, rather than going up and over the junction. + expect(up).toMatchObject({ source: 'A', target: split.junctionId, targetHandle: 'l' }); + expect(down).toMatchObject({ source: split.junctionId, sourceHandle: 'r', target: 'B' }); + // and the ends it did not touch are untouched + expect(up.sourceHandle).toBe('r'); + expect(down.targetHandle).toBe('l'); + }); + + it('does not double the pipe', () => { + const { nodes, edges } = line(); + const [up, down] = splitEdgeAt(nodes, edges, 'A-B', { x: 200, y: 30 })!.edges; + + // What there is only one of stays with the upstream half... + expect(up.data!.segments).toHaveLength(1); + expect((up.data!.params as Record).length).toMatchObject({ value: 3 }); + // ...so that the pair still adds up to three feet and two elbows. + expect(down.data!.segments).toBeUndefined(); + expect((down.data!.params as Record).length).toBeUndefined(); + + // Bore and roughness are true of both halves, so both get them. + for (const half of [up, down]) { + expect((half.data!.params as Record).bore).toMatchObject({ value: 0.5 }); + expect((half.data!.params as Record).roughness).toBeDefined(); + expect(half.data!.lineType).toBe('pipe'); + } + }); + + it('puts the junction on the line it landed on, not the page it was drawn from', () => { + const { nodes, edges } = line(); + const split = splitEdgeAt(nodes, edges, 'A-B', { x: 200, y: 30 }, 'gse')!; + const junction = split.nodes.find(n => n.id === split.junctionId)!; + expect(junction.data.page).toBe('gse'); + }); + + it('refuses a line it cannot find both ends of', () => { + const { nodes, edges } = line(); + expect(splitEdgeAt(nodes, edges, 'nope', { x: 0, y: 0 })).toBeNull(); + expect(splitEdgeAt([nodes[0]], edges, 'A-B', { x: 0, y: 0 })).toBeNull(); + }); +}); + +describe('taking the junction back out', () => { + it('gives back the line that was split', () => { + const { nodes, edges } = line(); + const [up, down] = splitEdgeAt(nodes, edges, 'A-B', { x: 200, y: 30 })!.edges; + + expect(mergedLineData(up.data, down.data)).toMatchObject({ + lineType: 'pipe', + segments: edges[0].data!.segments, + params: edges[0].data!.params, + }); + }); + + it('adds up what both halves came to say', () => { + const merged = mergedLineData( + { segments: [{ id: 's1' }], params: { length: { value: 3, unit: 'ft', source: 'estimate' } } }, + { segments: [{ id: 's2' }], params: { length: { value: 2, unit: 'ft', source: 'estimate' } } }, + ); + expect(merged.segments).toHaveLength(2); + expect((merged.params as Record).length).toMatchObject({ value: 5, unit: 'ft' }); + }); + + it('keeps the order the run is built in', () => { + const merged = mergedLineData( + { segments: [{ id: 'half_inch' }] }, + { segments: [{ id: 'quarter_inch' }] }, + ); + expect((merged.segments as { id: string }[]).map(s => s.id)) + .toEqual(['half_inch', 'quarter_inch']); + }); + + it('says nothing rather than the wrong thing when the units disagree', () => { + const merged = mergedLineData( + { params: { length: { value: 3, unit: 'ft', source: 'estimate' } } }, + { params: { length: { value: 600, unit: 'mm', source: 'estimate' } } }, + ); + // Not 3, not 603. There is no conversion at this layer, and "not stated" + // is a thing feed-twin reports; a wrong length is not. + expect((merged.params as Record).length).toBeUndefined(); + }); +}); + +describe('what a delete puts back', () => { + const junction = (id: string): Node => ({ + id, type: 'JUNCTION', position: { x: 0, y: 0 }, + data: { componentType: 'JUNCTION', label: id }, + }); + const wire = (id: string, source: string, target: string, data?: Record): Edge => + ({ id, source, target, sourceHandle: 'r', targetHandle: 'l', data }); + + it('rejoins a run through the junction that was taken out', () => { + const back = rejoinAfterDelete( + [junction('j1')], + [wire('a', 'A', 'j1', { lineType: 'pipe' }), wire('b', 'j1', 'B')], + ); + expect(back).toHaveLength(1); + expect(back[0]).toMatchObject({ source: 'A', target: 'B', data: { lineType: 'pipe' } }); + }); + + it('follows a chain, so two junctions in a row still leave one line', () => { + const back = rejoinAfterDelete( + [junction('j1'), junction('j2')], + [wire('a', 'A', 'j1'), wire('b', 'j1', 'j2'), wire('c', 'j2', 'B')], + ); + expect(back).toHaveLength(1); + expect(back[0]).toMatchObject({ source: 'A', target: 'B' }); + }); + + it('leaves a branch alone: three legs are not one run', () => { + // A tee with a relief valve on it. There is no single line to give back, + // so deleting it takes what was attached, as it always did. + expect(rejoinAfterDelete( + [junction('j1')], + [wire('a', 'A', 'j1'), wire('b', 'j1', 'B'), wire('c', 'RV', 'j1')], + )).toEqual([]); + }); + + it('does not rejoin to something that was deleted too', () => { + expect(rejoinAfterDelete( + [junction('j1'), { id: 'B', type: 'MAN', position: { x: 0, y: 0 }, data: { componentType: 'MAN' } }], + [wire('a', 'A', 'j1'), wire('b', 'j1', 'B')], + )).toEqual([]); + }); + + it('ignores a delete with no junction in it', () => { + expect(rejoinAfterDelete( + [{ id: 'V', type: 'MAN', position: { x: 0, y: 0 }, data: { componentType: 'MAN' } }], + [wire('a', 'A', 'V'), wire('b', 'V', 'B')], + )).toEqual([]); + }); + + it('adds the two halves back up', () => { + const { nodes, edges } = line(); + const split = splitEdgeAt(nodes, edges, 'A-B', { x: 200, y: 30 })!; + const j = split.nodes.find(n => n.id === split.junctionId)!; + const [back] = rejoinAfterDelete([j], split.edges); + expect(back.data).toMatchObject({ + segments: edges[0].data!.segments, + params: edges[0].data!.params, + }); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/splitEdge.ts b/pid-designer/frontend/src/components/pid/splitEdge.ts new file mode 100644 index 000000000..98d5d1f92 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/splitEdge.ts @@ -0,0 +1,220 @@ +import type { Edge, Node, XYPosition } from '@xyflow/react'; +import { nextJunctionId } from './ids'; +import { faceTowards } from './BranchableEdge'; +import type { PIDNodeData } from './types'; +import type { ParamValue } from './params'; +import type { LineSegment } from './segments'; + +/** + * Put a junction into a line. + * + * The one operation behind two gestures: the Junction tool, and dropping a + * connection onto a line. Both need identical results -- the same node, the + * same two halves, the same faces -- so they share this rather than each + * growing their own version that drifts. + * + * A junction is a point *in* a run, so the two halves are the same pipe told + * apart at a tee -- but only the *intensive* facts copy to both. See + * `EXTENSIVE_LINE_PARAMS`. + */ + +/** + * Line quantities that describe how *much* pipe there is, not what kind. + * + * This is the whole of why a split is not a copy. Bore, roughness and bend + * radius are true of both halves of a cut line; three feet and two elbows are + * true of the pair *together*, and giving both halves a copy would double the + * run's pressure drop every time somebody dropped a junction on it. + * + * So the tally stays with the upstream half and the downstream half starts + * unstated -- which in this codebase means "not stated", never "zero", so + * feed-twin defaults it and reports it unchecked rather than believing a zero + * nobody typed. Re-apportioning it is then a deliberate edit. + */ +export const EXTENSIVE_LINE_PARAMS = ['length', 'K_minor', 'end_fitting_K'] as const; + +/** The half of a split line that keeps what there is only one of. */ +function intensiveOnly(data: Record): Record { + const out = { ...data }; + delete out.segments; + const params = out.params as Record | undefined; + if (params) { + const kept = { ...params }; + for (const k of EXTENSIVE_LINE_PARAMS) delete kept[k]; + out.params = kept; + } + return out; +} + +const J_HALF = 5; + +const centre = (n: Node): XYPosition => ({ + x: n.position.x + (n.measured?.width ?? 60) / 2, + y: n.position.y + (n.measured?.height ?? 60) / 2, +}); + +export interface Split { + nodes: Node[]; + edges: Edge[]; + junctionId: string; +} + +export function splitEdgeAt( + nodes: Node[], + edges: Edge[], + edgeId: string, + at: XYPosition, + page?: string, + /** + * Where the line actually starts and ends, when the caller knows. + * + * The edge itself knows its two handle positions exactly; a caller working + * from graph data alone only has the node boxes. Both pick the same face for + * an ordinary run, so the centres are a fine default -- but a line leaving + * the top of one part and entering the side of another is not ordinary. + */ + ends?: { from: XYPosition; to: XYPosition }, +): Split | null { + const edge = edges.find(e => e.id === edgeId); + if (!edge) return null; + const from = nodes.find(n => n.id === edge.source); + const to = nodes.find(n => n.id === edge.target); + if (!from || !to) return null; + + const junctionId = nextJunctionId(); + const a = ends?.from ?? centre(from); + const b = ends?.to ?? centre(to); + + const junction: Node = { + id: junctionId, + type: 'JUNCTION', + position: { x: at.x - J_HALF, y: at.y - J_HALF }, + data: { + componentType: 'JUNCTION', + label: junctionId, + // A junction inherits the page of the line it lands on, so one never + // appears on a page its own pipe is not drawn on. + page: page ?? (from.data as unknown as PIDNodeData)?.page, + } as unknown as Record, + }; + + const carried = { ...(edge.data ?? {}), offset: 0 }; + const downstream = intensiveOnly(carried); + + return { + nodes: [...nodes, junction], + edges: [ + ...edges.filter(e => e.id !== edgeId), + { + ...edge, + id: `${edge.source}-${junctionId}`, + target: junctionId, + targetHandle: faceTowards(a.x, a.y, at.x, at.y), + data: carried, + }, + { + ...edge, + id: `${junctionId}-${edge.target}`, + source: junctionId, + sourceHandle: faceTowards(b.x, b.y, at.x, at.y), + target: edge.target, + targetHandle: edge.targetHandle, + data: downstream, + }, + ], + junctionId, + }; +} + +/** + * The two halves of a rejoined line, as one line again. + * + * The inverse of the split above, and it has to be: take a junction out of a + * run you have just put one into and you should get the run back, not a + * different one. + * + * Segments are ordered along the run, so upstream's followed by downstream's + * *is* the run once the tee between them is gone. Extensive params add up when + * both halves state them in the same unit; when the units differ there is no + * conversion at this layer, so the result is left unstated rather than + * pretending one of the two numbers was the whole run. + */ +export function mergedLineData( + a: Record | undefined, + b: Record | undefined, +): Record { + const merged: Record = { ...a }; + + const segments = [ + ...((a?.segments as LineSegment[] | undefined) ?? []), + ...((b?.segments as LineSegment[] | undefined) ?? []), + ]; + if (segments.length) merged.segments = segments; + + const pa = a?.params as Record | undefined; + const pb = b?.params as Record | undefined; + if (pa || pb) { + const params: Record = { ...pa }; + for (const k of EXTENSIVE_LINE_PARAMS) { + const x = pa?.[k]; + const y = pb?.[k]; + if (!y) continue; + if (!x) { params[k] = y; continue; } + if (x.unit !== y.unit) { delete params[k]; continue; } + params[k] = { ...x, value: Number(x.value) + Number(y.value) }; + } + merged.params = params; + } + + return merged; +} + +/** + * The lines to put back after a delete took some junctions with them. + * + * Works from what was deleted, not from what is left: React Flow has already + * removed both halves by the time a handler runs, so there is nothing in the + * current edge list to rejoin. + * + * A run is rejoined only where every junction along it was genuinely mid-line, + * one edge in and one out. A junction with a third leg on it is not a point in + * a single run, so there is no run to give back and everything attached goes, + * which is the ordinary behaviour. Deleting two adjacent junctions still leaves + * one line, because the walk follows the chain to whatever survives. + */ +export function rejoinAfterDelete(deletedNodes: Node[], deletedEdges: Edge[]): Edge[] { + const gone = new Set(deletedNodes.map(n => n.id)); + const junctions = new Set(deletedNodes + .filter(n => (n.data as unknown as PIDNodeData)?.componentType === 'JUNCTION') + .map(n => n.id)); + if (junctions.size === 0) return []; + + const rejoined: Edge[] = []; + for (const first of deletedEdges) { + // Start only from a line whose upstream end survives. + if (gone.has(first.source) || !junctions.has(first.target)) continue; + + let edge = first; + let data = first.data; + let ok = true; + for (;;) { + const j = edge.target; + const inTo = deletedEdges.filter(e => e.target === j); + const outOf = deletedEdges.filter(e => e.source === j); + if (inTo.length !== 1 || outOf.length !== 1) { ok = false; break; } + edge = outOf[0]; + data = mergedLineData(data, edge.data); + if (!junctions.has(edge.target)) break; + } + if (!ok || gone.has(edge.target)) continue; + + rejoined.push({ + ...first, + id: `${first.source}-${edge.target}-rejoined`, + target: edge.target, + targetHandle: edge.targetHandle, + data, + }); + } + return rejoined; +} From b9699ed911a50795dcd14e1aeb48880163d838a5 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 13:10:20 -0700 Subject: [PATCH 28/57] Open a drawing and you can see it; click a junction and you get it An adversarial pass over the branching work, and most of what it found was older than the branching. **Opening a diagram left you at 1:1 on the origin.** The `fitView` prop fits the nodes present at the first render and a diagram arrives from the server a moment later, so the frame nobody had asked for never happened. Same for pages: switching to one whose contents are drawn somewhere else showed empty canvas, and a page bar that appears to do nothing reads as broken. Both are "nobody has been here yet", so both are one rule -- no remembered viewport for this (diagram, page) means frame it, one means put it back, and where you were looking is now per page. **Taking the checkout threw the viewport away.** The canvas remounts, and the gesture that means "I would like to edit this" dropped the reader at the origin, away from the thing they were about to edit. And Fit View itself was dead on the first press after a Take, because it ran on the ReactFlow instance `onInit` had handed up -- which belonged to the mount that had just been replaced. Same root cause as the palette dropping nothing right after Take; fixed the same way, off the live store. That was the last thing holding an instance in state, so the whole chain is gone. **A junction could not be clicked.** It carried `nodrag`, which turns off the pointer handling that *selects* a node as well as the part that moves it -- so the dot could not be picked, Delete over it did nothing, and it could never be nudged off a bad spot. It selects, moves, shows that it is selected, and has a hit area you can actually hit. **A drop could land on another page.** `targetAt` hit-tested the whole document, and the graph is whole on purpose -- so a probe could clip to, or a junction land in, something not on screen. Scoped to the page. **Two narrower ones on the branch-by-dropping gesture.** It refused nothing when you dropped a connection on a line that component is already an end of, which is a parallel path and not what anybody meant. And it put the junction where the pointer was rather than on the pipe: the hit test measures against the straight line between two ends and the run is drawn orthogonally, so those differ by the whole depth of a bend. **The checks panel said everything twice.** "TK-1 has no operating pressure", then "An operating pressure is not set", then a lesson about boundary conditions. Now the title says what, one line says why, and six findings fit where three did. `onDelete` and `onConnectEnd` join the gating audit: both rewrite the graph without going through a control, so no audit of controls would see them. Verified it fails on the unguarded version. --- .../src/components/pid/BranchableEdge.tsx | 9 +- .../src/components/pid/PIDDesigner.tsx | 117 +++++++++++++++--- .../src/components/pid/PIDToolbar.tsx | 10 +- .../src/components/pid/attach.test.ts | 30 +++++ .../frontend/src/components/pid/attach.ts | 17 ++- .../frontend/src/components/pid/checks.ts | 33 ++--- .../src/components/pid/nodes/JunctionNode.tsx | 26 +++- pid-designer/frontend/src/lib/gating.test.ts | 5 + 8 files changed, 202 insertions(+), 45 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx index 55282d5c5..57829267a 100644 --- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx +++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx @@ -144,8 +144,11 @@ export function BranchableEdge(props: EdgeProps) { // splitEdge.ts. This used to be a second copy of it here, and the two had // already drifted: one stamped a junction the delete-rejoin could // recognise and the other did not. + // No page argument: a junction belongs on the page its own pipe is drawn + // on, and `splitEdgeAt` reads that off the line's upstream end. Pages live + // on components, so asking the edge would be asking the wrong thing. const split = splitEdgeAt( - getNodes(), getEdges(), id, hoverAt, (data as { page?: string })?.page, + getNodes(), getEdges(), id, hoverAt, undefined, // The exact handle positions, which this edge knows and a caller working // from the node boxes does not. { from: { x: sourceX, y: sourceY }, to: { x: targetX, y: targetY } }, @@ -156,7 +159,7 @@ export function BranchableEdge(props: EdgeProps) { setNodes(split.nodes); setEdges(split.edges); }); - }, [armed, hoverAt, dragging, id, data, getNodes, getEdges, setNodes, setEdges, + }, [armed, hoverAt, dragging, id, getNodes, getEdges, setNodes, setEdges, sourceX, sourceY, targetX, targetY]); return ( @@ -226,7 +229,7 @@ function pointsOf(d: string): { x: number; y: number }[] { * the pipe -- including exactly on a corner, which is where people aim when * they want to branch at a bend. */ -function nearestOnPath(d: string, p: { x: number; y: number }): { x: number; y: number } { +export function nearestOnPath(d: string, p: { x: number; y: number }): { x: number; y: number } { const pts = pointsOf(d); let best = pts[0] ?? p; let bestDist = Infinity; diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index 1f39b26f5..d24d81390 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -9,11 +9,12 @@ import { useNodesState, useEdgesState, useReactFlow, + useNodesInitialized, + type Viewport, applyNodeChanges, BackgroundVariant, SelectionMode, ConnectionMode, - type ReactFlowInstance, type Connection, type Node, type NodeChange, @@ -49,6 +50,7 @@ import { PageBar } from './PageBar'; import { DEFAULT_PAGE, applyPage, listPages, moveToPage, pageOf } from './pages'; import { clearOfHost, dragAttached, isInstrument, targetAt } from './attach'; import { rejoinAfterDelete, splitEdgeAt } from './splitEdge'; +import { nearestOnPath } from './BranchableEdge'; import { COMPONENT_SPECS } from './spec'; export type InteractionMode = 'pan' | 'select'; @@ -147,7 +149,21 @@ function useHistory( // ── Inner canvas ───────────────────────────────────────────────────────────── interface CanvasProps { diagramRef: DocRef; - onInstance: (inst: ReactFlowInstance) => void; + fitRef: React.MutableRefObject<() => void>; + /** + * Where the reader was looking, per diagram and page. + * + * Lives above the canvas because the canvas remounts whenever the checkout + * changes: without it, the gesture that means "I would like to edit this" + * dropped them back at the origin at 1:1, moving the drawing out from under + * the thing they were about to edit. + * + * Per *page* because pages are separate sheets. A page nobody has looked at + * yet has no entry, and that absence is what asks for it to be framed -- + * which is also how a freshly opened diagram gets framed, with no special + * case for it. + */ + viewportsRef: React.MutableRefObject>; getRef: React.MutableRefObject<() => Snapshot>; loadRef: React.MutableRefObject<(d: Snapshot) => void>; clearRef: React.MutableRefObject<() => void>; @@ -167,7 +183,7 @@ interface CanvasProps { } function PIDCanvas({ - diagramRef, onInstance, getRef, loadRef, clearRef, clearCountRef, undoRef, redoRef, + diagramRef, fitRef, viewportsRef, getRef, loadRef, clearRef, clearCountRef, undoRef, redoRef, releaseRef, getHistoryRef, getReleasesRef, restoreMicroRef, restoreReleaseRef, onForbidden, onLockLost, mode, }: CanvasProps) { @@ -199,7 +215,7 @@ function PIDCanvas({ // object, so the dialog reads live data and a save is never applied to a // stale copy. const [configFor, setConfigFor] = useState<{ kind: 'node' | 'edge'; id: string } | null>(null); - const { screenToFlowPosition, setCenter, getZoom } = useReactFlow(); + const { screenToFlowPosition, setCenter, getZoom, fitView, setViewport } = useReactFlow(); const { undo, redo } = useHistory(nodes, edges, setNodes, setEdges); @@ -392,9 +408,46 @@ function PIDCanvas({ setEdges(data.edges); }, [diagramKey, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps - // Handed up so the toolbar can fitView and export. Nothing in this component - // needs it -- see `screenToFlowPosition` above. - const onInit = useCallback((inst: ReactFlowInstance) => onInstance(inst), [onInstance]); + /** + * Framing the drawing, from the live store rather than a captured instance. + * + * `fitView` used to be called on the ReactFlow instance `onInit` handed up, + * and that instance belongs to one mount -- so the press right after taking + * the checkout went to the canvas that had just been replaced and did + * nothing, and it took a second press to work. Same root cause as the + * palette dropping nothing right after Take, fixed the same way. + */ + fitRef.current = useCallback(() => { void fitView({ padding: 0.1 }); }, [fitView]); + + /** + * Show a page when you arrive on it, and leave it where you left it. + * + * Two things that used to be wrong, and are one thing. The `fitView` prop + * only fits the nodes present at the *first* render and a diagram arrives + * from the server a moment later, so opening one left the reader at 1:1 on + * the origin looking at empty canvas. And switching to a page whose contents + * are drawn somewhere else did the same, which is worse, because a page bar + * that appears to do nothing reads as broken. + * + * Both are "nobody has been here yet": no remembered viewport for this + * (diagram, page) means frame it, and one means put it back. Waits for + * `useNodesInitialized`, because fitting before anything is measured frames + * nothing. + */ + const nodesReady = useNodesInitialized(); + const viewKey = `${diagramKey}::${page}`; + useEffect(() => { + if (!nodesReady) return; + const seen = viewportsRef.current.get(viewKey); + if (seen) { setViewport(seen); return; } + if (!nodes.some(n => pageOf(n.data as unknown as PIDNodeData) === page)) return; + void fitView({ padding: 0.1 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewKey, nodesReady]); + + const rememberViewport = useCallback( + (_: unknown, vp: Viewport) => { viewportsRef.current.set(viewKey, vp); }, + [viewportsRef, viewKey]); const edgeTypes = useMemo(() => ({ smoothstep: BranchableEdge, default: BranchableEdge }), []); @@ -549,10 +602,21 @@ function PIDCanvas({ const { nodes: ns, edges: es } = snapshot.current; // Only when it landed on a line and not on a component -- React Flow has // already made the connection in that case. - const hit = targetAt(flow, ns, es, from.nodeId); + const hit = targetAt(flow, ns, es, from.nodeId, pageRef.current); if (!hit || hit.kind !== 'edge') return; - const split = splitEdgeAt(ns, es, hit.id, flow, pageRef.current); + // Not onto a line this component is already an end of. That would be two + // lines from the same port to the same junction, which is a parallel path + // and not what anybody dragging there meant. + const line = es.find(e => e.id === hit.id); + if (!line || line.source === from.nodeId || line.target === from.nodeId) return; + + // Onto the pipe as drawn, not where the pointer happened to be. The hit + // test measures against the straight line between the two ends, and the + // run is drawn orthogonally, so those differ by the whole depth of a bend. + const at = onDrawnPath(hit.id, flow); + + const split = splitEdgeAt(ns, es, hit.id, at, pageRef.current); if (!split) return; setNodes(split.nodes); @@ -593,7 +657,7 @@ function PIDCanvas({ // An instrument dropped on top of a component or a line measures *that*. // No edge, because a probe carries no flow -- see attach.ts. const host = isInstrument(type) - ? targetAt(flowPos, snapshot.current.nodes, snapshot.current.edges) + ? targetAt(flowPos, snapshot.current.nodes, snapshot.current.edges, undefined, pageRef.current) : null; // Stand the probe clear of what it is measuring. Dropped exactly where the // pointer was, it covers the symbol it is attached to -- and the whole @@ -672,6 +736,7 @@ function PIDCanvas({ * ordinary behaviour stands and everything attached goes with it. */ const onDelete = useCallback(({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) => { + if (readOnlyRef.current) return; const rejoined = rejoinAfterDelete(nodes, edges); if (rejoined.length) setEdges(eds => [...eds, ...rejoined]); }, [setEdges]); @@ -775,7 +840,7 @@ function PIDCanvas({ @@ -919,7 +985,6 @@ function PIDCanvas({ // ── Top-level designer ──────────────────────────────────────────────────────── export function PIDDesigner() { - const [rfInstance, setRfInstance] = useState(null); const [mode, setMode] = useState('pan'); const [diagrams, setDiagrams] = useState([]); @@ -935,6 +1000,8 @@ export function PIDDesigner() { const clearRef = useRef<() => void>(() => {}); const clearCountRef = useRef<() => { page: string; nodes: number; edges: number }>( () => ({ page: '', nodes: 0, edges: 0 })); + const fitRef = useRef<() => void>(() => {}); + const viewportsRef = useRef>(new Map()); const undoRef = useRef<() => void>(() => {}); const redoRef = useRef<() => void>(() => {}); const releaseRef = useRef<(label: string) => Promise<{ label: string; savedAt: string }>>(() => Promise.resolve({ label: '', savedAt: '' })); @@ -943,7 +1010,6 @@ export function PIDDesigner() { const restoreMicroRef = useRef<(versionId: string) => Promise>(() => Promise.resolve()); const restoreReleaseRef = useRef<(label: string) => Promise>(() => Promise.resolve()); - const handleInstance = useCallback((inst: ReactFlowInstance) => setRfInstance(inst), []); // Load the user's diagram list once; create a first one if they have none. useEffect(() => { @@ -1084,7 +1150,7 @@ export function PIDDesigner() {

fitRef.current()} getSnapshot={() => getRef.current()} loadSnapshot={d => loadRef.current(d)} onClear={() => clearRef.current()} @@ -1110,7 +1176,8 @@ export function PIDDesigner() { // between them would otherwise reuse one canvas's state. key={`${activeKey}:${reloadKey}`} diagramRef={activeRef} - onInstance={handleInstance} + fitRef={fitRef} + viewportsRef={viewportsRef} getRef={getRef} loadRef={loadRef} clearRef={clearRef} @@ -1137,3 +1204,21 @@ export function PIDDesigner() { ); } + +/** + * A point moved onto the line as it is actually drawn. + * + * The rendered path is the same one the reader clicked on and the same one the + * Junction tool snaps to, so it is read back rather than recomputed -- the edge + * owns its routing, including a crossbar somebody has dragged, and duplicating + * that here would be a second version of it to keep in step. + * + * Falls back to the point given. A junction a few pixels off a pipe is worse + * than one exactly where somebody let go, but both beat not making one. + */ +function onDrawnPath(edgeId: string, at: { x: number; y: number }): { x: number; y: number } { + const el = document.querySelector( + `.react-flow__edge[data-id="${CSS.escape(edgeId)}"] .react-flow__edge-path`); + const d = el?.getAttribute('d'); + return d ? nearestOnPath(d, at) : at; +} diff --git a/pid-designer/frontend/src/components/pid/PIDToolbar.tsx b/pid-designer/frontend/src/components/pid/PIDToolbar.tsx index 9d76baf62..d50f8f1e0 100644 --- a/pid-designer/frontend/src/components/pid/PIDToolbar.tsx +++ b/pid-designer/frontend/src/components/pid/PIDToolbar.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; -import type { ReactFlowInstance, Node, Edge } from '@xyflow/react'; +import type { Node, Edge } from '@xyflow/react'; import type { InteractionMode, MicroVersion, ReleaseVersion } from './PIDDesigner'; import { useReadOnly } from '@stardesign-ui'; import { Modal } from '../ui'; interface PIDToolbarProps { - rfInstance: ReactFlowInstance | null; + onFitView: () => void; getSnapshot: () => { nodes: Node[]; edges: Edge[] }; loadSnapshot: (data: { nodes: Node[]; edges: Edge[] }) => void; onClear: () => void; @@ -34,7 +34,7 @@ function relativeTime(iso: string): string { } export function PIDToolbar({ - rfInstance, getSnapshot, loadSnapshot, onClear, clearSummary, onUndo, onRedo, + onFitView, getSnapshot, loadSnapshot, onClear, clearSummary, onUndo, onRedo, onRelease, onGetHistory, onGetReleases, onRestoreMicro, onRestoreRelease, canVersion, mode, onModeChange, }: PIDToolbarProps) { @@ -42,7 +42,7 @@ export function PIDToolbar({ // need the checkout. Pan / Select / Fit View / Export / History only change // what you are looking at, and stay live. const readOnly = useReadOnly(); - const fitView = () => rfInstance?.fitView({ padding: 0.1 }); + const fitView = () => onFitView(); const [showRelease, setShowRelease] = useState(false); const [relLabel, setRelLabel] = useState(''); @@ -154,7 +154,7 @@ export function PIDToolbar({ const data = JSON.parse(await file.text()) as { nodes: Node[]; edges: Edge[] }; if (Array.isArray(data.nodes) && Array.isArray(data.edges)) { loadSnapshot(data); - setTimeout(() => rfInstance?.fitView({ padding: 0.1 }), 100); + setTimeout(() => onFitView(), 100); } } catch { alert('Invalid P&ID JSON file.'); } }; diff --git a/pid-designer/frontend/src/components/pid/attach.test.ts b/pid-designer/frontend/src/components/pid/attach.test.ts index 30bcae633..d540ec854 100644 --- a/pid-designer/frontend/src/components/pid/attach.test.ts +++ b/pid-designer/frontend/src/components/pid/attach.test.ts @@ -74,3 +74,33 @@ describe('which components attach rather than connect', () => { expect(['PT', 'PG', 'TANK', 'SOL', 'PR', 'QD', 'ENGINE'].some(isInstrument)).toBe(false); }); }); + +describe('the page you are looking at', () => { + const gse: Node[] = [ + { id: 'TANK', type: 'TANK', position: { x: 0, y: 0 }, + measured: { width: 60, height: 100 }, + data: { componentType: 'TANK', label: 'TANK', page: 'GSE' } }, + { id: 'SOL', type: 'SOL', position: { x: 300, y: 20 }, + measured: { width: 60, height: 60 }, + data: { componentType: 'SOL', label: 'SOL', page: 'GSE' } }, + ]; + const wire: Edge[] = [{ id: 'e1', source: 'TANK', target: 'SOL' }]; + + it('is the only page a drop can land on', () => { + // The graph is whole so fluid and checks span pages -- which means an + // unscoped hit test would clip a probe to a tank that is not on screen. + expect(targetAt({ x: 30, y: 50 }, gse, wire, undefined, 'GSE')) + .toEqual({ id: 'TANK', kind: 'node' }); + expect(targetAt({ x: 30, y: 50 }, gse, wire, undefined, 'Main')).toBeNull(); + }); + + it('hides the lines on it too', () => { + expect(targetAt({ x: 180, y: 54 }, gse, wire, undefined, 'GSE')) + .toEqual({ id: 'e1', kind: 'edge' }); + expect(targetAt({ x: 180, y: 54 }, gse, wire, undefined, 'Main')).toBeNull(); + }); + + it('hits everything when no page is named', () => { + expect(targetAt({ x: 30, y: 50 }, gse, wire)).toEqual({ id: 'TANK', kind: 'node' }); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/attach.ts b/pid-designer/frontend/src/components/pid/attach.ts index ec2485d2f..887c069c5 100644 --- a/pid-designer/frontend/src/components/pid/attach.ts +++ b/pid-designer/frontend/src/components/pid/attach.ts @@ -1,5 +1,6 @@ import type { Edge, Node, XYPosition } from '@xyflow/react'; import type { PIDNodeData } from './types'; +import { pageOf } from './pages'; /** * Instruments clip to what they are measuring. @@ -49,12 +50,25 @@ export function targetAt( nodes: Node[], edges: Edge[], selfId?: string, + /** + * The page being looked at. + * + * Without it this hit-tests the whole document, and the graph is whole on + * purpose -- so a drop on empty canvas could clip a probe to, or put a + * junction in, something on a page that is not even on screen. The caller + * passes the state before `applyPage`, so `hidden` is not set on it yet and + * the page has to be asked for directly. + */ + page?: string, ): AttachTarget | null { + const here = (n: Node) => + !page || pageOf(n.data as unknown as PIDNodeData) === page; + // Components first: dropping a probe on a valve that happens to sit on a // line means the valve, which is the more specific of the two. for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]; - if (n.id === selfId) continue; + if (n.id === selfId || !here(n)) continue; const t2 = (n.data as unknown as PIDNodeData)?.componentType; // Never clip a probe to another probe, and never to a section box: a // region is scenery drawn over half the diagram, so it would swallow @@ -81,6 +95,7 @@ export function targetAt( const a = nodes.find(n => n.id === e.source); const b = nodes.find(n => n.id === e.target); if (!a || !b) continue; + if (!here(a) || !here(b)) continue; if (distanceToSegment(point, centre(a), centre(b)) <= TOLERANCE) { return { id: e.id, kind: 'edge' }; } diff --git a/pid-designer/frontend/src/components/pid/checks.ts b/pid-designer/frontend/src/components/pid/checks.ts index 0e2eba908..ff1c1e16b 100644 --- a/pid-designer/frontend/src/components/pid/checks.ts +++ b/pid-designer/frontend/src/components/pid/checks.ts @@ -121,7 +121,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: `tag-duplicate-${tag}`, severity: 'warning', title: `${sharing.length} components are all tagged ${tag}`, - detail: 'A tag is the name one piece of hardware answers to on the drawing, in a run report and in a procedure. Two sharing it is two things nobody can tell apart.', + detail: 'Rename one. A solve, a report and a procedure all key on the tag.', nodeIds: sharing.map(n => n.id), }); } @@ -169,7 +169,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: 'fluid-unassigned', severity: 'info', title: `${noFluid.length} component${noFluid.length === 1 ? '' : 's'} with no fluid`, - detail: 'Nothing that declares a fluid reaches these, so they are not downstream of any tank yet. Set the fluid on the tanks that feed them, or join them up.', + detail: 'Nothing that declares a fluid reaches them. Set it on the tank that feeds them, or join them up.', nodeIds: noFluid.map(n => n.id), }); } @@ -180,15 +180,15 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { const t = d?.componentType; if (t === 'TANK') { if (!d.params?.pressure) missing(push, n, 'an operating pressure', - 'A tank is where a feed solve starts: its pressure is the boundary condition everything downstream is measured against.'); + 'A solve starts here; without it there is no boundary condition.'); if (!d.params?.temperature) missing(push, n, 'a propellant temperature', - 'Fluid properties are read at a temperature. Without one there is no density, and without density there is no flow.'); + 'No temperature, no density, no flow.'); if (!speciesById(d.fluid)) missing(push, n, 'a fluid', - 'Set what is in this tank and every line downstream of it inherits it.'); + 'Set it here and every line downstream inherits it.'); } if (t === 'ENGINE') { if (!d.params?.chamber_pressure) missing(push, n, 'a chamber pressure', - 'Chamber pressure is the back pressure the whole feed system works against — the other end of the solve.'); + 'It is the back pressure the whole feed works against.'); } } @@ -202,7 +202,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: 'lines-unsized', severity: 'info', title: `${bare.length} line${bare.length === 1 ? '' : 's'} with no length or bore`, - detail: 'Most of the pressure drop in a feed system is in the pipe. Double-click a line to set what it is, or name a catalogue part.', + detail: 'Double-click a line to set what it is. Most of a feed system’s pressure drop is in the pipe.', edgeIds: bare.map(e => e.id), }); } @@ -229,7 +229,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: 'lines-orphaned-port', severity: 'error', title: `${orphaned.length} line${orphaned.length === 1 ? '' : 's'} attached to a port that is gone`, - detail: 'The port was removed or plugged after the line was drawn, so the line is saved but cannot be drawn. Re-attach it, or put the port back.', + detail: 'Saved but not drawable: the port went away after the line did. Re-attach it, or put the port back.', edgeIds: orphaned.map(e => e.id), }); } @@ -253,7 +253,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: 'lines-cross-pages', severity: 'warning', title: `${crossing.length} line${crossing.length === 1 ? '' : 's'} run between pages`, - detail: 'A line between pages is not drawn on either, because a reader cannot follow it. What crosses the umbilical is a disconnect pair — put a QD on each side and pair them instead.', + detail: 'Neither page draws it. Put a QD on each side and pair them — that is what crosses an umbilical.', edgeIds: crossing.map(e => e.id), }); } @@ -269,7 +269,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: `qd-samepage-${qd.id}`, severity: 'info', title: `${nameOf(qd)} and ${nameOf(other)} are on the same page`, - detail: 'A disconnect pair is the boundary between the vehicle and the ground, so its two halves usually live on different pages. Worth a look if that is not what you meant.', + detail: 'Usually right — a pair is the vehicle/ground boundary. Worth a look if it is not what you meant.', nodeIds: [qd.id, other.id], }); } @@ -303,7 +303,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: 'lines-crossing', severity: 'info', title: `${crossings} place${crossings === 1 ? '' : 's'} where lines cross`, - detail: 'Crossing lines are not joined. Where two are meant to meet, click the line to drop a junction on it; where they are not, drag a line’s middle segment to route around.', + detail: 'Crossing is not joining. To join them, drag one onto the other; to keep them apart, drag a line’s middle segment.', }); } @@ -318,7 +318,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: 'instruments-wired', severity: 'warning', title: `${wired.length} instrument${wired.length === 1 ? '' : 's'} wired into the flow path`, - detail: 'A probe carries no flow, so a solver treats it as a dead end and it makes the drawing harder to read. Delete the lines and drop it straight onto what it measures instead.', + detail: 'Delete the lines and drop it straight onto what it measures — a probe carries no flow.', nodeIds: wired.map(n => n.id), }); } @@ -352,7 +352,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { id: 'params-assumed', severity: 'info', title: `${assumed.length} value${assumed.length === 1 ? '' : 's'} nobody has established`, - detail: `Recorded as estimated or unchecked: ${assumed.slice(0, 8).join(', ')}${assumed.length > 8 ? `, and ${assumed.length - 8} more` : ''}. Not a fault — it is the list a design review should be looking at.`, + detail: `${assumed.slice(0, 8).join(', ')}${assumed.length > 8 ? `, and ${assumed.length - 8} more` : ''} — the list a design review should be looking at.`, }); } @@ -366,13 +366,14 @@ function missing(push: (f: Finding) => void, n: Node, what: string, why: string) id: `missing-${n.id}-${what.replace(/\s+/g, '-')}`, severity: 'warning', title: `${nameOf(n)} has no ${what.replace(/^an? /, '')}`, - detail: `${capitalise(what)} is not set. ${why}`, + // Just the reason. It used to open with " is not set", which is + // what the title above it already says -- and a panel that says everything + // twice is one people stop reading. + detail: why, nodeIds: [n.id], }); } -const capitalise = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - /** What the badge shows: things that are actually wrong. */ export const countProblems = (findings: Finding[]) => findings.filter(f => f.severity !== 'info').length; diff --git a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx index 3e98ba6de..eaa037a33 100644 --- a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx @@ -1,7 +1,16 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; -export function JunctionNode(_props: NodeProps) { +/** + * A branch point: the tee, as the drawing says it. + * + * Small on purpose -- it is a point in a run, not a component -- but it is + * still a thing people select, move and delete, so it has to behave like one. + * It carried `nodrag`, which in ReactFlow turns off the pointer handling that + * *selects* a node as well as the part that moves it: the dot could not be + * picked at all, and pressing Delete over it did nothing. + */ +export function JunctionNode({ selected }: NodeProps) { const handleStyle = { width: 10, height: 10, @@ -15,13 +24,22 @@ export function JunctionNode(_props: NodeProps) { width: 10, height: 10, borderRadius: '50%', - background: '#94a3b8', + background: selected ? '#3b82f6' : '#94a3b8', border: '2px solid #0f172a', - boxShadow: '0 0 0 2px #475569', + boxShadow: `0 0 0 2px ${selected ? '#3b82f6' : '#475569'}`, position: 'relative', + cursor: 'grab', }} - className="nodrag" > + {/* Ten pixels is a hard thing to hit. This reaches past the dot without + drawing anything, so aiming at a junction is aiming at a target the + size of a symbol -- and it sits under the handles, so a drag that + starts on one still draws a line. */} +
+ diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts index 6fddf7cbd..d77009d52 100644 --- a/pid-designer/frontend/src/lib/gating.test.ts +++ b/pid-designer/frontend/src/lib/gating.test.ts @@ -77,6 +77,11 @@ const MUST_DERIVE_FROM_READONLY = [ 'elementsSelectable', 'edgesReconnectable', 'deleteKeyCode', + // Both rewrite the graph without going through a control: dropping a + // connection on a line branches it, and deleting a junction puts the run + // back. Neither is a button an audit of controls would ever see. + 'const onConnectEnd', + 'const onDelete', 'loadRef.current', 'clearRef.current', 'undoRef.current', From a9224ea23a188c31d5efb2081e0e73df6e88b131 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 13:22:32 -0700 Subject: [PATCH 29/57] Take no longer moves you, and the line panel says which numbers count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More of the same pass. **Take threw you off the page you were on.** `page` and the list of declared-but-empty pages were state inside the canvas, and the canvas remounts on Take -- so editing a GSE drawing put you back on Main. Both now live above the remount, next to the viewport. **Clearing a page deleted the page.** A page exists because components are on it, so emptying one removed it from the bar and dropped the reader on Main -- which is neither what the confirmation says nor what somebody starting a page over wants. Clear declares the page it emptied, and `listPages` now orders used-then-empty so a tab does not jump when a page crosses between those two states in either direction. **The line panel reported guesses as facts.** Three of them. "ΣK 0.00 + 3 fittings" summed the *bore-transition* K, which is zero when there are no transitions -- reporting "nothing stated" as "nothing", on a line whose fittings feed-twin prices from geometry. The flow-path figure printed a length in metres that was whatever the picture needed when no segment stated one. And "radius ×11.2" read as a bend radius when it is the bore exaggeration, without which a metre of 10 mm tube is a hairline. **Two ways to say how long a line is, and nothing saying which won.** The line-level Length/Bore/Roughness/K sat above the segment list, both live, with precedence left to the reader. The plan's own rule for loss methods -- the panel says which is in force -- applies a level up too: with segments present those fields are dimmed and say they are superseded. --- .../src/components/pid/BoreProfile.tsx | 15 ++++++++-- .../src/components/pid/ConfigDialog.tsx | 24 +++++++++++++-- .../src/components/pid/PIDDesigner.tsx | 29 +++++++++++++++---- .../frontend/src/components/pid/pages.test.ts | 19 ++++++++++-- .../frontend/src/components/pid/pages.ts | 22 ++++++++++---- 5 files changed, 90 insertions(+), 19 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/BoreProfile.tsx b/pid-designer/frontend/src/components/pid/BoreProfile.tsx index 857b23563..07be4f730 100644 --- a/pid-designer/frontend/src/components/pid/BoreProfile.tsx +++ b/pid-designer/frontend/src/components/pid/BoreProfile.tsx @@ -43,6 +43,9 @@ export function BoreProfile({ segments }: { segments: LineSegment[] }) { ); } + // Every segment has to say how long it is before the total means anything. + const lengthStated = segments.every(s => s.length?.value !== undefined && s.length.value !== null); + const W = 300, H = 260, PAD = 18; // Independent scales: at true scale a 1.6 m run at 10 mm bore is 160:1 and @@ -82,8 +85,12 @@ export function BoreProfile({ segments }: { segments: LineSegment[] }) { Flow path - - {(walls.length / 1000).toFixed(3)} m + {/* A drawn length is not a stated one. Without a length on every + segment this figure is whatever the picture needed to be drawn at, + and printing it as metres claimed a number nobody typed. */} + + {lengthStated ? `${(walls.length / 1000).toFixed(3)} m` : 'length not stated'}
@@ -125,7 +132,9 @@ export function BoreProfile({ segments }: { segments: LineSegment[] }) {

- radius ×{rScale.toFixed(1)} · widest bore {(maxR * 2).toFixed(2)} mm + {/* "radius ×11.2" read as a bend radius. It is the exaggeration: at + true scale a metre of 10 mm tube is a hairline. */} + bore drawn ×{rScale.toFixed(1)} · widest {(maxR * 2).toFixed(2)} mm {anyAssumed && · amber = assumed}

diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index a8f549156..6d6839aa9 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -152,8 +152,18 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav : 0; const fittings = kind === 'edge' ? segments.reduce((n, s) => n + fittingCount(s), 0) : 0; + // No ΣK unless there is one. Every fitting here is priced by feed-twin from + // geometry, so a zero would be reporting "nothing was stated" as "nothing". + const parts = [ + fittings ? `${fittings} fitting${fittings === 1 ? '' : 's'}` : '', + derivedK ? `K ${derivedK.toFixed(2)} at bore changes` : '', + ].filter(Boolean); + // An itemised run is the whole answer for that line, so the one-number + // fields above it are no longer what a solve reads. + const superseded = kind === 'edge' && segments.length > 0; + const title = kind === 'edge' - ? `Line${segments.length ? ` · ΣK ${derivedK.toFixed(2)} + ${fittings} fitting${fittings === 1 ? '' : 's'}` : ''}` + ? `Line${parts.length ? ` · ${parts.join(' · ')}` : ''}` : (data.label || type); return ( @@ -227,7 +237,17 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav ))} {spec.params.length > 0 && ( -
+
+ {/* Two ways to say how long a line is, both editable, with nothing + saying which one counts. The plan's own rule for the loss + methods -- the panel says which is in force -- applies a level + up as well. */} + {superseded && ( +

+ Superseded by the segments below. +

+ )} {spec.params.map(p => ( void>; + /** Which page is being looked at. Above the canvas, because the canvas + * remounts on Take -- and being thrown back to Main by the gesture that + * means "let me edit this" is the same papercut as losing the viewport. */ + page: string; + setPage: React.Dispatch>; + /** Pages somebody made but has not drawn on yet. Everything else is derived + * from where the components are, so the two cannot disagree. Up here for + * the same reason: an empty page must survive a Take. */ + declaredPages: string[]; + setDeclaredPages: React.Dispatch>; /** * Where the reader was looking, per diagram and page. * @@ -183,7 +193,7 @@ interface CanvasProps { } function PIDCanvas({ - diagramRef, fitRef, viewportsRef, getRef, loadRef, clearRef, clearCountRef, undoRef, redoRef, + diagramRef, fitRef, viewportsRef, page, setPage, declaredPages, setDeclaredPages, getRef, loadRef, clearRef, clearCountRef, undoRef, redoRef, releaseRef, getHistoryRef, getReleasesRef, restoreMicroRef, restoreReleaseRef, onForbidden, onLockLost, mode, }: CanvasProps) { @@ -201,14 +211,10 @@ function PIDCanvas({ const toolRef = useRef(tool); toolRef.current = tool; - const [page, setPage] = useState(DEFAULT_PAGE); // Read inside `clearRef`, which is called through a ref from the toolbar and // would otherwise close over whichever page was current when it was built. const pageRef = useRef(page); pageRef.current = page; - // Pages people made but have not drawn on yet. Everything else is derived - // from where the components actually are, so the two cannot disagree. - const [declaredPages, setDeclaredPages] = useState([]); const [colorMenu, setColorMenu] = useState<{ kind: 'node' | 'edge'; id: string; x: number; y: number } | null>(null); // Which symbol or line has its config open. Held as an id rather than the @@ -352,13 +358,18 @@ function PIDCanvas({ clearRef.current = useCallback(() => { if (readOnlyRef.current) return; const here = pageRef.current; + // A page exists because components are on it, so emptying one used to + // delete it and drop the reader on Main -- which is not what "clear this + // page" says, and not what somebody starting a page over wants. Declaring + // it keeps the empty sheet. + setDeclaredPages(ps => ps.includes(here) ? ps : [...ps, here]); setNodes(nds => { const doomed = new Set( nds.filter(n => pageOf(n.data as unknown as PIDNodeData) === here).map(n => n.id)); setEdges(eds => eds.filter(e => !doomed.has(e.source) && !doomed.has(e.target))); return nds.filter(n => !doomed.has(n.id)); }); - }, [setNodes, setEdges]); + }, [setNodes, setEdges, setDeclaredPages]); /** What Clear would take, so the confirmation can say. */ const clearCount = useCallback(() => { @@ -1002,6 +1013,8 @@ export function PIDDesigner() { () => ({ page: '', nodes: 0, edges: 0 })); const fitRef = useRef<() => void>(() => {}); const viewportsRef = useRef>(new Map()); + const [page, setPage] = useState(DEFAULT_PAGE); + const [declaredPages, setDeclaredPages] = useState([]); const undoRef = useRef<() => void>(() => {}); const redoRef = useRef<() => void>(() => {}); const releaseRef = useRef<(label: string) => Promise<{ label: string; savedAt: string }>>(() => Promise.resolve({ label: '', savedAt: '' })); @@ -1178,6 +1191,10 @@ export function PIDDesigner() { diagramRef={activeRef} fitRef={fitRef} viewportsRef={viewportsRef} + page={page} + setPage={setPage} + declaredPages={declaredPages} + setDeclaredPages={setDeclaredPages} getRef={getRef} loadRef={loadRef} clearRef={clearRef} diff --git a/pid-designer/frontend/src/components/pid/pages.test.ts b/pid-designer/frontend/src/components/pid/pages.test.ts index d535a676f..f9a622e57 100644 --- a/pid-designer/frontend/src/components/pid/pages.test.ts +++ b/pid-designer/frontend/src/components/pid/pages.test.ts @@ -15,9 +15,24 @@ describe('which page a component is on', () => { expect(pageOf({ page: '' })).toBe(DEFAULT_PAGE); }); - it('lists declared pages first, then any others in use', () => { + it('lists pages in use first, then any declared and still empty', () => { const nodes = [node('a', 'GSE'), node('b', 'Rocket')]; - expect(listPages(nodes, ['Rocket'])).toEqual(['Rocket', 'GSE']); + expect(listPages(nodes, ['Rocket'])).toEqual(['GSE', 'Rocket']); + expect(listPages(nodes, ['Stand'])).toEqual(['GSE', 'Rocket', 'Stand']); + }); + + it('leaves a page where it was when it empties', () => { + // Clear declares the page it emptied, so the sheet survives -- and the + // tab must not jump to the front the moment it does. + const nodes = [node('a', 'Main'), node('b', 'GSE')]; + expect(listPages(nodes, [])).toEqual(['Main', 'GSE']); + expect(listPages([nodes[0]], ['GSE'])).toEqual(['Main', 'GSE']); + }); + + it('leaves a page where it was when somebody draws on it', () => { + expect(listPages([node('a', 'Main')], ['GSE'])).toEqual(['Main', 'GSE']); + expect(listPages([node('a', 'Main'), node('b', 'GSE')], ['GSE'])) + .toEqual(['Main', 'GSE']); }); it('always offers at least one page', () => { diff --git a/pid-designer/frontend/src/components/pid/pages.ts b/pid-designer/frontend/src/components/pid/pages.ts index 7c70825af..f593e1e20 100644 --- a/pid-designer/frontend/src/components/pid/pages.ts +++ b/pid-designer/frontend/src/components/pid/pages.ts @@ -27,14 +27,24 @@ export const DEFAULT_PAGE = 'Main'; export const pageOf = (data: { page?: string } | undefined) => data?.page || DEFAULT_PAGE; /** - * Every page in the diagram: the ones components sit on, plus any declared - * empty. Declared ones come first and in order, so adding a page and then - * drawing on it does not make it jump. + * Every page in the diagram: the ones components sit on, in the order those + * components were drawn, then any declared and still empty. + * + * The order matters more than it looks. A page tab that moves when you touch + * it is a page tab you stop trusting, and a page can cross between the two + * lists in both directions -- draw on an empty one and it becomes used; + * clear a used one and Clear declares it so the empty sheet survives. Putting + * used first and empty after keeps a page where it was through both, because + * a newly used page is last in node order and a newly emptied one is last + * among the declared. */ export function listPages(nodes: Node[], declared: string[] = []): string[] { - const used = new Set(nodes.map(n => pageOf(n.data as unknown as PIDNodeData))); - const out = [...declared]; - for (const p of used) if (!out.includes(p)) out.push(p); + const out: string[] = []; + for (const n of nodes) { + const p = pageOf(n.data as unknown as PIDNodeData); + if (!out.includes(p)) out.push(p); + } + for (const p of declared) if (!out.includes(p)) out.push(p); if (out.length === 0) out.push(DEFAULT_PAGE); return out; } From 2c87305af0a6691c43e7745100e205d34266037c Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 13:43:04 -0700 Subject: [PATCH 30/57] Drop a transducer on a line and it taps the line The other half of branching by dropping, and three things found proving it. **A gauge or a transducer landed on a pipe now taps it.** One port means there is only one thing that gesture could mean, and the topology it means is a junction with the instrument on its third leg. Placing the junction, drawing the line, then picking which of four ports is three steps for one intention. Dropped below the run it is turned over, because its tapping is on its underside and a tap has to point at the pipe -- the lettering stays upright either way. **"On the line" is now measured against the pipe as drawn.** The graph-level hit test measures to the straight line between two component centres, which is the right cheap answer for *which* line and the wrong one for *is this on it*: a run is drawn orthogonally, so on an L they disagree by the whole depth of the bend. Dropping near a corner either missed a pipe the pointer was sitting on or put the junction forty pixels from where somebody let go. `lineHit.ts` reads the rendered paths back -- the edge owns its routing, including a dragged crossbar -- and hidden pages are not rendered, so page scoping comes free. **A node nobody has measured yet is its own size.** `measured` arrives a render after a node does, and everything fell back to 60x60 -- a quarter of the drawing away for a junction, which is a ten-pixel dot. Anything hit-testing against a junction made in the same batch was aiming 25 px off the pipe. **Both branches of `onDrop` write the same way.** One appended functionally and the new one wrote an absolute array from the last rendered snapshot, so two drops in one batch had the second overwrite the first. `commitGraph` is the one path that writes a whole graph and tells the snapshot about it. Also: the Fit View icon's path had a missing space in an arc flag, so every render logged an SVG parse error and the icon drew three corners. --- .../src/components/pid/PIDDesigner.tsx | 107 ++++++++++++------ .../src/components/pid/PIDToolbar.tsx | 2 +- .../src/components/pid/attach.test.ts | 24 +++- .../frontend/src/components/pid/attach.ts | 45 ++++++-- .../src/components/pid/lineHit.test.ts | 39 +++++++ .../frontend/src/components/pid/lineHit.ts | 51 +++++++++ .../frontend/src/components/pid/splitEdge.ts | 10 +- 7 files changed, 227 insertions(+), 51 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/lineHit.test.ts create mode 100644 pid-designer/frontend/src/components/pid/lineHit.ts diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index dae4692c8..7c0b5f4f3 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -48,9 +48,9 @@ import { ChecksPanel } from './ChecksPanel'; import { VentLayer } from './VentLayer'; import { PageBar } from './PageBar'; import { DEFAULT_PAGE, applyPage, listPages, moveToPage, pageOf } from './pages'; -import { clearOfHost, dragAttached, isInstrument, targetAt } from './attach'; +import { clearOfHost, dragAttached, isInstrument, isTapped, targetAt } from './attach'; import { rejoinAfterDelete, splitEdgeAt } from './splitEdge'; -import { nearestOnPath } from './BranchableEdge'; +import { drawnLines, lineAt } from './lineHit'; import { COMPONENT_SPECS } from './spec'; export type InteractionMode = 'pan' | 'select'; @@ -592,6 +592,22 @@ function PIDCanvas({ * The Junction tool stays for placing one deliberately, on a line you have * not connected anything to yet. */ + /** + * Write a whole graph, and tell `snapshot` about it. + * + * These handlers read `snapshot.current` -- the last *rendered* state -- and + * write absolute arrays back. Two of them in one batch therefore both read + * the state before either ran, and the second overwrote the first: drop two + * transducers on a line without a render in between and only the second one + * existed. Updating the snapshot here is what makes the second read see the + * first write. + */ + const commitGraph = useCallback((nodes: Node[], edges: Edge[]) => { + snapshot.current = { nodes, edges }; + setNodes(nodes); + setEdges(edges); + }, [setNodes, setEdges]); + const connectingFrom = useRef<{ nodeId: string; handleId: string | null } | null>(null); const onConnectStart = useCallback(( @@ -611,10 +627,10 @@ function PIDCanvas({ const flow = screenToFlowPosition(point); const { nodes: ns, edges: es } = snapshot.current; - // Only when it landed on a line and not on a component -- React Flow has - // already made the connection in that case. - const hit = targetAt(flow, ns, es, from.nodeId, pageRef.current); - if (!hit || hit.kind !== 'edge') return; + // Only when it landed on a line. On a component ReactFlow has already made + // the connection, and `lineAt` will not claim it. + const hit = lineAt(drawnLines(), flow); + if (!hit) return; // Not onto a line this component is already an end of. That would be two // lines from the same port to the same junction, which is a parallel path @@ -622,16 +638,10 @@ function PIDCanvas({ const line = es.find(e => e.id === hit.id); if (!line || line.source === from.nodeId || line.target === from.nodeId) return; - // Onto the pipe as drawn, not where the pointer happened to be. The hit - // test measures against the straight line between the two ends, and the - // run is drawn orthogonally, so those differ by the whole depth of a bend. - const at = onDrawnPath(hit.id, flow); - - const split = splitEdgeAt(ns, es, hit.id, at, pageRef.current); + const split = splitEdgeAt(ns, es, hit.id, hit.at, pageRef.current); if (!split) return; - setNodes(split.nodes); - setEdges([ + commitGraph(split.nodes, [ ...split.edges, { id: `${from.nodeId}-${split.junctionId}`, @@ -643,7 +653,7 @@ function PIDCanvas({ data: {}, }, ]); - }, [screenToFlowPosition, setNodes, setEdges]); + }, [screenToFlowPosition, commitGraph]); const onDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -702,7 +712,51 @@ function PIDCanvas({ // Allocated outside the updater: React invokes updaters twice in // development, and an id minted inside one is neither pure nor stable. const id = nextNodeId(); - setNodes(nds => [...nds, { + + /** + * A transducer dropped on a line taps that line. + * + * The same gesture as branching by dropping a connection, from the other + * end: a gauge or a transducer has exactly one port, so landing one on a + * pipe can only mean "tap here" -- and the topology that means is a + * junction with the instrument on its third leg. Making somebody place the + * junction, then draw the line, then remember which of four ports to use + * is three steps for one intention. + */ + if (isTapped(type)) { + const hit = lineAt(drawnLines(), flowPos); + if (hit) { + const at = hit.at; + const split = splitEdgeAt( + snapshot.current.nodes, snapshot.current.edges, hit.id, at, pageRef.current); + if (split) { + // Standing off the pipe, on the side the pointer was, so the symbol + // does not sit on top of the line it is reading. Below the line it + // is turned over, because its one tapping is on its underside and a + // tap has to point at the pipe -- the lettering stays upright. + const above = flowPos.y <= at.y; + commitGraph( + [...split.nodes, { + id, type, + position: { x: at.x - 30, y: above ? at.y - 90 : at.y + 30 }, + data: { ...nodeData, ...(above ? {} : { rotation: 180 }) } as unknown as Record, + }], + [...split.edges, { + id: `${id}-${split.junctionId}`, + source: id, sourceHandle: 'b', + target: split.junctionId, + type: 'smoothstep', + data: {}, + }]); + return; + } + } + } + + // Through `commitGraph` like the tap above, not a functional updater: + // this handler's two branches have to agree about how they write, or two + // drops in one batch see different states and the absolute one wins. + commitGraph([...snapshot.current.nodes, { id, type, position, @@ -716,8 +770,8 @@ function PIDCanvas({ // eventually be believed. ...(type === 'REGION' ? { width: 320, height: 220, zIndex: -1 } : {}), data: nodeData as unknown as Record, - }]); - }, [screenToFlowPosition, setNodes, page]); + }], snapshot.current.edges); + }, [screenToFlowPosition, commitGraph, page]); /** Apply the current paint colour, or fall through to normal selection. */ const paintIfArmed = useCallback((kind: 'node' | 'edge', id: string): boolean => { @@ -1222,20 +1276,3 @@ export function PIDDesigner() { ); } -/** - * A point moved onto the line as it is actually drawn. - * - * The rendered path is the same one the reader clicked on and the same one the - * Junction tool snaps to, so it is read back rather than recomputed -- the edge - * owns its routing, including a crossbar somebody has dragged, and duplicating - * that here would be a second version of it to keep in step. - * - * Falls back to the point given. A junction a few pixels off a pipe is worse - * than one exactly where somebody let go, but both beat not making one. - */ -function onDrawnPath(edgeId: string, at: { x: number; y: number }): { x: number; y: number } { - const el = document.querySelector( - `.react-flow__edge[data-id="${CSS.escape(edgeId)}"] .react-flow__edge-path`); - const d = el?.getAttribute('d'); - return d ? nearestOnPath(d, at) : at; -} diff --git a/pid-designer/frontend/src/components/pid/PIDToolbar.tsx b/pid-designer/frontend/src/components/pid/PIDToolbar.tsx index d50f8f1e0..e40a6454a 100644 --- a/pid-designer/frontend/src/components/pid/PIDToolbar.tsx +++ b/pid-designer/frontend/src/components/pid/PIDToolbar.tsx @@ -181,7 +181,7 @@ export function PIDToolbar({ diff --git a/pid-designer/frontend/src/components/pid/attach.test.ts b/pid-designer/frontend/src/components/pid/attach.test.ts index d540ec854..7e55ca0c8 100644 --- a/pid-designer/frontend/src/components/pid/attach.test.ts +++ b/pid-designer/frontend/src/components/pid/attach.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Edge, Node } from '@xyflow/react'; -import { targetAt, dragAttached, isInstrument } from './attach'; +import { targetAt, dragAttached, isInstrument, centreOf } from './attach'; const node = (id: string, componentType: string, x: number, y: number, w = 60, h = 60, data: Record = {}): Node => @@ -104,3 +104,25 @@ describe('the page you are looking at', () => { expect(targetAt({ x: 30, y: 50 }, gse, wire)).toEqual({ id: 'TANK', kind: 'node' }); }); }); + +describe('a node nobody has measured yet', () => { + it('is its own size, not everything\u2019s size', () => { + // A junction is a ten-pixel dot and `measured` arrives a render after the + // node does. Falling back to 60 put its centre 25 px off the pipe, so a + // second branch made in the same batch missed the line entirely. + const junction: Node = { id: 'j', type: 'JUNCTION', position: { x: 100, y: 100 }, + data: { componentType: 'JUNCTION' } } as unknown as Node; + expect(centreOf(junction)).toEqual({ x: 105, y: 105 }); + + const valve: Node = { id: 'v', type: 'MAN', position: { x: 100, y: 100 }, + data: { componentType: 'MAN' } } as unknown as Node; + expect(centreOf(valve)).toEqual({ x: 130, y: 130 }); + }); + + it('yields to a real measurement once there is one', () => { + const tank: Node = { id: 't', type: 'TANK', position: { x: 0, y: 0 }, + measured: { width: 60, height: 100 }, + data: { componentType: 'TANK' } } as unknown as Node; + expect(centreOf(tank)).toEqual({ x: 30, y: 50 }); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/attach.ts b/pid-designer/frontend/src/components/pid/attach.ts index 887c069c5..b507923c7 100644 --- a/pid-designer/frontend/src/components/pid/attach.ts +++ b/pid-designer/frontend/src/components/pid/attach.ts @@ -29,6 +29,42 @@ export const INSTRUMENTS = new Set(['RTD', 'TC', 'LC']); export const isInstrument = (type?: string) => !!type && INSTRUMENTS.has(type); +/** + * Plumbed instruments: they screw into the system rather than clipping to it. + * + * A transducer and a gauge are fittings -- there is a hole in the pipe and a + * thread in the hole -- so they connect, and they have exactly one port to + * connect with. That single port is what makes dropping one on a line + * unambiguous: there is only one thing it could mean. + */ +export const TAPPED = new Set(['PT', 'PG']); + +export const isTapped = (type?: string) => !!type && TAPPED.has(type); + +/** + * How big a node is, before ReactFlow has measured it. + * + * `measured` arrives a render after a node does, and until then everything + * fell back to 60 x 60 -- which is a quarter of the drawing away from the + * truth for a junction, a ten-pixel dot. Anything hit-testing or picking a + * face against a junction created in the same batch was therefore aiming at a + * point 25 px off the pipe. + */ +export function nodeSize(n: Node): { w: number; h: number } { + const type = (n.data as unknown as PIDNodeData)?.componentType; + const fallback = type === 'JUNCTION' ? 10 : 60; + return { + w: n.measured?.width ?? fallback, + h: n.measured?.height ?? fallback, + }; +} + +/** A node's centre in flow coordinates. */ +export function centreOf(n: Node): XYPosition { + const { w, h } = nodeSize(n); + return { x: n.position.x + w / 2, y: n.position.y + h / 2 }; +} + export interface AttachTarget { id: string; kind: 'node' | 'edge'; @@ -74,8 +110,7 @@ export function targetAt( // region is scenery drawn over half the diagram, so it would swallow // every drop made inside it. if (isInstrument(t2) || t2 === 'REGION' || t2 === 'TEXT') continue; - const w = n.measured?.width ?? 60; - const h = n.measured?.height ?? 60; + const { w, h } = nodeSize(n); if (point.x >= n.position.x && point.x <= n.position.x + w && point.y >= n.position.y && point.y <= n.position.y + h) { return { id: n.id, kind: 'node' }; @@ -86,17 +121,13 @@ export function targetAt( // path and this is the straight line between its ends -- close enough to pick // a line out at the scale a P&ID is drawn, and it never disagrees about // *which* line, only about exactly where along it. - const centre = (n: Node) => ({ - x: n.position.x + (n.measured?.width ?? 60) / 2, - y: n.position.y + (n.measured?.height ?? 60) / 2, - }); const TOLERANCE = 14; for (const e of edges) { const a = nodes.find(n => n.id === e.source); const b = nodes.find(n => n.id === e.target); if (!a || !b) continue; if (!here(a) || !here(b)) continue; - if (distanceToSegment(point, centre(a), centre(b)) <= TOLERANCE) { + if (distanceToSegment(point, centreOf(a), centreOf(b)) <= TOLERANCE) { return { id: e.id, kind: 'edge' }; } } diff --git a/pid-designer/frontend/src/components/pid/lineHit.test.ts b/pid-designer/frontend/src/components/pid/lineHit.test.ts new file mode 100644 index 000000000..b72da67c5 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/lineHit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { lineAt } from './lineHit'; + +describe('the line under a point', () => { + it('measures against the pipe as drawn, not the straight line between ends', () => { + // An L: out to the right, then down. Its two ends are (0,0) and (200,200), + // and the point just below the top run is nowhere near the diagonal + // between those ends -- which is exactly what the graph-level hit test + // measures, and exactly what it gets wrong. + const lines = [{ id: 'e1', d: 'M 0,0 L 200,0 L 200,200' }]; + expect(lineAt(lines, { x: 100, y: 4 })).toMatchObject({ id: 'e1', at: { x: 100, y: 0 } }); + // ...and a point on that diagonal is not on the pipe at all. + expect(lineAt(lines, { x: 100, y: 100 })).toBeNull(); + }); + + it('snaps to the pipe, so a junction lands on it', () => { + expect(lineAt([{ id: 'e1', d: 'M 0,0 L 200,0' }], { x: 50, y: -9 })?.at) + .toEqual({ x: 50, y: 0 }); + }); + + it('takes the nearest of several', () => { + const lines = [ + { id: 'near', d: 'M 0,0 L 100,0' }, + { id: 'far', d: 'M 0,10 L 100,10' }, + ]; + expect(lineAt(lines, { x: 50, y: 3 })?.id).toBe('near'); + expect(lineAt(lines, { x: 50, y: 7 })?.id).toBe('far'); + }); + + it('claims nothing beyond the tolerance', () => { + const lines = [{ id: 'e1', d: 'M 0,0 L 200,0' }]; + expect(lineAt(lines, { x: 100, y: 40 })).toBeNull(); + expect(lineAt(lines, { x: 100, y: 40 }, 60)).toMatchObject({ id: 'e1' }); + }); + + it('claims nothing at all when there is nothing drawn', () => { + expect(lineAt([], { x: 0, y: 0 })).toBeNull(); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/lineHit.ts b/pid-designer/frontend/src/components/pid/lineHit.ts new file mode 100644 index 000000000..a673f254a --- /dev/null +++ b/pid-designer/frontend/src/components/pid/lineHit.ts @@ -0,0 +1,51 @@ +import type { XYPosition } from '@xyflow/react'; +import { nearestOnPath } from './BranchableEdge'; + +/** One line as it is actually drawn: its id, and its path data. */ +export interface DrawnLine { + id: string; + d: string; +} + +/** + * The pipes on screen, read back from what was rendered. + * + * Read rather than recomputed: the edge owns its routing, including a crossbar + * somebody has dragged, and a second copy of that geometry would be one more + * thing to keep in step. Hidden pages are not rendered, so this is scoped to + * the page for free. + */ +export function drawnLines(): DrawnLine[] { + const out: DrawnLine[] = []; + for (const el of document.querySelectorAll('.react-flow__edge[data-id]')) { + const d = el.querySelector('.react-flow__edge-path')?.getAttribute('d'); + if (d) out.push({ id: el.getAttribute('data-id')!, d }); + } + return out; +} + +/** + * The line under a point, and where on it. + * + * Measured against the pipe **as drawn**. The graph-level hit test in + * `attach.ts` measures against the straight line between two component + * centres, which is the right cheap answer for "which line is this" and the + * wrong one for "is this on the pipe": a run is drawn orthogonally, so on an + * L-shaped line the two disagree by the whole depth of the bend. Dropping + * there either missed a pipe the pointer was sitting on, or put a junction + * forty pixels from where somebody let go. + */ +export function lineAt( + lines: DrawnLine[], + at: XYPosition, + tolerance = 14, +): { id: string; at: XYPosition } | null { + let best: { id: string; at: XYPosition } | null = null; + let bestDist = tolerance; + for (const line of lines) { + const q = nearestOnPath(line.d, at); + const dist = Math.hypot(q.x - at.x, q.y - at.y); + if (dist < bestDist) { bestDist = dist; best = { id: line.id, at: q }; } + } + return best; +} diff --git a/pid-designer/frontend/src/components/pid/splitEdge.ts b/pid-designer/frontend/src/components/pid/splitEdge.ts index 98d5d1f92..fea42da23 100644 --- a/pid-designer/frontend/src/components/pid/splitEdge.ts +++ b/pid-designer/frontend/src/components/pid/splitEdge.ts @@ -4,6 +4,7 @@ import { faceTowards } from './BranchableEdge'; import type { PIDNodeData } from './types'; import type { ParamValue } from './params'; import type { LineSegment } from './segments'; +import { centreOf } from './attach'; /** * Put a junction into a line. @@ -48,11 +49,6 @@ function intensiveOnly(data: Record): Record { const J_HALF = 5; -const centre = (n: Node): XYPosition => ({ - x: n.position.x + (n.measured?.width ?? 60) / 2, - y: n.position.y + (n.measured?.height ?? 60) / 2, -}); - export interface Split { nodes: Node[]; edges: Edge[]; @@ -82,8 +78,8 @@ export function splitEdgeAt( if (!from || !to) return null; const junctionId = nextJunctionId(); - const a = ends?.from ?? centre(from); - const b = ends?.to ?? centre(to); + const a = ends?.from ?? centreOf(from); + const b = ends?.to ?? centreOf(to); const junction: Node = { id: junctionId, From 56e1c7dc483a5e6222acdb379926393f60cec651 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Wed, 9 Sep 2026 23:55:06 -0700 Subject: [PATCH 31/57] Lines leave a port the way the port points, and green means inert Your list, and what looking closely turned up alongside it. **Temperature follows the fluid.** Picking LOX and then being asked separately how cold it is asks a question whose answer is in the field above. Ambient for everything except the fluids that are only useful as liquids: LOX 90 K, LCH4 112 K, and nitrogen 77 K in a dewar but ambient in a bottle. Filled on change, not on save, so it is visible and can be argued with -- and only over an empty box or the previous fluid's default, so a number somebody typed is never taken away. **Pressurant is green.** Red is what every other drawing in the building uses for danger, and it was spent on the one fluid in the system that cannot burn. Ox stays blue, fuel is a warmer red-orange. **A tank is one silhouette, filled once.** It was three shapes with their own fills -- a tinted barrel between two slate heads -- so it read as a striped thing rather than a vessel and only its middle took the fluid colour. Now one outline with the seams drawn as lines. **"DOME" sat on the dome's own feed line.** Centred, it was directly under the port the pilot line arrives at. Moved beside it. **A tank straight above a regulator drew a leaning line.** Two things: the grid was 20 and a symbol's ports sit at 0, 30 and 60 across it, so the offset between a centre port and a side port was always an odd multiple of ten and could never come out level. The grid is 10 now. And a run whose ends are within ten pixels is drawn down their average rather than joining the two points. **Flow vs instrument was two ways to say one thing.** Put a transducer on a port and you have said it is a tapping; a dropdown saying so again is a second thing to forget to update. A port with only instruments on it now *is* an instrument port, derived. `Plugged` stays, because nothing else on the drawing says a port is blanked off. Then, from looking: **The router ignored which way a port faces** (`route.ts`, new, with an exhaustive test over all 224 combinations of side and offset). A line leaving a valve's right-hand port for something below and to the left set off *left*, back across the valve it had just come out of. Every segment touching an end now leaves that end the way the end points, and a run that has to double back goes round the symbol instead of down through it. **A helium-domed LOX regulator reported a fluid conflict** and drew its pilot line in the fault colour. So did every pressurant line into a tank's ullage -- which nobody had noticed, because the fault colour and the pressurant colour were both red. A dome is a pilot port and an ullage is not the outlet: a different fluid there is the arrangement working. **The keyboard hints were printed over the drawing.** Nine of them, which wrapped to four lines on any canvas narrower than a desktop and climbed up through the middle of the diagram. Three now, one line, and it no longer swallows clicks meant for the canvas underneath. --- .../src/components/pid/BranchableEdge.tsx | 45 ++-- .../src/components/pid/ConfigDialog.tsx | 34 ++- .../src/components/pid/FluidContext.tsx | 17 +- .../src/components/pid/ManifoldEditor.tsx | 4 +- .../src/components/pid/PIDDesigner.tsx | 25 ++- .../src/components/pid/fluids.test.ts | 56 +++++ .../frontend/src/components/pid/fluids.ts | 121 ++++++++++- .../src/components/pid/nodes/ManifoldNode.tsx | 10 +- .../src/components/pid/nodes/PRNode.tsx | 5 +- .../src/components/pid/nodes/Port.tsx | 18 +- .../src/components/pid/nodes/TankNode.tsx | 34 +-- .../frontend/src/components/pid/ports.test.ts | 51 ++++- .../frontend/src/components/pid/ports.ts | 47 +++- .../frontend/src/components/pid/route.test.ts | 202 ++++++++++++++++++ .../frontend/src/components/pid/route.ts | 201 +++++++++++++++++ 15 files changed, 787 insertions(+), 83 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/route.test.ts create mode 100644 pid-designer/frontend/src/components/pid/route.ts diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx index 57829267a..253891d9f 100644 --- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx +++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { BaseEdge, - Position, useReactFlow, type EdgeProps, } from '@xyflow/react'; import { splitEdgeAt } from './splitEdge'; +import { isHorizontal, routeOrthogonal } from './route'; import { useEdgeFluidColor } from './FluidContext'; import { useReadOnly } from '@stardesign-ui'; import { useTool } from './ToolContext'; @@ -53,38 +53,17 @@ export function BranchableEdge(props: EdgeProps) { const strokeColor = useEdgeFluidColor(id, (data as { color?: string })?.color); const offset = ((data as { offset?: number })?.offset ?? 0); - // Which way each end faces decides the shape of the run. Ends that face the - // same way give three segments with a crossbar in the middle; ends that face - // differently give a plain corner, which has nothing to move and should not - // pretend otherwise. - const isH = (p?: Position) => p === Position.Left || p === Position.Right; - const horizontal = isH(sourcePosition); - const sameAxis = isH(sourcePosition) === isH(targetPosition); - - const dx = Math.abs(sourceX - targetX); - const dy = Math.abs(sourceY - targetY); - const ALIGNED = 12; - - let edgePath: string; - let grip: { x: number; y: number } | null = null; - - if ((dx < ALIGNED && dy > dx) || (dy < ALIGNED && dx > dy)) { - // Already in line: a straight run, and nothing to move. - edgePath = `M ${sourceX},${sourceY} L ${targetX},${targetY}`; - } else if (!sameAxis) { - // A corner. Leave along the axis the source faces, then turn once. - edgePath = horizontal - ? `M ${sourceX},${sourceY} L ${targetX},${sourceY} L ${targetX},${targetY}` - : `M ${sourceX},${sourceY} L ${sourceX},${targetY} L ${targetX},${targetY}`; - } else if (horizontal) { - const midX = (sourceX + targetX) / 2 + offset; - edgePath = `M ${sourceX},${sourceY} L ${midX},${sourceY} L ${midX},${targetY} L ${targetX},${targetY}`; - grip = { x: midX, y: (sourceY + targetY) / 2 }; - } else { - const midY = (sourceY + targetY) / 2 + offset; - edgePath = `M ${sourceX},${sourceY} L ${sourceX},${midY} L ${targetX},${midY} L ${targetX},${targetY}`; - grip = { x: (sourceX + targetX) / 2, y: midY }; - } + // The shape of the run, and whether it has a crossbar to drag. See route.ts: + // the rule is that every segment touching an end leaves that end the way the + // end points, which is what stops a line doubling back over its own symbol. + const { d: edgePath, grip } = routeOrthogonal( + { x: sourceX, y: sourceY, side: sourcePosition }, + { x: targetX, y: targetY, side: targetPosition }, + offset, + ); + // Which way a drag on the crossbar moves it: across the run, so along the + // axis the two ends leave on. + const horizontal = isHorizontal(sourcePosition); // ── Moving the crossbar ──────────────────────────────────────────────────── const startDrag = useCallback((e: React.PointerEvent) => { diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index 6d6839aa9..92411761a 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Modal } from '../ui'; import { btn, primaryBtn } from '../../lib/ui'; import { COMPONENT_SPECS, LINE_SPECS, LINE_TYPE_LABELS, PEER_CHOICES } from './spec'; @@ -7,7 +7,7 @@ import { PROVENANCE_CHOICES, UNITS } from './params'; import type { ParamValue, Provenance } from './params'; import { portIds } from './ports'; import type { PortInfo, PortKind } from './ports'; -import { speciesById } from './fluids'; +import { defaultTemperatureK, speciesById } from './fluids'; import { SegmentPanel } from './SegmentPanel'; import { BoreProfile } from './BoreProfile'; import { ManifoldEditor } from './ManifoldEditor'; @@ -116,6 +116,28 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav setOptions(Object.fromEntries((spec.options ?? []).map(o => [o.key, data.options?.[o.key] ?? o.default]))); }, [open, data, spec]); + // ── Temperature follows the fluid ────────────────────────────────────────── + // + // Picking LOX and then being asked, separately, how cold it is, is asking a + // question whose answer is in the previous field. Filled on change rather + // than on save so the number is visible and can be argued with -- and only + // over an empty box or over the *previous* fluid's default, so a temperature + // somebody typed is never taken away from them. + const lastAutoTemp = useRef(null); + useEffect(() => { + if (!open || !spec?.fluids) return; + const k = defaultTemperatureK(fluid, type === 'DEWAR'); + if (k === undefined) return; + setDrafts(d => { + const t = d.temperature; + if (!t) return d; + const untouched = t.value.trim() === '' || t.value === lastAutoTemp.current; + if (!untouched) return d; + lastAutoTemp.current = String(k); + return { ...d, temperature: { ...t, value: String(k), unit: 'K' } }; + }); + }, [open, fluid, type, spec]); + if (!spec) return null; const save = () => { @@ -405,8 +427,12 @@ function PortGroup({ group, count, ports, readOnly, onChange }: { onChange={e => onChange(id, { kind: e.target.value as PortKind })} className={`${field} min-w-0`} > - - + {/* Two states, not three. "Instrument" was a second way of saying + what a transducer drawn on the port already says, and the port + now works that out for itself. Plugged is the one that has to + be authored: nothing else on the drawing says a port is + blanked off. */} +
diff --git a/pid-designer/frontend/src/components/pid/FluidContext.tsx b/pid-designer/frontend/src/components/pid/FluidContext.tsx index a3efcc6f4..68c18344d 100644 --- a/pid-designer/frontend/src/components/pid/FluidContext.tsx +++ b/pid-designer/frontend/src/components/pid/FluidContext.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import type { Edge, Node } from '@xyflow/react'; import { propagateFluids, edgeFluid, colorForSpecies, UNSET_COLOR } from './fluids'; import type { FluidAssignment } from './fluids'; +import { instrumentTaps } from './ports'; /** * Which fluid is in what, published once for every symbol to read. @@ -21,9 +22,12 @@ import type { FluidAssignment } from './fluids'; interface FluidMap { byNode: Map; byEdge: Map; + /** Ports that are instrument tappings, by `":"`. Derived from + * what is connected to them -- see `instrumentTaps`. */ + taps: Set; } -const EMPTY: FluidMap = { byNode: new Map(), byEdge: new Map() }; +const EMPTY: FluidMap = { byNode: new Map(), byEdge: new Map(), taps: new Set() }; const FluidContext = createContext(EMPTY); export function FluidProvider({ nodes, edges, children }: { @@ -31,13 +35,20 @@ export function FluidProvider({ nodes, edges, children }: { }) { const value = useMemo(() => { const byNode = propagateFluids(nodes, edges); - const byEdge = new Map(edges.map(e => [e.id, edgeFluid(e, byNode)])); - return { byNode, byEdge }; + const typeOf = new Map(nodes.map(n => + [n.id, (n.data as { componentType?: string } | undefined)?.componentType])); + const byEdge = new Map(edges.map(e => [e.id, edgeFluid(e, byNode, typeOf)])); + return { byNode, byEdge, taps: instrumentTaps(nodes, edges) }; }, [nodes, edges]); return {children}; } +/** Is this port an instrument tapping? True when everything on it is one. */ +export function useIsTap(nodeId: string, portId: string): boolean { + return useContext(FluidContext).taps.has(`${nodeId}:${portId}`); +} + /** What this component ended up carrying, declared or inherited. */ export function useNodeFluid(id: string): FluidAssignment | undefined { return useContext(FluidContext).byNode.get(id); diff --git a/pid-designer/frontend/src/components/pid/ManifoldEditor.tsx b/pid-designer/frontend/src/components/pid/ManifoldEditor.tsx index 7a12656f4..32b9f45f9 100644 --- a/pid-designer/frontend/src/components/pid/ManifoldEditor.tsx +++ b/pid-designer/frontend/src/components/pid/ManifoldEditor.tsx @@ -156,14 +156,14 @@ export function ManifoldEditor({ outlets, geometry, ports, onSave }: { if (kind === 'plug') return null; const cx = ox + p.x, cy = oy + p.y; const on = drag === id; - const colour = id === 'in' ? '#38bdf8' : kind === 'instrument' ? '#a78bfa' : '#94a3b8'; + const colour = id === 'in' ? '#38bdf8' : '#94a3b8'; return ( { if (!readOnly) { e.stopPropagation(); setDrag(id); } }} style={{ cursor: readOnly ? 'default' : 'grab' }}> - - - Drag from sidebar · Connect handles · V=Pan B=Box select · Cmd+click to multi-select · R=Rotate · Double-click to configure · Right-click to colour · Junction tool branches a line · Delete removes selection + {/* One line, and only the gestures nothing else on screen mentions. + It used to list nine, which wrapped to four lines on any canvas + narrower than a desktop and climbed up through the middle of the + drawing -- printing the instructions over the thing they are about. + `nowrap` is what makes that impossible rather than unlikely, and + it stopped taking clicks meant for the canvas underneath. */} + + + Double-click to configure · R rotates · Right-click colours diff --git a/pid-designer/frontend/src/components/pid/fluids.test.ts b/pid-designer/frontend/src/components/pid/fluids.test.ts index f29880a52..cd1942077 100644 --- a/pid-designer/frontend/src/components/pid/fluids.test.ts +++ b/pid-designer/frontend/src/components/pid/fluids.test.ts @@ -128,3 +128,59 @@ describe('the four colour categories that came before species', () => { })).toBe('helium'); }); }); + +describe('lines that are meant to carry something else', () => { + const typesOf = (nodes: Node[]) => new Map(nodes.map(n => + [n.id, (n.data as { componentType?: string }).componentType])); + + it('does not call a pressurant line into a tank a conflict', () => { + // The most-drawn arrangement on any stand: nitrogen onto the ullage of a + // LOX tank. The two ends genuinely hold different fluids, and the line + // drew in the fault colour for it -- invisible while pressurant was red. + const nodes = [ + node('KB-N2', 'KBOTTLE', { fluid: 'nitrogen' }), + node('TK-LOX', 'TANK', { fluid: 'oxygen' }), + ]; + const edges = [edge('u1', 'KB-N2', 'TK-LOX', 'r', 't')]; + const f = edgeFluid(edges[0], propagateFluids(nodes, edges), typesOf(nodes)); + expect(f.conflict).toBe(false); + expect(f.species).toBe('nitrogen'); + }); + + it('does not call the pilot gas on a dome a conflict', () => { + // Helium domed onto a LOX regulator. The dome sets the setpoint; it never + // joins the stream being regulated. + const nodes = [ + node('TK-LOX', 'TANK', { fluid: 'oxygen' }), + node('PR-1', 'PR'), + node('KB-HE', 'KBOTTLE', { fluid: 'helium' }), + ]; + const edges = [ + edge('p1', 'TK-LOX', 'PR-1', 'b', 'l'), + edge('p2', 'KB-HE', 'PR-1', 'r', 'dome'), + ]; + const fluids = propagateFluids(nodes, edges); + const dome = edgeFluid(edges[1], fluids, typesOf(nodes)); + expect(dome.conflict).toBe(false); + expect(dome.species).toBe('helium'); + // ...and the helium has not leaked into the regulator itself. + expect(fluids.get('PR-1')?.species).toBe('oxygen'); + expect(fluids.get('PR-1')?.conflict).toBe(false); + }); + + it('still reports two fluids meeting on ordinary ports', () => { + // The exemption is for the ports where a difference is the point. A LOX + // line joined to an ethanol line is still the worst afternoon of your life. + const nodes = [ + node('TK-LOX', 'TANK', { fluid: 'oxygen' }), + node('TK-ETH', 'TANK', { fluid: 'ethanol' }), + node('V', 'MAN'), + ]; + const edges = [ + edge('x1', 'TK-LOX', 'V', 'b', 'l'), + edge('x2', 'TK-ETH', 'V', 'b', 'r'), + ]; + const fluids = propagateFluids(nodes, edges); + expect(fluids.get('V')?.conflict).toBe(true); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/fluids.ts b/pid-designer/frontend/src/components/pid/fluids.ts index 8ba2d8f22..b1604fb79 100644 --- a/pid-designer/frontend/src/components/pid/fluids.ts +++ b/pid-designer/frontend/src/components/pid/fluids.ts @@ -54,11 +54,20 @@ export const speciesById = (id?: string): Species | undefined => /** * Colour by role, not by species: a reader is looking for "is this the ox side" * long before they are looking for which oxidiser. + * + * Blue ox, red-orange fuel, green inert. Pressurant was red, which is the + * colour every other drawing in the building uses for danger and the colour a + * reader's eye goes to first -- spent on the one fluid in the system that + * cannot burn. Green is what an inert gas is marked as on a bottle rack. + * + * The species name is drawn inside the symbol as well (LOX, ETH, N2, He), so + * colour is never the only thing telling the two apart -- worth keeping, since + * red-orange against green is the pair a red-green colour deficiency loses. */ export const ROLE_COLORS: Record = { oxidizer: '#60a5fa', - fuel: '#f97316', - pressurant: '#ef4444', + fuel: '#f2643f', + pressurant: '#34d399', unknown: '#94a3b8', }; @@ -69,6 +78,35 @@ export function colorForSpecies(id?: string): string { return s ? ROLE_COLORS[s.role] : UNSET_COLOR; } +/** Room temperature, K. What everything on a stand is until it is not. */ +export const AMBIENT_K = 293; + +/** + * What a vessel of this is at, before anybody says otherwise. + * + * Asking for a temperature with an empty box is asking the same question + * sixty times: nearly everything on a stand sits at ambient, and the + * exceptions are exactly the fluids that are only useful as liquids. Filling + * it from the fluid means the common case is already right and the unusual + * one is a number somebody deliberately changed. + * + * Normal boiling points, because that is what an unpressurised cryogenic + * vessel sits at. Nitrogen is the one that depends on the vessel: a bottle or + * a pressurant tank of GN2 is ambient, a dewar of it is LN2. + */ +export function defaultTemperatureK(species?: string, cryogenic = false): number | undefined { + switch (species) { + case 'oxygen': return 90; // LOX + case 'methane': return 112; // LCH4 + case 'nitrogen': return cryogenic ? 77 : AMBIENT_K; + case 'helium': return AMBIENT_K; + case 'ethanol': return AMBIENT_K; + // 'other' is unmodelled on purpose, and guessing its temperature would be + // the one place this file invented a number. + default: return undefined; + } +} + /** What the propagation worked out for one component. */ export interface FluidAssignment { species: SpeciesId | null; @@ -111,6 +149,32 @@ function isUllagePort(type: string | undefined, handle: string | null | undefine return (type === 'TANK' || type === 'DEWAR') && !!handle && /^t\d*$/.test(handle); } +/** + * A dome-loaded regulator's pilot port. + * + * The gas on the dome sets the setpoint. It never joins the stream being + * regulated, and on a real stand it is usually a different gas from it -- + * helium domed onto a LOX regulator is about as standard as an arrangement + * gets. Treating it as process flow made that drawing report a fluid conflict + * and paint the line in the fault colour. + */ +function isPilotPort(type: string | undefined, handle: string | null | undefined): boolean { + return type === 'PR' && handle === 'dome'; +} + +/** + * A port where a *different* fluid is expected rather than suspicious. + * + * Both members are the same idea: a connection that reaches a component + * without joining what flows through it. Nothing propagates along one on the + * first pass, and a fluid arriving down one is never a conflict. + */ +export function isOffProcessPort( + type: string | undefined, handle: string | null | undefined, +): boolean { + return isUllagePort(type, handle) || isPilotPort(type, handle); +} + /** * Assign a fluid to every component, spreading out from the ones that declare * one. @@ -153,8 +217,14 @@ export function propagateFluids( if (!e.source || !e.target) continue; // Undirected: a P&ID line has no arrow, and a fluid does not care which // end of it somebody happened to start the drag from. - link(isUllagePort(typeOf.get(e.source), e.sourceHandle) ? ullage : process, e.source, e.target); - link(isUllagePort(typeOf.get(e.target), e.targetHandle) ? ullage : process, e.target, e.source); + // Off-process is a property of the *line*, not of the direction you walk + // it. Classifying each direction by its own end put the pilot line into + // the process adjacency one way round, so helium walked into a LOX + // regulator and the regulator reported a conflict with itself. + const off = isOffProcessPort(typeOf.get(e.source), e.sourceHandle) + || isOffProcessPort(typeOf.get(e.target), e.targetHandle); + link(off ? ullage : process, e.source, e.target); + link(off ? ullage : process, e.target, e.source); } const isMeeting = (id: string) => MEETING_POINTS.has(typeOf.get(id) ?? ''); @@ -184,9 +254,11 @@ export function propagateFluids( for (const next of adjacency.get(cur.id) ?? []) { // A source holds its own fluid. Whatever arrives at it is expected. if (declared.has(next)) { - if (fillOnly) continue; const src = out.get(next)!; if (src.species !== cur.species) { + // Recorded even on the off-process pass: "the ullage is nitrogen and + // the outlet is LOX" is a fact about the tank worth keeping, and it + // is what tells a line into it that the difference is deliberate. src.mixing = true; if (!src.sources.includes(cur.from)) src.sources.push(cur.from); } @@ -199,7 +271,16 @@ export function propagateFluids( work.push({ id: next, species: cur.species, from: cur.from }); continue; } - if (fillOnly) continue; + if (fillOnly) { + // Same again for an ordinary component the off-process line reaches: + // a domed regulator holds what it regulates, and the pilot gas on it + // is expected rather than a fault. + if (seen.species !== cur.species) { + seen.mixing = true; + if (!seen.sources.includes(cur.from)) seen.sources.push(cur.from); + } + continue; + } if (seen.species === cur.species) { if (!seen.sources.includes(cur.from)) seen.sources.push(cur.from); continue; @@ -214,10 +295,17 @@ export function propagateFluids( }; walk(process, [...queue]); - // Second pass: ullage ports, seeded from the sources again so a bottle - // connected only by its top is still the source of its own contents. Fill - // only -- see `walk`. - walk(ullage, [...queue], true); + // Second pass: the off-process lines, now that the process side has settled. + // + // Seeded from everything the first pass reached, not just the sources. A + // pressurant line rarely runs from a bottle straight to an ullage -- it runs + // through a regulator and a solenoid first, and it is *those* that deliver. + // Seeding only the sources meant the nitrogen never arrived, and the tank + // stopped recording that two fluids meet in it. + const delivered = [...out.entries()] + .filter(([, f]) => f.species) + .map(([id, f]) => ({ id, species: f.species!, from: f.sources[0] ?? id })); + walk(ullage, delivered, true); return out; } @@ -231,6 +319,9 @@ export function propagateFluids( export function edgeFluid( edge: Edge, byNode: Map, + /** Component type by id, so the line can tell a pressurant feed from a + * mistake. Optional only so older callers keep compiling. */ + typeOf?: Map, ): FluidAssignment { const a = byNode.get(edge.source); const b = byNode.get(edge.target); @@ -239,6 +330,16 @@ export function edgeFluid( const one = (a ?? b)!; return { ...one, sources: [...one.sources] }; } + // A line onto an ullage or a dome carries what the *other* end sends down + // it, and differing from the vessel is the point of it. Without this, every + // pressurant line into a tank drew in the fluid-conflict colour -- which + // nobody noticed while pressurant itself was red. + const srcOff = isOffProcessPort(typeOf?.get(edge.source), edge.sourceHandle); + const tgtOff = isOffProcessPort(typeOf?.get(edge.target), edge.targetHandle); + if (srcOff !== tgtOff) { + const feeder = srcOff ? b : a; + return { ...feeder, sources: [...feeder.sources], conflict: false }; + } if (a.species !== b.species) { // A line into a meeting point legitimately differs from what is already // there -- the fuel line into an engine is not the oxidiser line. diff --git a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx index 39be0eb67..d8886e7f2 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx @@ -71,8 +71,7 @@ export function ManifoldNode({ id, data, selected }: NodeProps) { const ids = ['in', ...portIds('p', outlets)]; const spare = defaultPositions(ids); return ids.map(pid => { - const kind = portKind(data as unknown as PIDNodeData, pid); - if (kind === 'plug') return null; + if (portKind(data as unknown as PIDNodeData, pid) === 'plug') return null; const pt = perimeterPoint(geom.positions[pid] ?? spare[pid], W, H); // The side decides which way React Flow thinks the port faces, // which is what makes a line leave it in a sensible direction. @@ -89,7 +88,7 @@ export function ManifoldNode({ id, data, selected }: NodeProps) { pt.side === 'top' || pt.side === 'bottom' ? { left: pt.x } : { top: pt.y }; - return ; + return ; }); })() ) : ( @@ -101,13 +100,12 @@ export function ManifoldNode({ id, data, selected }: NodeProps) { drawn at all -- a P&ID does not draw plugs. */} {portOffsets(outlets).map((off, i) => { const pid = portId('p', i); - const kind = portKind(data as unknown as PIDNodeData, pid); - if (kind === 'plug') return null; + if (portKind(data as unknown as PIDNodeData, pid) === 'plug') return null; return ( diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index b798d6ebe..0c35c1178 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -37,8 +37,11 @@ export function PRNode({ id, data, selected }: NodeProps) { {/* the dome, and the stem tying it to the seat */} + {/* Beside the dome, not under it. Centred at x=30 it sat on the + stem and directly under the dome port, so the line feeding the + dome ran straight through the word. */} - DOME + DOME )} diff --git a/pid-designer/frontend/src/components/pid/nodes/Port.tsx b/pid-designer/frontend/src/components/pid/nodes/Port.tsx index 7b997a427..ec92428e8 100644 --- a/pid-designer/frontend/src/components/pid/nodes/Port.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/Port.tsx @@ -1,4 +1,5 @@ import { Handle, Position, type HandleProps } from '@xyflow/react'; +import { useIsTap } from '../FluidContext'; /** * A port on a P&ID symbol. @@ -29,14 +30,23 @@ export function Port({ id, position, style, - kind = 'flow', + nodeId, ...rest -}: { id: string; position: Position; kind?: 'flow' | 'instrument' } & Omit & { +}: { + id: string; + position: Position; + /** The symbol this port is on. Given, the port works out for itself whether + * it is an instrument tapping — see `instrumentTaps`. */ + nodeId?: string; +} & Omit & { style?: React.CSSProperties; }) { // An instrument tapping is drawn hollow and small: it is real hardware, but - // it carries no flow, and a reader should not mistake it for a feed. - const look = kind === 'instrument' + // it carries no flow, and a reader should not mistake it for a feed. Asked + // of the graph rather than passed in, because what is on a port is not + // something the symbol should have to be told. + const tap = useIsTap(nodeId ?? '', id); + const look = tap ? { background: 'transparent', border: '1.5px solid #64748b', width: 5, height: 5 } : { background: '#94a3b8' }; return ( diff --git a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx index 018c5c8da..2ce935216 100644 --- a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx @@ -22,18 +22,20 @@ const INJ_W = 60, INJ_H = 100; * already drawn to the ports that remain. */ function endPorts( - n: number, prefix: 't' | 'b', position: Position, width: number, data: PIDNodeData, + n: number, prefix: 't' | 'b', position: Position, width: number, + data: PIDNodeData, nodeId: string, ) { const count = Math.max(1, Math.min(4, n)); return Array.from({ length: count }, (_, i) => { const pid = portId(prefix, i); - const kind = portKind(data, pid); - if (kind === 'plug') return null; + // Only `plug` is authored. Whether a port is an instrument tapping is + // something the port works out from what is on it. + if (portKind(data, pid) === 'plug') return null; return ( @@ -88,16 +90,24 @@ export function TankNode({ id, data, selected }: NodeProps) { return (
- {endPorts(Number(options?.portsTop ?? 1), 't', Position.Top, TANK_W, data as unknown as PIDNodeData)} - {endPorts(Number(options?.portsBottom ?? 1), 'b', Position.Bottom, TANK_W, data as unknown as PIDNodeData)} + {endPorts(Number(options?.portsTop ?? 1), 't', Position.Top, TANK_W, data as unknown as PIDNodeData, id)} + {endPorts(Number(options?.portsBottom ?? 1), 'b', Position.Bottom, TANK_W, data as unknown as PIDNodeData, id)} - - - + {/* One silhouette, filled once. + It used to be three shapes with their own fills -- a tinted barrel + between two slate heads -- so a tank read as a striped thing rather + than a vessel, and only the middle of it took the fluid colour. + Filling all three instead would band at the seams, because the + heads overlap the barrel and the tint is translucent. */} + + {/* Where each dished head meets the barrel. */} + + diff --git a/pid-designer/frontend/src/components/pid/ports.test.ts b/pid-designer/frontend/src/components/pid/ports.test.ts index b6fdfd86b..643517be5 100644 --- a/pid-designer/frontend/src/components/pid/ports.test.ts +++ b/pid-designer/frontend/src/components/pid/ports.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { Node } from '@xyflow/react'; -import { portId, portIds, portsOf, drawnPortsOf, portKind } from './ports'; +import type { Edge, Node } from '@xyflow/react'; +import { portId, portIds, portsOf, drawnPortsOf, portKind, instrumentTaps } from './ports'; import { COMPONENT_DEFS } from './types'; import { COMPONENT_SPECS } from './spec'; @@ -92,3 +92,50 @@ describe('the port table covers what the palette can drop', () => { } }); }); + +describe('which ports are instrument tappings', () => { + const n = (id: string, componentType: string): Node => + ({ id, position: { x: 0, y: 0 }, data: { componentType } }) as unknown as Node; + const e = (id: string, s: string, sh: string, t: string, th?: string): Edge => + ({ id, source: s, sourceHandle: sh, target: t, targetHandle: th ?? 'b' }) as unknown as Edge; + + it('is a port whose only line goes to a transducer', () => { + const taps = instrumentTaps( + [n('TK-1', 'TANK'), n('PT-1', 'PT')], + [e('a', 'TK-1', 't', 'PT-1')], + ); + expect(taps.has('TK-1:t')).toBe(true); + }); + + it('is not a port that feeds anything real', () => { + const taps = instrumentTaps( + [n('TK-1', 'TANK'), n('SOL-1', 'SOL')], + [e('a', 'TK-1', 'b', 'SOL-1', 'l')], + ); + expect(taps.has('TK-1:b')).toBe(false); + }); + + it('is not a port carrying flow as well as a tap', () => { + // A tee off a port with both a gauge and a run on it is a run, not a + // tapping -- and drawing it small would say the wrong thing. + const taps = instrumentTaps( + [n('MF-1', 'MANIFOLD'), n('PG-1', 'PG'), n('SOL-1', 'SOL')], + [e('a', 'MF-1', 'p', 'PG-1'), e('b', 'MF-1', 'p', 'SOL-1', 'l')], + ); + expect(taps.has('MF-1:p')).toBe(false); + }); + + it('says nothing about a port with nothing on it', () => { + // Empty is empty. It used to be possible to mark one an instrument port + // and leave it bare, which drew a tapping that measured nothing. + expect(instrumentTaps([n('TK-1', 'TANK')], []).has('TK-1:t')).toBe(false); + }); + + it('reads both ends of a line', () => { + const taps = instrumentTaps( + [n('TK-1', 'TANK'), n('PT-1', 'PT')], + [e('a', 'PT-1', 'b', 'TK-1', 't2')], + ); + expect(taps.has('TK-1:t2')).toBe(true); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/ports.ts b/pid-designer/frontend/src/components/pid/ports.ts index 34e5299ed..b35833dd7 100644 --- a/pid-designer/frontend/src/components/pid/ports.ts +++ b/pid-designer/frontend/src/components/pid/ports.ts @@ -1,4 +1,4 @@ -import type { Node } from '@xyflow/react'; +import type { Edge, Node } from '@xyflow/react'; import type { PIDNodeData } from './types'; /** @@ -13,7 +13,8 @@ import type { PIDNodeData } from './types'; * * - **flow** — carries fluid. The default, and what a drawn line attaches to. * - **instrument** — a tapping for a transducer. Real hardware, no flow; drawn - * smaller so it does not read as a feed. + * smaller so it does not read as a feed. **Derived, never authored** — see + * `instrumentTaps` below. * - **plug** — blanked off. **Not drawn at all**, because a P&ID does not draw * plugs; the port simply is not there until somebody says it is. That is * also why a plug is a port kind rather than a symbol you place. @@ -21,6 +22,48 @@ import type { PIDNodeData } from './types'; export type PortKind = 'flow' | 'instrument' | 'plug'; +/** + * The ports that are instrument tappings, worked out from what is on them. + * + * This used to be a third choice in a dropdown, and it was a second way of + * saying something the drawing already said: put a transducer on a port and + * you have told everyone it is a tapping. Two ways to say one thing is two + * ways to disagree, and the one somebody forgot to update is the one a reader + * would have believed. + * + * So a port with a transducer or a gauge on it *is* an instrument port, and + * `plug` is the only kind left worth authoring -- because "this one is blanked + * off" is a fact about the hardware that nothing else on the drawing states. + * + * Keys are `":"`. + */ +export function instrumentTaps(nodes: Node[], edges: Edge[]): Set { + const typeOf = new Map( + nodes.map(n => [n.id, (n.data as unknown as PIDNodeData)?.componentType])); + const isTap = (id?: string) => id === 'PT' || id === 'PG'; + + // Every edge on a port, by port. + const on = new Map(); + const add = (node: string, port: string | null | undefined, other: string) => { + if (!port) return; + const key = `${node}:${port}`; + const list = on.get(key); + if (list) list.push(other); + else on.set(key, [other]); + }; + for (const e of edges) { + add(e.source, e.sourceHandle, e.target); + add(e.target, e.targetHandle, e.source); + } + + const taps = new Set(); + for (const [key, others] of on) { + // Every line on it goes to an instrument, and there is at least one. + if (others.every(id => isTap(typeOf.get(id)))) taps.add(key); + } + return taps; +} + export interface PortInfo { label?: string; kind?: PortKind; diff --git a/pid-designer/frontend/src/components/pid/route.test.ts b/pid-designer/frontend/src/components/pid/route.test.ts new file mode 100644 index 000000000..cdf66e4cc --- /dev/null +++ b/pid-designer/frontend/src/components/pid/route.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { Position } from '@xyflow/react'; +import { routeOrthogonal, facing, isHorizontal } from './route'; + +const L = Position.Left, R = Position.Right, T = Position.Top, B = Position.Bottom; + +/** The points of a path, so a test can talk about shape rather than string. */ +function points(d: string): [number, number][] { + return [...d.matchAll(/[ML]\s*(-?[\d.]+),(-?[\d.]+)/g)] + .map(m => [Number(m[1]), Number(m[2])] as [number, number]); +} + +/** Every segment is horizontal or vertical. */ +function orthogonal(d: string): boolean { + const p = points(d); + for (let i = 0; i < p.length - 1; i++) { + const dx = Math.abs(p[i][0] - p[i + 1][0]); + const dy = Math.abs(p[i][1] - p[i + 1][1]); + if (dx > 1e-6 && dy > 1e-6) return false; + } + return true; +} + +/** Does the first segment leave `a` the way `a` points? */ +function leavesCorrectly(d: string, side: Position): boolean { + const p = points(d); + const [x0, y0] = p[0]; + // Skip zero-length openers, which a stub route can produce at a corner. + const next = p.find(([x, y]) => Math.abs(x - x0) > 1e-6 || Math.abs(y - y0) > 1e-6); + if (!next) return false; + const step = isHorizontal(side) ? next[0] - x0 : next[1] - y0; + // The first move must be along the port's own axis, in its own direction. + const alongAxis = isHorizontal(side) + ? Math.abs(next[1] - y0) < 1e-6 + : Math.abs(next[0] - x0) < 1e-6; + return alongAxis && step * facing(side) > 0; +} + +/** Does the last segment arrive at `b` from the side `b` points at? */ +function arrivesCorrectly(d: string, side: Position): boolean { + const p = points(d); + const [x1, y1] = p[p.length - 1]; + const prev = [...p].reverse().find( + ([x, y]) => Math.abs(x - x1) > 1e-6 || Math.abs(y - y1) > 1e-6); + if (!prev) return false; + const step = isHorizontal(side) ? prev[0] - x1 : prev[1] - y1; + const alongAxis = isHorizontal(side) + ? Math.abs(prev[1] - y1) < 1e-6 + : Math.abs(prev[0] - x1) < 1e-6; + return alongAxis && step * facing(side) > 0; +} + +describe('a line leaves a port the way the port points', () => { + // The bug this module exists for: a right-hand port reaching something below + // and to the left set off left, back across the symbol it came out of. + it('does not double back over the symbol it came from', () => { + const { d } = routeOrthogonal( + { x: 403, y: 290, side: R }, + { x: 370, y: 417, side: T }, + ); + expect(leavesCorrectly(d, R), d).toBe(true); + expect(arrivesCorrectly(d, T), d).toBe(true); + expect(orthogonal(d), d).toBe(true); + // Specifically: the first move is to the right of 403, not to 370. + expect(points(d)[1][0]).toBeGreaterThan(403); + }); + + const sides = [L, R, T, B]; + const spots: [number, number][] = [ + [0, 0], [200, 0], [-200, 0], [0, 200], [0, -200], + [200, 200], [-200, 200], [200, -200], [-200, -200], + [8, 200], [200, 8], [-8, -200], [30, 30], [-30, 30], + ]; + + it('leaves and arrives correctly from every side, everywhere', () => { + const bad: string[] = []; + for (const from of sides) { + for (const to of sides) { + for (const [dx, dy] of spots) { + const a = { x: 400, y: 300, side: from }; + const b = { x: 400 + dx, y: 300 + dy, side: to }; + if (dx === 0 && dy === 0) continue; + const { d } = routeOrthogonal(a, b); + if (!orthogonal(d)) bad.push(`not orthogonal ${from}->${to} @${dx},${dy}: ${d}`); + if (!leavesCorrectly(d, from)) bad.push(`leaves wrong ${from}->${to} @${dx},${dy}: ${d}`); + if (!arrivesCorrectly(d, to)) bad.push(`arrives wrong ${from}->${to} @${dx},${dy}: ${d}`); + } + } + } + expect(bad, `${bad.length} bad routes:\n${bad.slice(0, 12).join('\n')}`).toEqual([]); + }); +}); + +describe('the shapes a run takes', () => { + it('draws a straight line when both ends point along it', () => { + // A tank over a regulator: down out of one, up into the other. + const { d, grip } = routeOrthogonal( + { x: 300, y: 100, side: B }, + { x: 300, y: 400, side: T }, + ); + expect(points(d)).toEqual([[300, 100], [300, 400]]); + expect(grip).toBeNull(); + }); + + it('straightens a run that is a few pixels out rather than leaning it', () => { + // The reported bug: a port five pixels off drew a line leaning over its + // whole length. Both ends move by half the error and the run is vertical. + const { d } = routeOrthogonal( + { x: 300, y: 100, side: B }, + { x: 305, y: 400, side: T }, + ); + expect(points(d)).toEqual([[302.5, 100], [302.5, 400]]); + }); + + it('will not straighten a run whose ends point across it', () => { + // Vertically in line, but both ports face sideways. A straight vertical + // line would run out through the side of each symbol. + const { d } = routeOrthogonal( + { x: 300, y: 100, side: R }, + { x: 300, y: 400, side: R }, + ); + expect(points(d).length).toBeGreaterThan(2); + expect(leavesCorrectly(d, R), d).toBe(true); + }); + + it('turns one corner when the corner is ahead of both ends', () => { + const { d, grip } = routeOrthogonal( + { x: 100, y: 100, side: R }, + { x: 300, y: 300, side: T }, + ); + expect(points(d)).toEqual([[100, 100], [300, 100], [300, 300]]); + expect(grip).toBeNull(); + }); + + it('offers a crossbar between two ports that face each other', () => { + const { d, grip } = routeOrthogonal( + { x: 100, y: 100, side: R }, + { x: 300, y: 200, side: L }, + ); + expect(points(d)).toEqual([[100, 100], [200, 100], [200, 200], [300, 200]]); + expect(grip).toEqual({ x: 200, y: 150 }); + }); + + it('moves that crossbar by the stored offset', () => { + const { d } = routeOrthogonal( + { x: 100, y: 100, side: R }, + { x: 300, y: 200, side: L }, + 40, + ); + expect(points(d)[1][0]).toBe(240); + }); + + it('pins the crossbar clear of both ends when they point the same way', () => { + // Two right-hand ports: the crossbar cannot sit between them, so it goes + // out past the further one -- and there is nothing to drag. + const { d, grip } = routeOrthogonal( + { x: 100, y: 100, side: R }, + { x: 300, y: 200, side: R }, + ); + expect(grip).toBeNull(); + expect(points(d)[1][0]).toBeGreaterThan(300); + expect(leavesCorrectly(d, R), d).toBe(true); + expect(arrivesCorrectly(d, R), d).toBe(true); + }); + + it('routes around two ports that face away from each other', () => { + const { d } = routeOrthogonal( + { x: 300, y: 100, side: L }, + { x: 100, y: 200, side: R }, + ); + expect(leavesCorrectly(d, L), d).toBe(true); + expect(arrivesCorrectly(d, R), d).toBe(true); + }); +}); + +describe('a run that has to double back', () => { + it('goes round the symbol rather than through it', () => { + // A regulator's dome, and the bottle feeding it sitting underneath. Both + // ports point up and they are eight pixels apart, so the run must come + // back down past the regulator -- and it used to do that at the same x, + // straight through the body it had just left. + const { d } = routeOrthogonal( + { x: 510, y: 117, side: T }, + { x: 502, y: 257, side: T }, + ); + const xs = points(d).map(([x]) => x); + // Every intermediate leg is clear of the two ends, not between them. + expect(Math.max(...xs)).toBeGreaterThan(540); + expect(leavesCorrectly(d, T), d).toBe(true); + expect(arrivesCorrectly(d, T), d).toBe(true); + expect(orthogonal(d), d).toBe(true); + }); + + it('still takes the short way when there is room between them', () => { + // Far enough apart that the crossbar sits in open space. + const { d } = routeOrthogonal( + { x: 100, y: 100, side: T }, + { x: 400, y: 300, side: T }, + ); + expect(points(d).length).toBeLessThanOrEqual(4); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/route.ts b/pid-designer/frontend/src/components/pid/route.ts new file mode 100644 index 000000000..89f7e83d0 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/route.ts @@ -0,0 +1,201 @@ +import { Position } from '@xyflow/react'; + +/** + * Orthogonal routing between two ports. + * + * A P&ID is drawn with square corners, and a line leaves a port the way the + * port points. That second half is the one that was missing: the router used + * to turn a corner at the target's coordinate regardless of which way either + * end faced, so a line leaving a valve's right-hand port to reach something + * below and to the left set off *left*, back across the valve it had just come + * out of, before turning down. + * + * The rule here is one sentence: **every segment touching an end runs in the + * direction that end faces.** Everything below is that rule applied to the + * three shapes a run can take. + */ + +/** One end of a run: where it is, and which way it points. */ +export interface End { + x: number; + y: number; + side: Position; +} + +export interface Route { + d: string; + /** The middle segment, when there is one to drag. Null when the shape has no + * crossbar — a plain corner has nothing a reader could usefully move. */ + grip: { x: number; y: number } | null; +} + +/** + * How far a line runs straight out of a port before it is allowed to turn. + * + * Enough to read as "it leaves this way" at the zoom a bay is drawn at, and + * short enough not to collide with a symbol sitting one grid square away. + */ +const STUB = 16; + +/** + * Two ends this close on the perpendicular axis are treated as in line. + * + * Ports sit at 0, 30 and 60 across a 60-pixel symbol and the grid is 10, so + * two symbols stacked deliberately can still be a few pixels out. Snapping the + * run to their average is straight; joining the points is a line that leans. + */ +const ALIGNED = 10; + +/** + * How far to the side a detour goes to clear the symbol it is leaving. + * + * A symbol is sixty across and its ports sit on the edge, so half of one is + * thirty from the port. Two ports pointing the same way and nearly in line -- + * a regulator's dome and the bottle feeding it, sitting under it -- otherwise + * got a run that went up, across by a few pixels, and back down straight + * through the regulator body. + */ +const CLEAR = 44; + +export const isHorizontal = (p: Position) => + p === Position.Left || p === Position.Right; + +/** +1 for right/down, -1 for left/up: the way a port points, as a sign. */ +export const facing = (p: Position): 1 | -1 => + p === Position.Right || p === Position.Bottom ? 1 : -1; + +export function routeOrthogonal(a: End, b: End, offset = 0): Route { + const aH = isHorizontal(a.side); + const bH = isHorizontal(b.side); + const as = facing(a.side); + const bs = facing(b.side); + + const dx = Math.abs(a.x - b.x); + const dy = Math.abs(a.y - b.y); + + // ── In line, and pointing along the run ────────────────────────────────── + // + // Only when both ends face along it. Two ports that happen to be vertically + // aligned but both point sideways still need to leave sideways, and drawing + // the straight line between them would run out of the side of each symbol. + if (dx <= ALIGNED && dy > dx && !aH && !bH + && (b.y - a.y) * as > 0 && (a.y - b.y) * bs > 0) { + const x = (a.x + b.x) / 2; + return { d: `M ${x},${a.y} L ${x},${b.y}`, grip: null }; + } + if (dy <= ALIGNED && dx > dy && aH && bH + && (b.x - a.x) * as > 0 && (a.x - b.x) * bs > 0) { + const y = (a.y + b.y) / 2; + return { d: `M ${a.x},${y} L ${b.x},${y}`, grip: null }; + } + + // ── One end horizontal, one vertical: a corner, if the corner is ahead ─── + if (aH !== bH) { + const h = aH ? a : b; // the end that leaves sideways + const v = aH ? b : a; // the end that leaves up or down + const hs = aH ? as : bs; + const vs = aH ? bs : as; + // The natural corner sits at the vertical end's x and the horizontal + // end's y. It only works if it is on the side each end actually faces. + const ahead = (v.x - h.x) * hs > 0 && (h.y - v.y) * vs > 0; + if (ahead) { + const d = aH + ? `M ${a.x},${a.y} L ${b.x},${a.y} L ${b.x},${b.y}` + : `M ${a.x},${a.y} L ${a.x},${b.y} L ${b.x},${b.y}`; + return { d, grip: null }; + } + // Otherwise stub out of both ends first and join the stubs. Four segments, + // and every one of them leaves an end the way that end points. + const hx = h.x + hs * STUB; + const vy = v.y + vs * STUB; + const d = aH + ? `M ${a.x},${a.y} L ${hx},${a.y} L ${hx},${vy} L ${b.x},${vy} L ${b.x},${b.y}` + : `M ${a.x},${a.y} L ${a.x},${vy} L ${hx},${vy} L ${hx},${b.y} L ${b.x},${b.y}`; + return { d, grip: null }; + } + + // ── Both horizontal, or both vertical: a crossbar between them ─────────── + // + // Where the crossbar can go depends on which way the two ends point. Facing + // each other, it goes between them and the reader can slide it. Facing the + // same way, or facing apart with no room in between, it has to clear both + // ends instead -- and then it is not a free crossbar any more, so there is + // nothing to offer a drag handle for. + // Two ends pointing the same way and nearly in line have to double back, + // and doing that at the same coordinate draws the return leg through the + // symbol. Send those round the side instead. + const perp = aH ? Math.abs(a.y - b.y) : Math.abs(a.x - b.x); + const doublesBack = as === bs && perp < CLEAR; + + if (aH) { + const mid = doublesBack ? null : crossbar(a.x, as, b.x, bs, offset); + if (mid) { + const d = `M ${a.x},${a.y} L ${mid.at},${a.y} L ${mid.at},${b.y} L ${b.x},${b.y}`; + return { d, grip: mid.free ? { x: mid.at, y: (a.y + b.y) / 2 } : null }; + } + // Facing apart, with nothing between them: out of both ends, and round. + const ax = a.x + as * STUB; + const bx = b.x + bs * STUB; + const my = aside(a.y, b.y); + return { + d: `M ${a.x},${a.y} L ${ax},${a.y} L ${ax},${my} L ${bx},${my} L ${bx},${b.y} L ${b.x},${b.y}`, + grip: null, + }; + } + const mid = doublesBack ? null : crossbar(a.y, as, b.y, bs, offset); + if (mid) { + return { + d: `M ${a.x},${a.y} L ${a.x},${mid.at} L ${b.x},${mid.at} L ${b.x},${b.y}`, + grip: mid.free ? { x: (a.x + b.x) / 2, y: mid.at } : null, + }; + } + const ay = a.y + as * STUB; + const by = b.y + bs * STUB; + const mx = aside(a.x, b.x); + return { + d: `M ${a.x},${a.y} L ${a.x},${ay} L ${mx},${ay} L ${mx},${by} L ${b.x},${by} L ${b.x},${b.y}`, + grip: null, + }; +} + +/** + * A line to run round on, on the axis the two ends do *not* leave along. + * + * Their midpoint, unless they share it -- two symbols stacked exactly would + * otherwise get a "detour" that retraces the line it just drew. + */ +function aside(a: number, b: number): number { + // Far apart, the midpoint is between the two symbols and clear of both. + // Close together, it is *inside* them, so go round instead. + return Math.abs(a - b) > 2 * CLEAR ? (a + b) / 2 : Math.max(a, b) + CLEAR; +} + +/** + * Where the crossbar sits on a same-axis run, and whether it may be moved. + * + * It must be ahead of both ends: `(at - a)·as > 0` and `(at - b)·bs > 0`. When + * the two point at each other with room in between, every position in that gap + * satisfies both and the midpoint is as good a default as any -- so that one is + * free to drag. In every other arrangement exactly one side of both ends works, + * and the crossbar is pinned just past the further of them. + */ +function crossbar( + a: number, as: number, b: number, bs: number, offset: number, +): { at: number; free: boolean } | null { + // Pointing at each other with room between: anywhere in the gap works, so + // the midpoint is the default and the reader may slide it. + if (as > 0 && bs < 0 && b - a > 2 * STUB) { + return { at: (a + b) / 2 + offset, free: true }; + } + if (as < 0 && bs > 0 && a - b > 2 * STUB) { + return { at: (a + b) / 2 + offset, free: true }; + } + // Pointing the same way: one side of both ends works. Out past the further. + if (as === bs) { + return { at: as > 0 ? Math.max(a, b) + STUB : Math.min(a, b) - STUB, free: false }; + } + // Pointing apart, or at each other with no room. No single crossbar can be + // ahead of both, and pretending otherwise is what drew a line back through + // the symbol it came from. The caller routes round instead. + return null; +} From 9633362f55753f1a5fc99861db921f903c152b10 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Thu, 10 Sep 2026 00:02:47 -0700 Subject: [PATCH 32/57] Turning a symbol turns its ports, not just its picture Rotation was a CSS transform on the whole node. That moved a port on screen without moving what ReactFlow believed about it: a valve turned ninety degrees had its inlet sitting on the top edge and still recorded as facing left, so the router sent the line off sideways and hooked it back. Every rotated symbol drew that hook, and it is most of what "the lines are janky sometimes" was. Ports now live outside the rotation and the artwork inside it. `Frame` holds both and takes the turned symbol's shape, so a rotated tank is 100 wide and 60 tall rather than hanging out of a box that never turned. `turn` says which side a port ends up on and `turnPlacement` says whereabouts along it -- the left edge's top end becomes the top edge's *right* end, which is the part that is easy to get backwards and is covered from every side at every rotation. A tank straight down through a turned solenoid into a turned dome regulator is now three points on one vertical line, where it used to be two hooks. Falling out of it, because they were all the same problem: - The tag stopped needing to counter-rotate, so `DraggableLabel` lost the rotation maths entirely, including rotating each drag delta back into the symbol's frame. It sits under the box as drawn. - Same for the NO/NC marker, the relief valve's set pressure and the bottle pressure -- all three were counter-rotating a wrapper that no longer turns. - A rotated symbol's ports land on the symbol. Checked directly: no handle on any of eleven symbols at four rotations sits outside its own box. --- .../components/pid/nodes/CheckValveNode.tsx | 16 ++- .../components/pid/nodes/DraggableLabel.tsx | 54 +++------ .../src/components/pid/nodes/EngineNode.tsx | 21 ++-- .../src/components/pid/nodes/Frame.tsx | 86 +++++++++++++++ .../src/components/pid/nodes/ManifoldNode.tsx | 104 +++++++++--------- .../src/components/pid/nodes/PRNode.tsx | 18 ++- .../src/components/pid/nodes/QDNode.tsx | 20 ++-- .../src/components/pid/nodes/RVNode.tsx | 37 ++++--- .../src/components/pid/nodes/SensorNode.tsx | 12 +- .../src/components/pid/nodes/SupplyNode.tsx | 65 ++++++----- .../src/components/pid/nodes/TankNode.tsx | 46 +++++--- .../src/components/pid/nodes/ValveNode.tsx | 47 ++++---- .../frontend/src/components/pid/route.test.ts | 77 ++++++++++++- .../frontend/src/components/pid/route.ts | 54 +++++++++ 14 files changed, 453 insertions(+), 204 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/nodes/Frame.tsx diff --git a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx index c047ecfe3..49f9a4382 100644 --- a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx @@ -2,23 +2,29 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Frame } from './Frame'; +import { turn } from '../route'; const W = 60, H = 60; export function CheckValveNode({ id, data, selected }: NodeProps) { const { label, labelOffset, rotation } = data as unknown as PIDNodeData; const stroke = selected ? '#3b82f6' : '#94a3b8'; + // The box once turned, so the tag sits under what is drawn. + const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( -
- - + + + + + } + > - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx b/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx index bee8f9024..0a968e66b 100644 --- a/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx @@ -6,12 +6,13 @@ interface DraggableLabelProps { nodeId: string; label: string; offset?: { x: number; y: number }; + /** Where the tag sits when nobody has dragged it. Measured from the top-left + * of the symbol's box *as drawn*, so a caller that turns its artwork passes + * the turned box's height and the tag stays underneath it. */ defaultOffset: { x: number; y: number }; - /** The symbol's rotation, so the tag can undo it and stay upright. */ - rotation?: number; } -export function DraggableLabel({ nodeId, label, offset, defaultOffset, rotation = 0 }: DraggableLabelProps) { +export function DraggableLabel({ nodeId, label, offset, defaultOffset }: DraggableLabelProps) { const { setNodes, getViewport } = useReactFlow(); // This edits through useReactFlow rather than the canvas's own handlers, // so ReactFlow's interaction props do not reach it. It has to check the @@ -25,13 +26,11 @@ export function DraggableLabel({ nodeId, label, offset, defaultOffset, rotation useEffect(() => { if (!editing) setEditVal(label); }, [label, editing]); - // Rotating a valve rotated its tag with it, and sideways text is not what - // anybody wanted from R. The tag counter-rotates so it stays upright, and its - // default position swings round to whichever side is now "below" the symbol - // -- dragging it still overrides that, and a dragged offset is left alone. - const spun = ((rotation % 360) + 360) % 360; - const swung = offset ?? rotatedDefault(defaultOffset, spun); - const currentOffset = swung; + // Nothing here turns any more. A symbol's rotation is applied to its + // artwork alone (see `Frame`), so the tag is drawn in the box's own frame: + // upright by construction, and below the symbol as it actually appears + // rather than below where it would have been unturned. + const currentOffset = offset ?? defaultOffset; const commitLabel = useCallback(() => { setNodes(nds => nds.map(n => @@ -61,21 +60,11 @@ export function DraggableLabel({ nodeId, label, offset, defaultOffset, rotation const dx = (e.clientX - dragStart.current.mouseX) / zoom; const dy = (e.clientY - dragStart.current.mouseY) / zoom; - // The offset lives in the symbol's own frame, and the symbol may be - // turned. A drag is measured on screen, so it has to be rotated *back* - // into that frame before it is added -- otherwise dragging a tag on a - // symbol rotated 90 degrees moves it sideways, and on one rotated 180 it - // moves the opposite way to the mouse. - const a = (spun * Math.PI) / 180; - const cos = Math.cos(a), sin = Math.sin(a); - const localDx = dx * cos + dy * sin; - const localDy = -dx * sin + dy * cos; - setNodes(nds => nds.map(n => n.id === nodeId ? { ...n, data: { ...n.data, labelOffset: { - x: dragStart.current!.ox + localDx, - y: dragStart.current!.oy + localDy, + x: dragStart.current!.ox + dx, + y: dragStart.current!.oy + dy, }}} : n, )); @@ -89,7 +78,7 @@ export function DraggableLabel({ nodeId, label, offset, defaultOffset, rotation window.removeEventListener('mousemove', onMove, true); window.removeEventListener('mouseup', onUp, true); }; - }, [dragging, nodeId, setNodes, getViewport, spun]); + }, [dragging, nodeId, setNodes, getViewport]); return (
); } - -/** - * Where a tag sits once its symbol has been turned. - * - * The default puts it under the symbol. Turned ninety degrees that position is - * off to one side, so it is swung round the symbol's centre to stay under what - * the reader now sees. - */ -function rotatedDefault(d: { x: number; y: number }, deg: number): { x: number; y: number } { - switch (deg) { - case 90: return { x: d.y, y: -d.x }; - case 180: return { x: -d.x, y: -d.y }; - case 270: return { x: -d.y, y: d.x }; - default: return d; - } -} diff --git a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx index 527da2151..ce038ac0d 100644 --- a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx @@ -1,5 +1,5 @@ import { Position, type NodeProps } from '@xyflow/react'; -import { Port } from './Port'; +import { Frame, TurnedPort } from './Frame'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; import { Upright } from './Upright'; @@ -24,12 +24,17 @@ export function EngineNode({ id, data, selected }: NodeProps) { const stroke = selected ? '#3b82f6' : '#94a3b8'; const pc = params?.chamber_pressure; + const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( -
- - - - + + + + + + } + > {/* injector manifold block */} - - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/Frame.tsx b/pid-designer/frontend/src/components/pid/nodes/Frame.tsx new file mode 100644 index 000000000..31d599837 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/nodes/Frame.tsx @@ -0,0 +1,86 @@ +import { Position } from '@xyflow/react'; +import { turnPlacement } from '../route'; +import { Port } from './Port'; +import type { ReactNode } from 'react'; + +/** + * The box a symbol is drawn in, and the thing that makes rotation honest. + * + * Rotating used to be a CSS transform on the whole node -- ports included -- + * which moved a port on screen without moving what ReactFlow believed about + * it. A turned valve's inlet was still recorded as facing left while sitting + * on the top edge, and the router duly sent its line off sideways. + * + * So the ports come out of the rotation and the artwork stays in it: the + * caller places each port with the side it *now* faces (see `turn`), and + * everything in here is only the picture. That also lets the box take the + * turned symbol's shape, which is what stops a rotated tank hanging out of + * its own bounds. + * + * Anything that must stay upright -- a tag, a NO/NC marker -- belongs outside + * `children`, as a sibling of this component's output, where nothing rotates + * it in the first place. + */ +export function Frame({ w, h, rotation = 0, children, extra }: { + w: number; + h: number; + rotation?: number; + /** The artwork. Rotated about the box's centre. */ + children: ReactNode; + /** Ports, tags and markers. Never rotated. */ + extra?: ReactNode; +}) { + const quarter = Math.round(((rotation % 360) + 360) % 360 / 90) % 2 === 1; + const bw = quarter ? h : w; + const bh = quarter ? w : h; + + return ( +
+ {extra} +
+ {children} +
+
+ ); +} + +/** + * A port placed on a symbol that may have been turned. + * + * Takes the side and the distance along it as *drawn* -- the numbers a reader + * of the artwork would measure -- and puts the handle where those land once + * the symbol is rotated, facing the way it now faces. Nodes state their + * geometry once, unturned, and this does the rest. + */ +export function TurnedPort({ id, nodeId, side, along, w, h, rotation = 0 }: { + id: string; + nodeId: string; + side: Position; + /** Pixels along the edge from the box's top-left. Omitted means centred. */ + along?: number; + w: number; + h: number; + rotation?: number; +}) { + const quarter = Math.round(((rotation % 360) + 360) % 360 / 90) % 2 === 1; + const centre = side === Position.Top || side === Position.Bottom ? w / 2 : h / 2; + const placed = turnPlacement(side, along ?? centre, w, h, rotation); + const across = placed.side === Position.Top || placed.side === Position.Bottom; + // Centred ports need no override: ReactFlow already centres them, and on a + // turned box its 50% is the right 50%. + const style = along === undefined && !quarter + ? undefined + : across ? { left: placed.along } : { top: placed.along }; + return ; +} diff --git a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx index d8886e7f2..bfcf2834d 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx @@ -1,6 +1,6 @@ import { Position, useUpdateNodeInternals, type NodeProps } from '@xyflow/react'; import { useEffect } from 'react'; -import { Port } from './Port'; +import { Frame, TurnedPort } from './Frame'; import type { PIDNodeData } from '../types'; import { colorForSpecies, speciesById, UNSET_COLOR } from '../fluids'; import { useNodeFluid } from '../FluidContext'; @@ -63,57 +63,57 @@ export function ManifoldNode({ id, data, selected }: NodeProps) { const W = geom ? geom.width : vertical ? BODY : run; const H = geom ? geom.height : vertical ? run : BODY; - return ( -
- {geom ? ( - // Placed by hand: each port sits where its perimeter fraction puts it. - (() => { - const ids = ['in', ...portIds('p', outlets)]; - const spare = defaultPositions(ids); - return ids.map(pid => { - if (portKind(data as unknown as PIDNodeData, pid) === 'plug') return null; - const pt = perimeterPoint(geom.positions[pid] ?? spare[pid], W, H); - // The side decides which way React Flow thinks the port faces, - // which is what makes a line leave it in a sensible direction. - const position = - pt.side === 'top' ? Position.Top - : pt.side === 'bottom' ? Position.Bottom - : pt.side === 'left' ? Position.Left - : Position.Right; - // Only the coordinate along the edge. React Flow uses `transform` - // to sit a handle *on* its edge, so overriding it pushes the port - // off the block by half its own width -- which is what the ports - // floating outside the outline were. - const style: React.CSSProperties = - pt.side === 'top' || pt.side === 'bottom' - ? { left: pt.x } - : { top: pt.y }; - return ; - }); - })() - ) : ( - <> - {/* The feed in, at the near end. */} - + const quarter = (rotation ?? 0) % 180 === 90; + const boxH = quarter ? W : H; + + const ports = geom ? ( + // Placed by hand: each port sits where its perimeter fraction puts it. + (() => { + const ids = ['in', ...portIds('p', outlets)]; + const spare = defaultPositions(ids); + return ids.map(pid => { + if (portKind(data as unknown as PIDNodeData, pid) === 'plug') return null; + const pt = perimeterPoint(geom.positions[pid] ?? spare[pid], W, H); + const side = + pt.side === 'top' ? Position.Top + : pt.side === 'bottom' ? Position.Bottom + : pt.side === 'left' ? Position.Left + : Position.Right; + const along = pt.side === 'top' || pt.side === 'bottom' ? pt.x : pt.y; + return ( + + ); + }); + })() + ) : ( + <> + {/* The feed in, at the near end. */} + - {/* One tapping per outlet, down the long side. A plugged one is not - drawn at all -- a P&ID does not draw plugs. */} - {portOffsets(outlets).map((off, i) => { - const pid = portId('p', i); - if (portKind(data as unknown as PIDNodeData, pid) === 'plug') return null; - return ( - - ); - })} - - )} + {/* One tapping per outlet, down the long side. A plugged one is not + drawn at all -- a P&ID does not draw plugs. */} + {portOffsets(outlets).map((off, i) => { + const pid = portId('p', i); + if (portKind(data as unknown as PIDNodeData, pid) === 'plug') return null; + return ( + + ); + })} + + ); + return ( + + {ports} + + } + > @@ -122,8 +122,6 @@ export function ManifoldNode({ id, data, selected }: NodeProps) { ? : } - - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index 0c35c1178..f2346013c 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -3,6 +3,8 @@ import { useEffect } from 'react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Frame } from './Frame'; +import { turn } from '../route'; import { Upright } from './Upright'; const W = 60, H = 60; @@ -18,11 +20,16 @@ export function PRNode({ id, data, selected }: NodeProps) { const updateNodeInternals = useUpdateNodeInternals(); useEffect(() => { updateNodeInternals(id); }, [id, domeLoaded, updateNodeInternals]); + // The box once turned, so the tag sits under what is drawn. + const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( -
- - - {domeLoaded && } + + + + {domeLoaded && } + + } + > - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx index 5bf537db9..569a6f73b 100644 --- a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx @@ -2,6 +2,8 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Frame } from './Frame'; +import { turn } from '../route'; import { Upright } from './Upright'; const W = 60, H = 60; @@ -19,12 +21,17 @@ export function QDNode({ id, data, selected }: NodeProps) { const stroke = selected ? '#3b82f6' : '#94a3b8'; const hydraulic = options?.service === 'hydraulic'; + // The box once turned, so the tag sits under what is drawn. + const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( -
- - - - + + + + + + + } + > - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx index fd2bc8be7..fa12e69a6 100644 --- a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx @@ -2,6 +2,8 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Frame } from './Frame'; +import { turn } from '../route'; const W = 60, H = 60; @@ -20,20 +22,13 @@ export function RVNode({ id, data, selected }: NodeProps) { const reseat = params?.reseat_pressure; const spin = ((((rotation ?? 0) % 360) + 360) % 360); + // The box once turned, so the tag sits under what is drawn. + const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( -
- - - - - - - - - - {(set || reseat) && ( + + + + {(set || reseat) && ( )} + + } + > + + + + + + + + - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx index de0909595..eec11e538 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx @@ -2,6 +2,8 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Frame } from './Frame'; +import { turn } from '../route'; import { Upright } from './Upright'; /** @@ -25,9 +27,12 @@ export function SensorNode({ id, data, selected }: NodeProps) { const stroke = selected ? '#3b82f6' : (color ?? '#94a3b8'); return ( -
+ + {tapped && } + + } + > {/* One tapping, at the bottom. Rotate the symbol to point it elsewhere. */} - {tapped && } - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx index fd8e7f5bf..915061d68 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx @@ -1,9 +1,9 @@ import { Position, type NodeProps } from '@xyflow/react'; -import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { speciesById, colorForSpecies, UNSET_COLOR } from '../fluids'; import { useNodeFluid } from '../FluidContext'; import { DraggableLabel } from './DraggableLabel'; +import { Frame, TurnedPort } from './Frame'; import { Upright } from './Upright'; /** @@ -32,13 +32,24 @@ export function SupplyNode({ id, data, selected }: NodeProps) { const species = speciesById(assigned?.species ?? undefined); const tint = color ?? (species ? colorForSpecies(species.id) : UNSET_COLOR); const p = params?.pressure; + // The box each symbol occupies once turned, so what sits under it -- the + // bottle pressure, the tag -- follows the picture rather than the unturned + // dimensions. + const quarter = (rotation ?? 0) % 180 === 90; + const dewarBoxH = quarter ? DW_W : DW_H; + const bottleBoxH = quarter ? KB_W : KB_H; if (componentType === 'DEWAR') { return ( -
- - - + + + + + + } + > - -
+ ); } return ( -
- - + + + + {p && ( + + {p.value} {p.unit} + + )} + + } + > {/* valve stem and cap */} @@ -80,22 +107,6 @@ export function SupplyNode({ id, data, selected }: NodeProps) { - {/* Under the bottle, not inside it: "6000 psi" is wider than the body - and was running off both sides of it. */} - {p && ( - - {p.value} {p.unit} - - )} - - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx index 2ce935216..2449acec5 100644 --- a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx @@ -1,5 +1,5 @@ import { Position, useUpdateNodeInternals, type NodeProps } from '@xyflow/react'; -import { Port } from './Port'; +import { Frame, TurnedPort } from './Frame'; import type { PIDNodeData } from '../types'; import { speciesById, colorForSpecies, UNSET_COLOR } from '../fluids'; import { useNodeFluid } from '../FluidContext'; @@ -22,8 +22,8 @@ const INJ_W = 60, INJ_H = 100; * already drawn to the ports that remain. */ function endPorts( - n: number, prefix: 't' | 'b', position: Position, width: number, - data: PIDNodeData, nodeId: string, + n: number, prefix: 't' | 'b', side: Position, w: number, h: number, + data: PIDNodeData, nodeId: string, rotation: number, ) { const count = Math.max(1, Math.min(4, n)); return Array.from({ length: count }, (_, i) => { @@ -32,12 +32,14 @@ function endPorts( // something the port works out from what is on it. if (portKind(data, pid) === 'plug') return null; return ( - ); }); @@ -65,12 +67,21 @@ export function TankNode({ id, data, selected }: NodeProps) { const species = speciesById(assigned?.species ?? undefined); const fluidColor = color ?? (species ? colorForSpecies(species.id) : UNSET_COLOR); const isInjector = componentType === 'INJECTOR'; + // The box each symbol occupies once turned, so the tag stays under it. + const quarter = (rotation ?? 0) % 180 === 90; + const injBoxH = quarter ? INJ_W : INJ_H; + const tankBoxH = quarter ? TANK_W : TANK_H; if (isInjector) { return ( -
- - + + + + + } + > - -
+ ); } return ( -
- {endPorts(Number(options?.portsTop ?? 1), 't', Position.Top, TANK_W, data as unknown as PIDNodeData, id)} - {endPorts(Number(options?.portsBottom ?? 1), 'b', Position.Bottom, TANK_W, data as unknown as PIDNodeData, id)} + + {endPorts(Number(options?.portsTop ?? 1), 't', Position.Top, TANK_W, TANK_H, data as unknown as PIDNodeData, id, rotation ?? 0)} + {endPorts(Number(options?.portsBottom ?? 1), 'b', Position.Bottom, TANK_W, TANK_H, data as unknown as PIDNodeData, id, rotation ?? 0)} + + } + > {/* One silhouette, filled once. @@ -116,7 +131,6 @@ export function TankNode({ id, data, selected }: NodeProps) { - -
+ ); } diff --git a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx index c52b7d53e..e0219c19b 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx @@ -2,6 +2,8 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; +import { Frame } from './Frame'; +import { turn } from '../route'; import { Upright } from './Upright'; const W = 60, H = 60; @@ -50,29 +52,34 @@ export function ValveNode({ id, data, selected }: NodeProps) { // drawing during a procedure review looks for -- so it belongs on the // symbol, not two clicks away inside a dialog. const failOpen = options?.failState === 'open'; + // The box the symbol occupies once turned, so the tag sits under what the + // reader actually sees rather than under where it would have been unturned. + const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( -
- - + + + + {componentType !== 'MAN' && ( + + {failOpen ? 'NO' : 'NC'} + + )} + + } + > {componentType === 'MAN' ? : } - {componentType !== 'MAN' && ( - - {failOpen ? 'NO' : 'NC'} - - )} - -
+ ); } + diff --git a/pid-designer/frontend/src/components/pid/route.test.ts b/pid-designer/frontend/src/components/pid/route.test.ts index cdf66e4cc..dc77b8f8f 100644 --- a/pid-designer/frontend/src/components/pid/route.test.ts +++ b/pid-designer/frontend/src/components/pid/route.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { Position } from '@xyflow/react'; -import { routeOrthogonal, facing, isHorizontal } from './route'; +import { routeOrthogonal, facing, isHorizontal, turn, turnPlacement } from './route'; const L = Position.Left, R = Position.Right, T = Position.Top, B = Position.Bottom; @@ -200,3 +200,78 @@ describe('a run that has to double back', () => { expect(points(d).length).toBeLessThanOrEqual(4); }); }); + +describe('turning a symbol turns which way its ports face', () => { + it('steps a quarter turn clockwise', () => { + expect(turn(L, 90)).toBe(T); + expect(turn(T, 90)).toBe(R); + expect(turn(R, 90)).toBe(B); + expect(turn(B, 90)).toBe(L); + }); + + it('leaves an unturned symbol alone', () => { + for (const s of [L, R, T, B]) expect(turn(s, 0)).toBe(s); + }); + + it('comes back round after four', () => { + for (const s of [L, R, T, B]) { + expect(turn(s, 360)).toBe(s); + expect(turn(turn(s, 180), 180)).toBe(s); + expect(turn(s, 270)).toBe(turn(s, -90)); + } + }); + + it('is what makes a turned valve route straight', () => { + // A tank above a valve turned ninety degrees. The valve's inlet is drawn + // on its left and now sits on top, so the run is a plain vertical drop -- + // and used to be a hook, because the port still claimed to face left. + const inlet = turn(L, 90); + expect(inlet).toBe(T); + const { d } = routeOrthogonal( + { x: 300, y: 100, side: B }, + { x: 300, y: 260, side: inlet }, + ); + expect(points(d)).toEqual([[300, 100], [300, 260]]); + }); +}); + +describe('where a port sits after the symbol is turned', () => { + // An engine: 72 across, 120 tall, fuel inlet 18 down its left edge. + const W = 72, H = 120; + + it('leaves an unturned symbol alone', () => { + expect(turnPlacement(L, 18, W, H, 0)).toEqual({ side: L, along: 18 }); + }); + + it('reverses the direction where a quarter turn reverses the edge', () => { + // The left edge's top end becomes the top edge's right end, so a port 18 + // down the left is 18 in from the right of a box that is now 120 wide. + expect(turnPlacement(L, 18, W, H, 90)).toEqual({ side: T, along: H - 18 }); + }); + + it('keeps the direction where the turn preserves it', () => { + expect(turnPlacement(T, 20, W, H, 90)).toEqual({ side: R, along: 20 }); + }); + + it('puts a port back where it started after four turns', () => { + for (const side of [L, R, T, B]) { + expect(turnPlacement(side, 25, W, H, 360)).toEqual({ side, along: 25 }); + } + }); + + it('keeps a port on the box it belongs to', () => { + // Whatever the turn, the offset is inside the turned box's own extent. + for (const rotation of [0, 90, 180, 270]) { + const quarter = rotation % 180 === 90; + const [bw, bh] = quarter ? [H, W] : [W, H]; + for (const side of [L, R, T, B]) { + for (const along of [0, 18, 40]) { + const out = turnPlacement(side, along, W, H, rotation); + const extent = out.side === T || out.side === B ? bw : bh; + expect(out.along, `${side}@${along} r${rotation}`).toBeGreaterThanOrEqual(0); + expect(out.along, `${side}@${along} r${rotation}`).toBeLessThanOrEqual(extent); + } + } + } + }); +}); diff --git a/pid-designer/frontend/src/components/pid/route.ts b/pid-designer/frontend/src/components/pid/route.ts index 89f7e83d0..3b04b248b 100644 --- a/pid-designer/frontend/src/components/pid/route.ts +++ b/pid-designer/frontend/src/components/pid/route.ts @@ -199,3 +199,57 @@ function crossbar( // the symbol it came from. The caller routes round instead. return null; } + +/** The four sides, clockwise, so a quarter turn is one step along. */ +const CLOCKWISE = [Position.Top, Position.Right, Position.Bottom, Position.Left]; + +/** + * Which side a port ends up on once its symbol has been turned. + * + * A rotation moves a port on screen, and until this existed it did not move + * the port's *facing*: React Flow still had a rotated valve's inlet down as + * left-facing, so the router sent the line off sideways from a port that was + * now on the top. The coordinates were right and the direction was not, which + * is the whole of why rotated symbols drew hooks. + */ +export function turn(side: Position, rotation = 0): Position { + const steps = Math.round(((rotation % 360) + 360) % 360 / 90) % 4; + return CLOCKWISE[(CLOCKWISE.indexOf(side) + steps) % 4]; +} + +/** + * Where a port sits after its symbol is turned. + * + * `turn` says which side a port ends up on; this says whereabouts along that + * side. A tank's three bottom ports and an engine's two inlets are placed a + * measured distance along their edge, and a quarter turn does not just move + * the edge -- it can reverse the direction the distance is measured in. The + * left edge's top end becomes the top edge's *right* end. + * + * `along` is pixels from the box's top-left corner, down or across the edge. + * The returned `along` is in the turned box, whose width and height have + * swapped for an odd number of quarter turns. + */ +export function turnPlacement( + side: Position, along: number, w: number, h: number, rotation = 0, +): { side: Position; along: number } { + const steps = Math.round(((rotation % 360) + 360) % 360 / 90) % 4; + + // The port as a point in the unturned box. + let x = side === Position.Right ? w : side === Position.Left ? 0 : along; + let y = side === Position.Bottom ? h : side === Position.Top ? 0 : along; + let bw = w, bh = h; + + // One quarter turn clockwise: (x, y) in a bw x bh box becomes (bh - y, x). + for (let i = 0; i < steps; i++) { + const nx = bh - y, ny = x; + [x, y] = [nx, ny]; + [bw, bh] = [bh, bw]; + } + + const turned = turn(side, rotation); + return { + side: turned, + along: turned === Position.Top || turned === Position.Bottom ? x : y, + }; +} From ae656c08163dbea2c23257f4d376cc53728bf408 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Thu, 10 Sep 2026 00:06:54 -0700 Subject: [PATCH 33/57] Lettering stays on the thing it labels, and tags stop being see-through Two more from looking closely at a turned symbol. **A solenoid's `S` flew off its actuator.** `Upright` cancelled the symbol's rotation about the symbol's *centre*, which put the text back where it would have been unturned -- while the actuator it names had moved round to the side. Counter-rotating about the text's own anchor instead leaves it exactly where the rotation carried it and only spins it upright. The engine's two labels needed splitting for the same reason: sharing one wrapper spun the second about the first's anchor. **A tag was eighty percent opaque.** A tank's tag sits directly under its bottom port, so the run leaving that port passes behind the text -- and showed through it. --- .../components/pid/nodes/DraggableLabel.tsx | 5 ++++- .../src/components/pid/nodes/EngineNode.tsx | 13 ++++++++---- .../src/components/pid/nodes/PRNode.tsx | 4 ++-- .../src/components/pid/nodes/QDNode.tsx | 2 +- .../src/components/pid/nodes/SensorNode.tsx | 2 +- .../src/components/pid/nodes/SupplyNode.tsx | 4 ++-- .../src/components/pid/nodes/TankNode.tsx | 4 ++-- .../src/components/pid/nodes/Upright.tsx | 21 ++++++++++++------- .../src/components/pid/nodes/ValveNode.tsx | 2 +- 9 files changed, 36 insertions(+), 21 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx b/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx index 0a968e66b..08ad41891 100644 --- a/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/DraggableLabel.tsx @@ -137,7 +137,10 @@ export function DraggableLabel({ nodeId, label, offset, defaultOffset }: Draggab style={{ cursor: 'default', color: dragging ? '#3b82f6' : '#cbd5e1', - background: dragging ? 'rgba(59,130,246,0.15)' : 'rgba(10,15,26,0.8)', + // Opaque, not 80%. A tank's tag sits directly under its bottom + // port, so the run leaving that port passes behind the text -- + // and at 80% it showed through the letters. + background: dragging ? 'rgba(59,130,246,0.15)' : 'var(--color-bg-primary)', outline: dragging ? '1px dashed #3b82f6' : 'none', }} > diff --git a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx index ce038ac0d..b55f87427 100644 --- a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx @@ -49,14 +49,19 @@ export function EngineNode({ id, data, selected }: NodeProps) { fill="#1e293b" stroke={stroke} strokeWidth={selected ? 2.5 : 1.5} /> - + {/* One wrapper each: the counter-rotation is about the text's own + anchor, so two texts sharing one would spin the second about the + first's position and fling it off the symbol. */} + INJ - {pc && ( + + {pc && ( + {pc.value}{pc.unit === '-' ? '' : pc.unit} - )} - + + )} ); diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index f2346013c..d8272681c 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -36,7 +36,7 @@ export function PRNode({ id, data, selected }: NodeProps) { fill="#1e293b" stroke={stroke} strokeWidth={selected ? 2.5 : 1.5} /> - + PR {domeLoaded && ( @@ -47,7 +47,7 @@ export function PRNode({ id, data, selected }: NodeProps) { {/* Beside the dome, not under it. Centred at x=30 it sat on the stem and directly under the dome port, so the line feeding the dome ran straight through the word. */} - + DOME diff --git a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx index 569a6f73b..9ee9c706c 100644 --- a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx @@ -46,7 +46,7 @@ export function QDNode({ id, data, selected }: NodeProps) { {hydraulic && ( - + HYD )} diff --git a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx index eec11e538..2540af728 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx @@ -41,7 +41,7 @@ export function SensorNode({ id, data, selected }: NodeProps) { stroke={stroke} strokeWidth={selected ? 2.5 : 1.5} /> - + {componentType} diff --git a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx index 915061d68..06a3f57c4 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx @@ -59,7 +59,7 @@ export function SupplyNode({ id, data, selected }: NodeProps) { {/* the vacuum jacket, which is what makes it a dewar and not a drum */} - + {species?.short ?? 'DEWAR'} @@ -99,7 +99,7 @@ export function SupplyNode({ id, data, selected }: NodeProps) { {/* the bottle: domed shoulder, straight body */} - + {species?.short ?? 'KB'} diff --git a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx index 2449acec5..db70f9080 100644 --- a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx @@ -86,7 +86,7 @@ export function TankNode({ id, data, selected }: NodeProps) { - + INJ - + {species?.short ?? 'TANK'} diff --git a/pid-designer/frontend/src/components/pid/nodes/Upright.tsx b/pid-designer/frontend/src/components/pid/nodes/Upright.tsx index bc0d7a2db..4c11a4be5 100644 --- a/pid-designer/frontend/src/components/pid/nodes/Upright.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/Upright.tsx @@ -4,16 +4,23 @@ import type { ReactNode } from 'react'; * Lettering inside a symbol that has been turned. * * Rotating a part is about pointing it somewhere. Its lettering should still - * read left to right afterwards, so this undoes the symbol's rotation about - * the same centre: an annotation swings round to stay beside the thing it - * labels, but never ends up sideways or upside down. + * read left to right afterwards — but it should also stay on the thing it + * labels, and those are two different requirements. + * + * So the counter-rotation is about the text's **own anchor**, not the symbol's + * centre. About the centre it cancelled the turn completely and snapped the + * text back to where it would have been unturned: a solenoid's `S` flew off + * its actuator and sat above the bowtie the moment the valve was rotated, + * with the actuator itself over on the right. About its own anchor the text + * stays exactly where the rotation carried it and only spins upright. */ -export function Upright({ rotation = 0, cx, cy, children }: { +export function Upright({ rotation = 0, x, y, children }: { rotation?: number; - cx: number; - cy: number; + /** The anchor the text is drawn at, in the symbol's own coordinates. */ + x: number; + y: number; children: ReactNode; }) { if (!rotation) return <>{children}; - return {children}; + return {children}; } diff --git a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx index e0219c19b..4c240f403 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx @@ -25,7 +25,7 @@ function BowtieWithActuator({ selected, actuatorLabel, failOpen, rotation }: { - + {actuatorLabel} From 499d462de91328a542b18156e5fc6d18b9b1c4b9 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Thu, 10 Sep 2026 09:26:13 -0700 Subject: [PATCH 34/57] Tell ReactFlow the ports moved when a symbol is turned My regression, and I shipped it by testing the wrong path. Making rotation turn a port's *facing* meant rotation now changes each handle's `position` prop. ReactFlow caches where a node's handles are and re-measures only when told to -- so rotating a regulator left every line attached to the side the port used to be on. Ports on top and bottom, connections still going out of the sides: worse than the hooks the turn work removed. `Frame` now refreshes the node's internals whenever its rotation changes. There rather than in each symbol: it is the one place that always knows a turn happened, and a symbol added later cannot forget. Why the tests missed it: every rotation case I wrote mounts a node that is *already* turned, and a fresh mount measures correctly. The broken path was turning one that already existed -- pressing R -- which no test and none of my checks exercised. Verified by hand through all four quarter-turns and back: 90 gives l=top r=bottom, 180 l=right r=left, 270 l=bottom r=top, 360 back to l=left r=right, and a tank over a turned regulator is one straight vertical line. --- .../src/components/pid/nodes/CheckValveNode.tsx | 2 +- .../src/components/pid/nodes/EngineNode.tsx | 2 +- .../frontend/src/components/pid/nodes/Frame.tsx | 16 ++++++++++++++-- .../src/components/pid/nodes/ManifoldNode.tsx | 2 +- .../frontend/src/components/pid/nodes/PRNode.tsx | 2 +- .../frontend/src/components/pid/nodes/QDNode.tsx | 2 +- .../frontend/src/components/pid/nodes/RVNode.tsx | 2 +- .../src/components/pid/nodes/SensorNode.tsx | 2 +- .../src/components/pid/nodes/SupplyNode.tsx | 4 ++-- .../src/components/pid/nodes/TankNode.tsx | 4 ++-- .../src/components/pid/nodes/ValveNode.tsx | 2 +- 11 files changed, 26 insertions(+), 14 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx index 49f9a4382..93a056901 100644 --- a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx @@ -13,7 +13,7 @@ export function CheckValveNode({ id, data, selected }: NodeProps) { // The box once turned, so the tag sits under what is drawn. const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( -
{extra} diff --git a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx index bfcf2834d..8804356b4 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ManifoldNode.tsx @@ -108,7 +108,7 @@ export function ManifoldNode({ id, data, selected }: NodeProps) { return ( {ports} diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index d8272681c..39884a125 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -23,7 +23,7 @@ export function PRNode({ id, data, selected }: NodeProps) { // The box once turned, so the tag sits under what is drawn. const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( - + {domeLoaded && } diff --git a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx index 9ee9c706c..729068886 100644 --- a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx @@ -24,7 +24,7 @@ export function QDNode({ id, data, selected }: NodeProps) { // The box once turned, so the tag sits under what is drawn. const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( - + diff --git a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx index fa12e69a6..0d4e329f2 100644 --- a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx @@ -25,7 +25,7 @@ export function RVNode({ id, data, selected }: NodeProps) { // The box once turned, so the tag sits under what is drawn. const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( - + {(set || reseat) && ( diff --git a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx index 2540af728..2a11184a1 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SensorNode.tsx @@ -27,7 +27,7 @@ export function SensorNode({ id, data, selected }: NodeProps) { const stroke = selected ? '#3b82f6' : (color ?? '#94a3b8'); return ( - + {tapped && } } diff --git a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx index 06a3f57c4..6adae168f 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx @@ -42,7 +42,7 @@ export function SupplyNode({ id, data, selected }: NodeProps) { if (componentType === 'DEWAR') { return ( @@ -73,7 +73,7 @@ export function SupplyNode({ id, data, selected }: NodeProps) { return ( diff --git a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx index db70f9080..eb80ebcd4 100644 --- a/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/TankNode.tsx @@ -75,7 +75,7 @@ export function TankNode({ id, data, selected }: NodeProps) { if (isInjector) { return ( @@ -100,7 +100,7 @@ export function TankNode({ id, data, selected }: NodeProps) { return ( {endPorts(Number(options?.portsTop ?? 1), 't', Position.Top, TANK_W, TANK_H, data as unknown as PIDNodeData, id, rotation ?? 0)} {endPorts(Number(options?.portsBottom ?? 1), 'b', Position.Bottom, TANK_W, TANK_H, data as unknown as PIDNodeData, id, rotation ?? 0)} diff --git a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx index 4c240f403..bcaed5411 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx @@ -57,7 +57,7 @@ export function ValveNode({ id, data, selected }: NodeProps) { const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( From c6d3cc3eaff339ffb16c849a103abec17e8a7663 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Thu, 10 Sep 2026 10:09:44 -0700 Subject: [PATCH 35/57] Four on a turned rotary valve: the vent, the P, the NC, and QD ports **The stray arrow was the vent mark.** A valve open on one side gets an open-to-atmosphere arrow, and it was placed by "valve ports sit at the left and right edges, vertically centred" -- which stopped being true the moment rotation started moving ports. On a turned valve it stuck out of a side with no port on it, a mark for a vent belonging to nothing. It now comes off the port that is actually open, pointing the way that port points. **The actuator letter sat on the box's bottom edge.** Its baseline was at the foot of the actuator rect rather than its middle, which reads low unturned and -- since lettering now spins about its own anchor -- put the letter off the box once turned. Anchored and centred on the rect. **NO/NC was drawn over the bowtie.** The hourglass reaches all four corners, so a marker in one of them is always crossed. It has its own row now, with the tag on the next one. And because a turned valve's ports are top and bottom, "underneath" is where its outlet is -- vent arrow included. A turned valve puts its marker and its tag off to the side instead, which is the side with no ports on it. **A quick disconnect has one port on each side, not four.** It is inline hardware: a half on each end of a break in one run. Four ports invited a line into the top of something that physically has two ends, and pointing the pair somewhere else is what the R key is for. A junction keeps its four, because a tee does branch in four directions. --- .../frontend/src/components/pid/VentLayer.tsx | 49 +++++++++++++------ .../src/components/pid/nodes/QDNode.tsx | 6 +-- .../src/components/pid/nodes/ValveNode.tsx | 36 +++++++++++--- .../frontend/src/components/pid/ports.test.ts | 16 ++++++ .../frontend/src/components/pid/ports.ts | 9 +++- 5 files changed, 89 insertions(+), 27 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/VentLayer.tsx b/pid-designer/frontend/src/components/pid/VentLayer.tsx index 68db87831..b7da4fff6 100644 --- a/pid-designer/frontend/src/components/pid/VentLayer.tsx +++ b/pid-designer/frontend/src/components/pid/VentLayer.tsx @@ -1,4 +1,5 @@ -import { ViewportPortal, type Edge, type Node } from '@xyflow/react'; +import { Position, ViewportPortal, type Edge, type Node } from '@xyflow/react'; +import { turnPlacement } from './route'; import { findVents } from './vents'; /** @@ -18,14 +19,26 @@ export function VentLayer({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) { const marks = vents.flatMap(v => { const n = nodes.find(x => x.id === v.nodeId); if (!n || n.hidden) return []; - const w = n.measured?.width ?? 60; - const h = n.measured?.height ?? 60; - // Valve ports sit at the left and right edges, vertically centred. - const right = v.handle === 'r'; - const x = n.position.x + (right ? w : 0); - const y = n.position.y + h / 2; - const dir = right ? 1 : -1; - return [{ id: v.nodeId, x, y, dir }]; + // Off the port that is actually open, which means asking where rotation + // put it. This used to assume "left and right edges, vertically centred" + // and drew the arrow out of the side of a turned valve, where there is no + // port -- a mark for a vent that appeared to belong to nothing. + const rotation = (n.data as { rotation?: number })?.rotation ?? 0; + const quarter = Math.round(((rotation % 360) + 360) % 360 / 90) % 2 === 1; + const bw = n.measured?.width ?? 60; + const bh = n.measured?.height ?? 60; + // `measured` is the turned box; `turnPlacement` works in the unturned one. + const w = quarter ? bh : bw; + const h = quarter ? bw : bh; + const placed = turnPlacement( + v.handle === 'r' ? Position.Right : Position.Left, h / 2, w, h, rotation); + + let x = n.position.x, y = n.position.y, dx = 0, dy = 0; + if (placed.side === Position.Right) { x += bw; y += placed.along; dx = 1; } + else if (placed.side === Position.Left) { y += placed.along; dx = -1; } + else if (placed.side === Position.Bottom) { x += placed.along; y += bh; dy = 1; } + else { x += placed.along; dy = -1; } + return [{ id: v.nodeId, x, y, dx, dy }]; }); return ( @@ -34,12 +47,18 @@ export function VentLayer({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) { style={{ position: 'absolute', overflow: 'visible', pointerEvents: 'none', zIndex: 0 }} width={1} height={1} > - {marks.map(m => ( - - - - - ))} + {marks.map(m => { + // Along the way the port points, with the triangle opening across it. + const px = -m.dy, py = m.dx; + const sx = m.x + 14 * m.dx, sy = m.y + 14 * m.dy; + const tx = m.x + 24 * m.dx, ty = m.y + 24 * m.dy; + return ( + + + + + ); + })} ); diff --git a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx index 729068886..ad453fd65 100644 --- a/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/QDNode.tsx @@ -25,10 +25,8 @@ export function QDNode({ id, data, selected }: NodeProps) { const boxH = (rotation ?? 0) % 180 === 90 ? W : H; return ( - - - - + + } > diff --git a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx index bcaed5411..f3ed79a2f 100644 --- a/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/ValveNode.tsx @@ -25,8 +25,13 @@ function BowtieWithActuator({ selected, actuatorLabel, failOpen, rotation }: { - - {actuatorLabel} + {/* Anchored on the box's true centre, and centred on it in both + directions. It used to sit on a baseline at the box's bottom edge, + which reads as low unturned and lands off the box entirely once the + letter is spun about that anchor. */} + + {actuatorLabel} @@ -52,9 +57,21 @@ export function ValveNode({ id, data, selected }: NodeProps) { // drawing during a procedure review looks for -- so it belongs on the // symbol, not two clicks away inside a dialog. const failOpen = options?.failState === 'open'; - // The box the symbol occupies once turned, so the tag sits under what the - // reader actually sees rather than under where it would have been unturned. - const boxH = (rotation ?? 0) % 180 === 90 ? W : H; + // Where the writing goes, which is wherever the ports are not. + // + // A valve's ports are on its left and right, so unturned there is room + // underneath. Turn it and they are top and bottom -- and underneath is now + // where the outlet is, complete with the vent arrow if that side is open. + // So a turned valve carries its marker and its tag off to the side instead. + const quarter = (rotation ?? 0) % 180 === 90; + const boxW = quarter ? H : W; + const boxH = quarter ? W : H; + const markStyle: React.CSSProperties = quarter + ? { left: boxW + 4, top: 2 } + : { left: 0, top: boxH + 2 }; + const tagOffset = quarter + ? { x: boxW + 4, y: 14 } + : { x: -4, y: boxH + (componentType === 'MAN' ? 2 : 13) }; return ( {failOpen ? 'NO' : 'NC'} )} - + } > {componentType === 'MAN' diff --git a/pid-designer/frontend/src/components/pid/ports.test.ts b/pid-designer/frontend/src/components/pid/ports.test.ts index 643517be5..dde0064f7 100644 --- a/pid-designer/frontend/src/components/pid/ports.test.ts +++ b/pid-designer/frontend/src/components/pid/ports.test.ts @@ -139,3 +139,19 @@ describe('which ports are instrument tappings', () => { expect(taps.has('TK-1:t2')).toBe(true); }); }); + +describe('what a disconnect has', () => { + const node = (componentType: string): Node => + ({ id: 'x', position: { x: 0, y: 0 }, data: { componentType } }) as unknown as Node; + + it('has one port on each side, not four', () => { + // A disconnect is inline hardware: a half on each end of a break in one + // run. Four ports invited a line into the top of something that + // physically has two ends. + expect(portsOf(node('QD'))).toEqual(['l', 'r']); + }); + + it('leaves a junction with four, because a tee branches', () => { + expect(portsOf(node('JUNCTION'))).toEqual(['t', 'b', 'l', 'r']); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/ports.ts b/pid-designer/frontend/src/components/pid/ports.ts index b35833dd7..918a3c440 100644 --- a/pid-designer/frontend/src/components/pid/ports.ts +++ b/pid-designer/frontend/src/components/pid/ports.ts @@ -123,8 +123,15 @@ export function portsOf(node: Node): string[] { // were three invitations to draw a pipe through an instrument. case 'PT': case 'PG': return ['b']; - case 'QD': case 'JUNCTION': + // A junction is a tee, and a tee branches in four directions. + case 'JUNCTION': return ['t', 'b', 'l', 'r']; + // A disconnect is inline hardware -- a half on each end of a break in one + // run. Four ports invited a line into the top of something that physically + // has two ends, and rotating it to point the pair somewhere else is what + // the R key is for. + case 'QD': + return ['l', 'r']; case 'MAN': case 'ROT': case 'SOL': case 'RV': case 'CV': return ['l', 'r']; case 'PR': From 9892ad048608d6ee219d3e460404ff457b12d67d Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Thu, 10 Sep 2026 10:21:41 -0700 Subject: [PATCH 36/57] Dropping a symbol lines its ports up with the ones already drawn An engine under a rotary valve could not be lined up. Not "was fiddly" -- could not, at any position, by anybody. A valve is sixty wide so its centre port sits thirty from the node's origin; an engine is seventy-two, so its top port sits at thirty-six. Origins snap to ten, so the gap between those two ports is always a multiple of ten minus six. Measured on the running app before the fix: valve port at x=748, engine port at x=744. The tempting fix is to make every symbol's width a multiple of twenty so every centre port lands on one lattice. That works for centre ports and fails again for a tank with three outlets, or a manifold whose ports were dragged round its perimeter by hand -- the general case is symbols whose ports are wherever the hardware puts them, and no lattice covers that. So the drawing does it instead. On release, if a port of the symbol you moved is nearly in line with one already there, the symbol moves the last few pixels so that it is. Six pixels of tolerance, which is arithmetic rather than taste: both origins are on the ten grid, so from the nearest square any pair of ports is at most five apart, and six covers every pair there can be while leaving a deliberate one-square offset alone. Read off ReactFlow's own measured handle bounds rather than a table of where each symbol keeps its ports -- it already knows, it stays right when a symbol is turned or its port count changes, and a second copy of that geometry is a second thing to get wrong. The whole selection takes one shift, so a group does not come apart to satisfy each member, and probes clipped to something that moved go with it. A symbol can line up vertically with one neighbour and horizontally with another, which is what a real bay looks like. Verified by dragging: the engine moved 190 -> 194, putting its top port on 230 with the valve's, and the run between them is one segment -- `M 230,303 L 230,387`. Note it is now off the ten grid, which is the right way round: the grid serves alignment, not the other way about. --- .../src/components/pid/PIDDesigner.tsx | 68 ++++++++++++++- .../frontend/src/components/pid/snap.test.ts | 78 ++++++++++++++++++ .../frontend/src/components/pid/snap.ts | 82 +++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 pid-designer/frontend/src/components/pid/snap.test.ts create mode 100644 pid-designer/frontend/src/components/pid/snap.ts diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index 6a4620665..144d1e966 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -51,6 +51,8 @@ import { DEFAULT_PAGE, applyPage, listPages, moveToPage, pageOf } from './pages' import { clearOfHost, dragAttached, isInstrument, isTapped, targetAt } from './attach'; import { rejoinAfterDelete, splitEdgeAt } from './splitEdge'; import { drawnLines, lineAt } from './lineHit'; +import { alignmentShift } from './snap'; +import type { PortPositions } from './snap'; import { COMPONENT_SPECS } from './spec'; export type InteractionMode = 'pan' | 'select'; @@ -232,7 +234,7 @@ function PIDCanvas({ // object, so the dialog reads live data and a save is never applied to a // stale copy. const [configFor, setConfigFor] = useState<{ kind: 'node' | 'edge'; id: string } | null>(null); - const { screenToFlowPosition, setCenter, getZoom, fitView, setViewport } = useReactFlow(); + const { screenToFlowPosition, setCenter, getZoom, fitView, setViewport, getInternalNode } = useReactFlow(); const { undo, redo } = useHistory(nodes, edges, setNodes, setEdges); @@ -817,6 +819,69 @@ function PIDCanvas({ if (rejoined.length) setEdges(eds => [...eds, ...rejoined]); }, [setEdges]); + /** + * Dropping a symbol lines its ports up with what is already there. + * + * The grid cannot do this and never could: a valve is sixty wide so its + * centre port is thirty from the origin, an engine is seventy-two so its top + * port is at thirty-six, and both origins snap to ten -- so those two ports + * were six apart at every position either could be put in. See `snap.ts`. + * + * Read off ReactFlow's own measured handle bounds rather than a table of + * where each symbol keeps its ports: it already knows, it stays right when a + * symbol is turned or its port count changes, and a second copy of that + * geometry is a second thing to get wrong. + */ + const onNodeDragStop = useCallback(( + _e: MouseEvent | TouchEvent, node: Node, dragged: Node[], + ) => { + if (readOnlyRef.current) return; + const portsOf = (n: Node): PortPositions | null => { + const handles = getInternalNode(n.id)?.internals.handleBounds?.source; + if (!handles?.length) return null; + return { + id: n.id, + xs: handles.map(h => n.position.x + h.x + h.width / 2), + ys: handles.map(h => n.position.y + h.y + h.height / 2), + }; + }; + + // Everything that moved, against everything that did not -- so a symbol + // never lines itself up with one it is being dragged alongside. + const moving = new Set((dragged.length ? dragged : [node]).map(n => n.id)); + const here = pageRef.current; + const mine = [...moving].map(id => snapshot.current.nodes.find(n => n.id === id)) + .filter((n): n is Node => !!n).map(portsOf).filter((p): p is PortPositions => !!p); + if (mine.length === 0) return; + + const others = snapshot.current.nodes + .filter(n => !moving.has(n.id) && pageOf(n.data as unknown as PIDNodeData) === here) + .map(portsOf).filter((p): p is PortPositions => !!p); + if (others.length === 0) return; + + // One shift for the whole selection, from whichever of its symbols is + // nearest an alignment. Shifting them individually would pull a group + // apart to satisfy each member. + const shift = mine + .map(m => alignmentShift(m, others)) + .reduce((best, s) => ({ + dx: best.dx || s.dx, + dy: best.dy || s.dy, + }), { dx: 0, dy: 0 }); + if (!shift.dx && !shift.dy) return; + + setNodes(nds => { + let next = nds.map(n => moving.has(n.id) + ? { ...n, position: { x: n.position.x + shift.dx, y: n.position.y + shift.dy } } + : n); + // Probes clipped to something that moved go with it, exactly as they do + // during the drag itself. + const delta = { x: shift.dx, y: shift.dy }; + for (const id of moving) next = dragAttached(next, id, delta); + return next; + }); + }, [getInternalNode, setNodes]); + const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => { if (paintIfArmed('node', node.id)) { e.stopPropagation(); e.preventDefault(); } }, [paintIfArmed]); @@ -922,6 +987,7 @@ function PIDCanvas({ onEdgeContextMenu={onEdgeContextMenu} onNodeContextMenu={onNodeContextMenu} onNodeClick={onNodeClick} + onNodeDragStop={onNodeDragStop} onDelete={onDelete} onEdgeClick={onEdgeClick} onNodeDoubleClick={onNodeDoubleClick} diff --git a/pid-designer/frontend/src/components/pid/snap.test.ts b/pid-designer/frontend/src/components/pid/snap.test.ts new file mode 100644 index 000000000..962c084b2 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/snap.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { alignmentShift, SNAP_TOLERANCE } from './snap'; + +const at = (id: string, xs: number[], ys: number[]) => ({ id, xs, ys }); + +describe('dropping a symbol lines its ports up', () => { + it('closes the gap the grid cannot', () => { + // The reported case, in numbers. A rotary valve is 60 wide so its centre + // port is 30 from the origin; an engine is 72, so its top port is at 36. + // Both origins snap to 10, so the ports are always 6 out — at every + // position, forever. + const valve = at('ROT', [100 + 30], [200]); + const engine = at('ENG', [100 + 36], [400]); + const { dx } = alignmentShift(engine, [valve]); + expect(dx).toBe(-6); + expect(engine.xs[0] + dx).toBe(valve.xs[0]); + }); + + it('leaves a symbol alone when its ports already line up', () => { + const a = at('a', [130], [200]); + const b = at('b', [130], [400]); + expect(alignmentShift(b, [a])).toEqual({ dx: 0, dy: 0 }); + }); + + it('will not drag something across a deliberate offset', () => { + // One grid square across is a decision, not a near miss. + const a = at('a', [130], [200]); + const b = at('b', [140], [400]); + expect(alignmentShift(b, [a]).dx).toBe(0); + }); + + it('reaches every pair there can be, from the nearest square', () => { + // Both origins sit on the ten grid, so whatever two symbols' port offsets + // are, the nearest square leaves them at most five apart. The tolerance + // has to cover five and stop short of ten. + for (let residual = 0; residual <= 5; residual++) { + const { dx } = alignmentShift(at('m', [100 + residual], [0]), [at('o', [100], [0])]); + expect(Math.abs(dx), `residual ${residual}`).toBe(residual); + } + expect(alignmentShift(at('m', [110], [0]), [at('o', [100], [0])]).dx).toBe(0); + }); + + it('takes the nearest alignment, not the first one found', () => { + const far = at('far', [137], [0]); + const near = at('near', [132], [0]); + const moved = at('m', [134], [0]); + expect(alignmentShift(moved, [far, near]).dx).toBe(-2); + }); + + it('decides each axis on its own', () => { + // Lined up vertically with one neighbour, horizontally with another -- + // which is what a real bay looks like. + const above = at('above', [133], [500]); + const beside = at('beside', [900], [297]); + const moved = at('m', [130], [300]); + expect(alignmentShift(moved, [above, beside])).toEqual({ dx: 3, dy: -3 }); + }); + + it('considers every port, not just the first', () => { + // A tank's second outlet is what lines up, not its first. + const target = at('t', [220], [0]); + const tank = at('tank', [180, 200, 223], [0]); + expect(alignmentShift(tank, [target]).dx).toBe(-3); + }); + + it('does nothing when there is nothing to line up with', () => { + expect(alignmentShift(at('m', [130], [300]), [])).toEqual({ dx: 0, dy: 0 }); + }); + + it('never moves further than the tolerance', () => { + const moved = at('m', [100], [100]); + for (let gap = 0; gap <= 30; gap++) { + const { dx } = alignmentShift(moved, [at('o', [100 + gap], [0])]); + expect(Math.abs(dx)).toBeLessThan(SNAP_TOLERANCE + 1); + expect(Math.abs(dx)).toBe(gap <= SNAP_TOLERANCE ? gap : 0); + } + }); +}); diff --git a/pid-designer/frontend/src/components/pid/snap.ts b/pid-designer/frontend/src/components/pid/snap.ts new file mode 100644 index 000000000..6fe2b55b3 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/snap.ts @@ -0,0 +1,82 @@ +/** + * Dropping a symbol lines its ports up with the ones already on the drawing. + * + * The grid is not enough, and cannot be. A valve is sixty wide so its centre + * port sits thirty from the node's origin; an engine is seventy-two, so its + * top port sits at thirty-six. Node positions snap to ten, so the gap between + * those two ports is always a multiple of ten minus six — an engine under a + * rotary valve could not be lined up at all, at any position, by anybody. + * + * The obvious answer is to make every symbol's width a multiple of twenty so + * every centre port lands on the same lattice. That works for centre ports and + * then fails again for a tank with three outlets, or a manifold whose ports + * were dragged round its perimeter by hand — the general case is symbols whose + * ports are wherever the hardware puts them, and no lattice fixes that. + * + * So: on release, if a port of the symbol you moved is nearly in line with a + * port already on the drawing, move the symbol the last few pixels so that it + * *is*. Alignment stops being arithmetic the reader has to do and becomes + * something the drawing does. + * + * Deliberately on release rather than during the drag. Nudging a symbol under + * the cursor while it is still moving fights the hand holding it. + */ + +/** Where one symbol's ports are, in flow coordinates. */ +export interface PortPositions { + id: string; + xs: number[]; + ys: number[]; +} + +/** + * How far a symbol may be moved to bring a port into line. + * + * Six, which is chosen rather than picked. Two ports are as far apart as the + * difference in their offsets from their symbols' origins, and both origins + * sit on the ten grid — so from the nearest grid square any pair is at most + * five out, and six covers every pair there can be. The valve-and-engine case + * is exactly six. + * + * Below the grid on purpose. A symbol put one square across from where it + * would align is a decision, and it stays where it was put. + */ +export const SNAP_TOLERANCE = 6; + +/** + * The shift that lines `moved` up with anything in `others`, or zeros. + * + * Each axis is decided on its own — a symbol can line up vertically with one + * neighbour and horizontally with a different one, which is what happens in + * any real bay. The smallest shift wins, so the nearest alignment is the one + * taken rather than whichever port happened to be looked at first. + */ +export function alignmentShift( + moved: PortPositions, + others: PortPositions[], + tolerance = SNAP_TOLERANCE, +): { dx: number; dy: number } { + return { + dx: bestShift(moved.xs, others.flatMap(o => o.xs), tolerance), + dy: bestShift(moved.ys, others.flatMap(o => o.ys), tolerance), + }; +} + +function bestShift(mine: number[], theirs: number[], tolerance: number): number { + let best = 0; + let bestGap = Infinity; + for (const a of mine) { + for (const b of theirs) { + const gap = Math.abs(b - a); + // Inclusive: the tolerance is the largest gap worth closing, so a pair + // exactly that far apart is the case it was sized for, not the first + // case it turns down. + if (gap > tolerance) continue; + if (gap < bestGap) { + bestGap = gap; + best = b - a; + } + } + } + return best; +} From a1e7116743204a487244205eeb35715bc7e97aa8 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Fri, 11 Sep 2026 00:26:19 -0700 Subject: [PATCH 37/57] Rebuild the fittings panel around what somebody is trying to say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, in the order a fabricator says them: what tube it is, how long it is, what is in it. The panel asked for none of those first. What using it was actually like, before. Open a line: nine numeric fields, of which four feed a wall-thermal model that is off unless a drawing asks for it, sitting between the two anybody came for. Scroll -- the dialog was 565px of content in a 432px box -- to find "SEGMENTS", a word for a thing nobody sets out to create. Click "+ add segment"; it lands below the fold, so the button appears to do nothing. Then a row of seven unlabelled controls, then a five-way "loss from" selector before you may name a fitting. Then, to enter three elbows: click "+ add fitting", type "elb", click the result, click ×3, click ×3 again. Now: one button, then a size, then three clicks. **Size first, bore second-hand.** `1/2 x 0.049` is what a fabricator knows; the bore is arithmetic off it. So the size is a picker and the bore is *stated back* -- "bore 10.21 mm" -- with a way in for when the catalogue cannot answer. A number nobody types is a number nobody mistypes. **Length in the units it was measured in.** It was metres only, in an app where every other length has a unit beside it, and the *basis* -- tube or overall -- was dressed as the unit ("m tube" / "m overall") to save a control. Two ideas in one select is one too many. Real unit dropdown, and the basis is a two-state toggle that says "tube only" / "end to end". **Fittings by clicking.** The five a stand is built from are buttons, so three elbows is three clicks and no typing; clicking one that is already there increments it. The other ten stay behind "more", searchable. **A line that says what it came to.** "1.219 m of tube · 4 fittings", and the cut length when that is answerable -- under the controls that produced it, so the panel answers the question it just asked. **The loss method got out of the way.** Five options of which "itemised" is the default and right nearly always, on every segment. Behind "measured instead?" now. **"Segment" is never the word.** You add another *size*, which is the only reason a run is described in more than one piece -- and numbering appears only once there are two. Beside it, in the dialog: - The four thermal params fold away behind "4 more (wall, roughness, head)". The dialog now fits without scrolling. - `fitting_count` is no longer a number to type. The fitting list already says it, and two ways to say one thing is two ways to disagree -- so it is counted on save, with the reference saying so. It stays declared in `spec.ts` because the solver reads it and `test_spec_parity` is right to insist; `ParamSpec.derived` is the new third rank for exactly this. One real bug, found by using it: changing the unit on an empty field wrote `{ value: 0, unit }` to keep the select honest -- putting a zero bore on a line nobody had measured. A zero here reads as "no hole", not "not said". The unit choice is remembered without asserting a value. --- .../src/components/pid/ConfigDialog.tsx | 38 +- .../src/components/pid/SegmentPanel.tsx | 439 ++++++++++++------ .../frontend/src/components/pid/params.ts | 6 +- .../frontend/src/components/pid/spec.ts | 65 ++- pid-designer/frontend/src/lib/gating.test.ts | 4 + 5 files changed, 404 insertions(+), 148 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index 92411761a..bd0161dac 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -123,6 +123,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav // than on save so the number is visible and can be argued with -- and only // over an empty box or over the *previous* fluid's default, so a temperature // somebody typed is never taken away from them. + const [showAdvanced, setShowAdvanced] = useState(false); const lastAutoTemp = useRef(null); useEffect(() => { if (!open || !spec?.fluids) return; @@ -143,11 +144,21 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav const save = () => { const params: Record = {}; for (const p of spec.params) { + if (p.derived) continue; // computed below, never typed const d = drafts[p.key]; if (!d || d.value.trim() === '') continue; // absent, not zero const value = Number(d.value); if (Number.isFinite(value)) params[p.key] = { value, unit: d.unit, source: d.source }; } + // Counted, not asked for. Only when there is a list to count: with no + // segments the drawing has not said, and a zero would be a claim. + if (kind === 'edge' && segments.length) { + const n = segments.reduce((sum, s) => sum + fittingCount(s), 0); + params.fitting_count = { + value: n, unit: '-', source: 'default', + reference: 'counted from the fittings on this run', + }; + } const keptPorts: Record = {}; for (const [id, info] of Object.entries(ports)) { const name = info.label?.trim(); @@ -270,7 +281,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav Superseded by the segments below.

)} - {spec.params.map(p => ( + {spec.params.filter(p => !p.advanced && !p.derived).map(p => ( setDrafts(d => ({ ...d, [p.key]: { ...d[p.key], ...patch } }))} /> ))} + {/* The rest are real and are not why anyone opened this. A + hardline's wall thickness and fitting mass feed a thermal model + that is off unless a drawing asks for it; flat alongside length + and bore they read as four more things you were supposed to + know. See `ParamSpec.advanced`. */} + {spec.params.some(p => p.advanced) && ( + <> + + {showAdvanced && spec.params.filter(p => p.advanced && !p.derived).map(p => ( + setDrafts(d => ({ ...d, [p.key]: { ...d[p.key], ...patch } }))} + /> + ))} + + )}
)} diff --git a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx index b8dd88ddb..6c584d1b7 100644 --- a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx +++ b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx @@ -7,26 +7,72 @@ import { import type { FittingRow, LineSegment, LossMethod } from './segments'; import { STANDARDS, TUBE_SIZES, DASH_SIZES, NPT_SIZES, cutLength, loadCatalog, suggestBore, tubeOdForSize } from './catalog'; import type { Standard } from './catalog'; +import { UNITS } from './params'; import type { ParamValue } from './params'; /** - * What a line is made of, and how its loss is known. + * What a line is made of. * - * The method selector is the point of this panel. A feed system's resistance is - * known differently at different stages -- itemised while it is being designed, - * measured once it has been flowed -- and both have to be first-class with no - * ambiguity about which is in force. Choosing one hides the others' fields - * rather than leaving a pile of inputs where the precedence is a guess. + * Rebuilt around the three things somebody actually wants to say about a run, + * in the order they want to say them: **what tube it is, how long it is, and + * what is in it.** The version before this asked for a "segment" first, which + * is a word for a thing nobody sets out to create, then offered a row of seven + * unlabelled controls, then asked how the loss was known before letting anyone + * name a fitting -- and putting three elbows in took five interactions and a + * search box. * - * Flat: a segment is a row and its fittings are rows under it. No accordions. + * The decisions behind the layout: + * + * **Size first, bore second-hand.** Picking `1/2 x 0.049` is the thing a + * fabricator knows; the bore is arithmetic off it. So the size is a picker and + * the bore is *stated back* rather than asked for, with a way in for the case + * the catalogue cannot answer. A number nobody has to type is a number nobody + * can mistype. + * + * **Length in the units it was measured in.** It used to be metres only, in an + * app where every other length has a unit next to it, with the *basis* -- tube + * or overall -- dressed up as the unit to save a control. Two ideas in one + * select is one too many; they are separate now. + * + * **Fittings by clicking.** The five a stand is actually built from are + * buttons, so three elbows is three clicks and no typing. The full list is + * still there behind "more", searchable, for the other ten. + * + * **One line that says what it all came to.** Length, count and the K that is + * knowable here, under the controls that produced them, so the panel answers + * the question it just asked you. + * + * **The loss method got out of the way.** Five options, of which "itemised" is + * the default and is right nearly always, on every segment. It is behind a + * disclosure now; opening it is how you say you measured the line instead. */ const field = 'rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] px-2 py-1 text-xs text-[var(--color-text-primary)] outline-none focus:border-[var(--color-accent)]'; const muted = 'text-[10px] text-[var(--color-text-muted)]'; +const chip = + 'rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)] disabled:opacity-40'; + +/** + * The fittings a stand is mostly built from, in the order they come up. + * + * Buttons rather than a search box. Fifteen kinds is too many to list and far + * too few to need searching -- so the handful that appear on every run are one + * click, and the rest stay one click further away. + */ +const COMMON: readonly (typeof FITTING_KINDS)[number][] = [ + 'elbow_90', 'tee_run', 'tee_branch', 'ball_valve_full', 'elbow_45', +]; const numOf = (p?: ParamValue) => (p === undefined ? '' : String(p.value)); +/** Millimetres, for the summary line, whatever it was entered in. */ +const TO_MM: Record = { + mm: 1, cm: 10, m: 1000, in: 25.4, ft: 304.8, +}; +const mmOf = (p?: ParamValue) => + p === undefined ? null : p.value * (TO_MM[p.unit] ?? 1); + export function SegmentPanel({ segments, onChange }: { segments: LineSegment[]; onChange: (next: LineSegment[]) => void; @@ -34,6 +80,20 @@ export function SegmentPanel({ segments, onChange }: { const readOnly = useReadOnly(); const catalog = useMemo(() => loadCatalog(), []); const transitions = useMemo(() => transitionsOf(segments), [segments]); + const [showMethod, setShowMethod] = useState(false); + /** + * The unit chosen for a field nobody has typed a number into yet. + * + * Kept here rather than on the segment because a unit with no value is not + * something the document can hold: `ParamValue` is a value *and* a unit. The + * first version wrote `{ value: 0, unit }` to keep the select honest, which + * put a zero bore on a line nobody had measured -- and a zero here reads as + * "no hole", not as "not said". Absent means absent everywhere else in this + * app and it means absent here too. + */ + const [pending, setPending] = useState>({}); + const unitOf = (seg: LineSegment, key: 'bore' | 'length', fallback: string) => + seg[key]?.unit ?? pending[`${seg.id}:${key}`] ?? fallback; const patch = (i: number, next: Partial) => onChange(segments.map((s, j) => (j === i ? { ...s, ...next } : s))); @@ -47,6 +107,14 @@ export function SegmentPanel({ segments, onChange }: { } }; + const setUnit = (i: number, key: 'bore' | 'length', unit: string) => { + const seg = segments[i]; + setPending(u => ({ ...u, [`${seg.id}:${key}`]: unit })); + // Only a field that already has a number gets rewritten. An empty one just + // remembers the choice, and uses it when a number arrives. + if (seg[key]) patch(i, { [key]: { ...seg[key]!, unit } } as Partial); + }; + const addSegment = () => onChange([...segments, { id: nextSegmentId(), method: 'itemised', fittings: [], standard: 'tube' }]); @@ -60,13 +128,33 @@ export function SegmentPanel({ segments, onChange }: { }); }; + // Nothing said yet: one button, and no vocabulary to learn first. + if (segments.length === 0) { + return ( +
+ +

+ Optional. Without it the line uses the length and bore above. +

+
+ ); + } + + const many = segments.length > 1; + return (
- Segments - {segments.length === 0 && ( - optional — the line uses its own bore and length - )} + + {many ? 'The run, by size' : 'Tube and fittings'} + +
{segments.map((seg, i) => { @@ -78,86 +166,137 @@ export function SegmentPanel({ segments, onChange }: { : DASH_SIZES.map(d => `-${d}`); const boreKnown = seg.bore !== undefined; const od = tubeOdForSize(standard, (seg.tubeSize ?? '').replace(/^-/, '')); + const byHand = boreKnown && seg.bore?.source !== 'default'; return (
-
- {/* size · length · bore */} +
+ {/* ── What it is ─────────────────────────────────────────── */}
- {i + 1} + {many && ( + {i + 1} + )} - setNum(i, 'length', e.target.value, 'm')} - className={`${field} w-[62px] shrink-0`} /> - - setNum(i, 'bore', e.target.value, 'mm')} - className={`${field} w-[62px] shrink-0`} - title="Flow diameter — never the thread size" /> - mm + + {/* The bore is arithmetic off the size, so it is told to you + rather than asked of you -- until the catalogue cannot + answer, or somebody means something else. */} + {!byHand && boreKnown && ( + + bore + {seg.bore!.value.toFixed(2)} + mm + + )} + {!byHand && boreKnown && !readOnly && ( + + )} + {(byHand || !boreKnown) && ( + <> + bore + setNum(i, 'bore', e.target.value, unitOf(seg, 'bore', 'mm'))} + className={`${field} w-[56px] shrink-0`} + title="Flow diameter — never the thread size" /> + + + )} + + title={many ? 'Remove this size' : 'Remove'}>×
- {/* Why the bore says what it says, and the trap when it says nothing. */} - {seg.tubeSize && ( -

- {boreKnown - ? seg.bore?.reference ?? 'bore set by hand' - : od - ? `no catalogue bore for ${standard} ${seg.tubeSize} — type it. Thread size is not flow diameter.` - : 'type the bore'} + {/* The trap: a thread size is not a flow diameter. */} + {seg.tubeSize && !boreKnown && ( +

+ {od + ? `No catalogue bore for ${standard} ${seg.tubeSize}. Type the flow diameter — the thread size is not it.` + : 'Type the flow diameter.'}

)} - {/* How the loss is known */} -
- loss from - setNum(i, 'length', e.target.value, unitOf(seg, 'length', 'm'))} + className={`${field} w-[64px] shrink-0`} /> + - {LOSS_METHODS.find(m => m.id === method)?.note} + {/* Two states, named. This used to masquerade as the unit + ("m tube" / "m overall"), which hid a real distinction + inside a control nobody reads twice. */} + + {(['tube', 'overall'] as const).map(basis => ( + + ))} +
+ {/* ── How the loss is known, when it is not the fittings ── */} + {showMethod && ( +
+ Loss + + {LOSS_METHODS.find(m => m.id === method)?.note} +
+ )} + {(method === 'measured_K' || method === 'lumped_K') && ( -
- K +
+ K setNum(i, 'K', e.target.value, '-')} - className={`${field} w-[72px]`} /> + className={`${field} w-[64px]`} /> {method === 'measured_K' && ( - supersedes any fittings below + supersedes the fittings )}
)} {method === 'curve' && ( -

+

Δp against ṁ, entered in feed-twin against the run that produced it.

)} {method === 'itemised' && ( - )} - +
{transitions[i] && ( -

+

↓ {transitions[i]!.kind === 'contraction' ? 'reducer' : 'expander'}{' '} {transitions[i]!.fromMm.toFixed(2)} → {transitions[i]!.toMm.toFixed(2)} mm @@ -181,42 +320,59 @@ export function SegmentPanel({ segments, onChange }: { ); })} + {/* "Segment" is never the word. You add another *size*, which is the + only reason a run is ever described in more than one piece. */}

); } /** - * The straight tube to cut, when the run was measured end to end. + * What it all came to, under the controls that produced it. * - * Shown only when it can be answered: every fitting needs a length, because a - * partial subtraction is a mis-cut part rather than an approximate one. + * The panel asked for a size, a length and a list; this is it answering. The + * cut length only appears when it can be answered -- every fitting needs a + * body length, because a partial subtraction is a mis-cut part rather than an + * approximate one. */ -function CutList({ seg }: { seg: LineSegment }) { - if (seg.lengthBasis !== 'overall' || !seg.length) return null; - const overallMm = seg.length.value * (seg.length.unit === 'm' ? 1000 : 1); - const flat = (seg.fittings ?? []).flatMap(r => Array.from({ length: r.count }, () => r)); - const cut = cutLength(overallMm, flat); +function Summary({ seg }: { seg: LineSegment }) { + const bits: string[] = []; + const lenMm = mmOf(seg.length); + if (lenMm !== null) { + bits.push(`${(lenMm / 1000).toFixed(3)} m ${seg.lengthBasis === 'overall' ? 'end to end' : 'of tube'}`); + } + const n = fittingCount(seg); + if (n) bits.push(`${n} fitting${n === 1 ? '' : 's'}`); + const k = knownK(seg); + if (k) bits.push(`K ${k.toFixed(2)}`); + + if (seg.lengthBasis === 'overall' && lenMm !== null) { + const flat = (seg.fittings ?? []).flatMap(r => Array.from({ length: r.count }, () => r)); + const cut = cutLength(lenMm, flat); + bits.push(cut === null + ? 'cut length needs a body length on every fitting' + : `cut ${(cut / 1000).toFixed(3)} m`); + } + + if (bits.length === 0) return null; return ( -

- {cut === null - ? 'cut length needs a body length on every fitting' - : `cut ${(cut / 1000).toFixed(3)} m of tube · fittings occupy ${((overallMm - cut) / 1000).toFixed(3)} m`} +

+ {bits.join(' · ')}

); } -/** The fittings in a segment: ordered rows, each with a count. */ -function FittingRows({ rows, readOnly, segmentBore, onChange }: { +/** The fittings in a run: a chip each, with the common ones one click away. */ +function Fittings({ rows, readOnly, segmentBore, onChange }: { rows: FittingRow[]; readOnly: boolean; segmentBore?: number; onChange: (rows: FittingRow[]) => void; }) { - const [adding, setAdding] = useState(false); + const [more, setMore] = useState(false); const [query, setQuery] = useState(''); const [openRow, setOpenRow] = useState(null); @@ -226,12 +382,11 @@ function FittingRows({ rows, readOnly, segmentBore, onChange }: { const set = (id: string, patch: Partial) => onChange(rows.map(r => (r.id === id ? { ...r, ...patch } : r))); - const move = (i: number, by: number) => { - const j = i + by; - if (j < 0 || j >= rows.length) return; - const next = [...rows]; - [next[i], next[j]] = [next[j], next[i]]; - onChange(next); + /** One more of this kind, wherever it already is in the run. */ + const add = (kind: (typeof FITTING_KINDS)[number]) => { + const existing = rows.find(r => r.kind === kind); + if (existing) set(existing.id, { count: existing.count + 1 }); + else onChange([...rows, { id: nextRowId(), kind, count: 1 }]); }; const numField = (v: number | undefined, onSet: (n: number | undefined) => void, ph: string) => ( @@ -242,80 +397,76 @@ function FittingRows({ rows, readOnly, segmentBore, onChange }: { const t = e.target.value.trim(); onSet(t === '' ? undefined : Number.isFinite(Number(t)) ? Number(t) : undefined); }} - className={`${field} w-[62px]`} + className={`${field} w-[58px]`} /> ); return ( -
- {rows.map((r, i) => ( -
-
- - - - - - {FITTING_LABELS[r.kind]} - {r.boreMm !== undefined && r.boreMm !== segmentBore && ( - · {r.boreMm} mm - )} - {r.K !== undefined && · K {r.K}} - - - - -
+
+
+ Fittings + {rows.length === 0 && none yet} + {rows.map(r => ( + + {FITTING_LABELS[r.kind]} + ×{r.count} + + + + + ))} +
- {openRow === r.id && ( -
- bore - {numField(r.boreMm, v => set(r.id, { boreMm: v }), segmentBore ? String(segmentBore) : 'mm')} - body - {numField(r.lengthMm, v => set(r.id, { lengthMm: v }), 'mm')} - engages - {numField(r.engagementMm, v => set(r.id, { engagementMm: v }), 'mm')} - K - {numField(r.K, v => set(r.id, { K: v }), 'meas.')} -
- )} -
- ))} + {/* Three elbows is three clicks. */} +
+ {COMMON.map(k => ( + + ))} + +
- {adding ? ( - - setQuery(e.target.value)} - onBlur={() => window.setTimeout(() => { setAdding(false); setQuery(''); }, 120)} - className={`${field} w-[150px]`} /> - + {more && ( +
+ setQuery(e.target.value)} className={`${field} w-full`} /> +
{matches.map(k => ( - ))} - {matches.length === 0 && nothing matches} - - - ) : ( - + {matches.length === 0 && nothing matches} +
+
)} + + {rows.map(r => openRow === r.id && ( +
+ {FITTING_LABELS[r.kind]} + · bore + {numField(r.boreMm, v => set(r.id, { boreMm: v }), segmentBore ? String(segmentBore.toFixed(2)) : 'mm')} + mm · body + {numField(r.lengthMm, v => set(r.id, { lengthMm: v }), 'mm')} + mm · engages + {numField(r.engagementMm, v => set(r.id, { engagementMm: v }), 'mm')} + mm · K + {numField(r.K, v => set(r.id, { K: v }), 'measured')} +
+ ))}
); } diff --git a/pid-designer/frontend/src/components/pid/params.ts b/pid-designer/frontend/src/components/pid/params.ts index 7be55c303..293be1626 100644 --- a/pid-designer/frontend/src/components/pid/params.ts +++ b/pid-designer/frontend/src/components/pid/params.ts @@ -58,7 +58,8 @@ export interface ParamValue { export type Dimension = | 'pressure' | 'temperature' | 'length' | 'volume' - | 'flow_coefficient' | 'dimensionless' | 'time' | 'mass_flow' | 'angle'; + | 'flow_coefficient' | 'dimensionless' | 'time' | 'mass' | 'mass_flow' | 'angle' + | 'specific_heat' | 'thermal_conductance'; /** * Units offered per dimension, in the spelling `feedtwin.model.units` uses. @@ -72,8 +73,11 @@ export const UNITS: Record = { flow_coefficient: ['Cv', 'Kv'], dimensionless: ['-', '%'], time: ['s', 'ms', 'min'], + mass: ['g', 'kg', 'lbm'], mass_flow: ['kg/s', 'g/s', 'lbm/s'], angle: ['deg', 'rad'], + specific_heat: ['J/(kg.K)'], + thermal_conductance: ['W/K'], }; /** Pressures are absolute. Said in the UI, next to the field. */ diff --git a/pid-designer/frontend/src/components/pid/spec.ts b/pid-designer/frontend/src/components/pid/spec.ts index 03df8dbea..5da3fe9a4 100644 --- a/pid-designer/frontend/src/components/pid/spec.ts +++ b/pid-designer/frontend/src/components/pid/spec.ts @@ -21,6 +21,28 @@ export interface ParamSpec { dimension: Dimension; /** Starting value, in `unit`. */ suggested?: { value: number; unit: string }; + /** + * Real, and not what somebody opened this dialog for. + * + * A hardline carries nine numbers, of which two -- how long and how wide -- + * are why anyone is here; the rest feed a wall-thermal model that is off + * unless a drawing asks for it. Shown flat, the nine read as nine equally + * expected answers and the two that matter are somewhere in the middle. + * These fold away instead. + */ + advanced?: boolean; + /** + * Real, read by the solver, and never asked for -- because the drawing + * already says it somewhere better. + * + * `fitting_count` is the case this exists for. The line-wall model needs to + * know how many fittings' worth of metal is on a run, and the fitting list + * two panels down knows exactly. Asking for the number as well is the + * two-ways-to-say-one-thing problem in its purest form: the one somebody + * forgot to update is the one the solver would have believed. So it is + * computed on save and never rendered. + */ + derived?: boolean; } export interface OptionSpec { @@ -61,6 +83,15 @@ const P = (key: string, label: string, dimension: Dimension, suggested?: { value: number; unit: string }): ParamSpec => ({ key, label, dimension, suggested }); +/** The same, folded away until asked for. See `ParamSpec.advanced`. */ +const A = (key: string, label: string, dimension: Dimension, + suggested?: { value: number; unit: string }): ParamSpec => + ({ key, label, dimension, suggested, advanced: true }); + +/** Declared and fed, never asked for. See `ParamSpec.derived`. */ +const D = (key: string, label: string, dimension: Dimension): ParamSpec => + ({ key, label, dimension, derived: true }); + const ALL_FLUIDS: SpeciesId[] = ['oxygen', 'ethanol', 'nitrogen', 'helium', 'methane', 'other']; function valveSpec(): ComponentSpec { @@ -89,6 +120,12 @@ export const COMPONENT_SPECS: Partial> = { P('temperature', 'Temperature', 'temperature'), P('volume', 'Volume', 'volume'), P('MAWP', 'MAWP', 'pressure'), + // The vessel's own wall, which fights the gas cooling during a blowdown + // or a press. Left blank, feed-twin estimates all three from the volume + // and says so; a weighed vessel should declare them. + P('wall_mass', 'Wall mass', 'mass'), + P('wall_capacity', 'Wall specific heat', 'specific_heat', { value: 900, unit: 'J/(kg.K)' }), + P('wall_conductance', 'Gas-to-wall hA', 'thermal_conductance'), ], options: [ { key: 'portsTop', label: 'Top ports', default: '1', @@ -110,6 +147,12 @@ export const COMPONENT_SPECS: Partial> = { P('temperature', 'Temperature', 'temperature', { value: 293, unit: 'K' }), P('volume', 'Water volume', 'volume', { value: 49, unit: 'L' }), P('count', 'Bottles', 'dimensionless', { value: 1, unit: '-' }), + // The vessel's own wall, which fights the gas cooling during a blowdown + // or a press. Left blank, feed-twin estimates all three from the volume + // and says so; a weighed vessel should declare them. + P('wall_mass', 'Wall mass', 'mass'), + P('wall_capacity', 'Wall specific heat', 'specific_heat', { value: 500, unit: 'J/(kg.K)' }), + P('wall_conductance', 'Gas-to-wall hA', 'thermal_conductance'), ], }, @@ -119,6 +162,12 @@ export const COMPONENT_SPECS: Partial> = { P('pressure', 'Delivery pressure', 'pressure', { value: 35, unit: 'psi' }), P('temperature', 'Temperature', 'temperature'), P('volume', 'Capacity', 'volume'), + // The vessel's own wall, which fights the gas cooling during a blowdown + // or a press. Left blank, feed-twin estimates all three from the volume + // and says so; a weighed vessel should declare them. + P('wall_mass', 'Wall mass', 'mass'), + P('wall_capacity', 'Wall specific heat', 'specific_heat', { value: 500, unit: 'J/(kg.K)' }), + P('wall_conductance', 'Gas-to-wall hA', 'thermal_conductance'), ], }, @@ -306,8 +355,18 @@ export const LINE_SPECS: Record = { params: [ P('length', 'Length', 'length'), P('bore', 'Bore', 'length'), - P('roughness', 'Roughness', 'length', { value: 1.5e-3, unit: 'mm' }), P('K_minor', 'Lumped fitting K', 'dimensionless', { value: 0, unit: '-' }), + A('roughness', 'Roughness', 'length', { value: 1.5e-3, unit: 'mm' }), + // Static head. Ten metres of LOX is about 1.6 bar, so a tall stand that + // leaves this unset is wrong by more than most of its line losses. + A('elevation_change', 'Rise (outlet − inlet)', 'length', { value: 0, unit: 'm' }), + // Thermal mass. Both feed the line-wall model in feed-twin, which is off + // unless a drawing declares metal for it -- see docs/thermal/line-walls.md. + A('wall_thickness', 'Tube wall', 'length', { value: 0.889, unit: 'mm' }), + // Counted off the fitting list rather than typed -- see `ParamSpec.derived`. + // The solver needs it for the line-wall model; the drawing already knows. + D('fitting_count', 'Fittings on this run', 'dimensionless'), + A('fitting_mass', 'Fitting mass (weighed)', 'mass'), ], }, flex_hose: { @@ -317,7 +376,9 @@ export const LINE_SPECS: Record = { P('bore', 'Bore', 'length'), P('installed_bend_radius', 'Installed bend radius', 'length'), P('min_bend_radius', 'Min bend radius', 'length'), - P('end_fitting_K', 'End fittings K', 'dimensionless', { value: 0.5, unit: '-' }), + A('end_fitting_K', 'End fittings K', 'dimensionless', { value: 0.5, unit: '-' }), + A('min_bend_radius_dynamic', 'Min bend radius (flexing)', 'length'), + A('convolution_factor', 'Convolution friction factor', 'dimensionless', { value: 2, unit: '-' }), ], options: [ { key: 'construction', label: 'Construction', default: 'smooth_bore', diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts index d77009d52..a8df8cd4e 100644 --- a/pid-designer/frontend/src/lib/gating.test.ts +++ b/pid-designer/frontend/src/lib/gating.test.ts @@ -63,6 +63,10 @@ const VIEW_ONLY: Record = { 'ChecksPanel.tsx:onSelect(finding.nodeIds': 'selects what a finding is about — selection is view state, stripped by toStored', 'SegmentPanel.tsx:setOpenRow(openRow === r.id': 'expands a fitting row to show its fields; the fields themselves are gated', 'ConfigDialog.tsx:onClick={onClose}': 'Cancel closes the config dialog; Save is what writes, and Save is gated', + 'ConfigDialog.tsx:setShowAdvanced': 'folds the wall/roughness/head params in and out; the fields themselves are gated', + 'SegmentPanel.tsx:setShowMethod': 'reveals the loss-method selector; the selector itself is gated', + 'SegmentPanel.tsx:setOpenRow': 'expands a fitting to show its fields; the fields themselves are gated', + 'SegmentPanel.tsx:setMore': 'shows the rest of the fitting list; adding one is gated', 'PIDDesigner.tsx:setShowChange(true)': 'opens the Change dialog (rename/share/copy are not gated by design)', } From b63c48f410c721ee36336de8103fff6ad213ff4b Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Fri, 11 Sep 2026 01:05:36 -0700 Subject: [PATCH 38/57] Fittings screw into each other, and the drawing works out how far Answering "how do these join?" once for a run now prices every joint on it. Nobody types an engagement. The overlap comes from how the family closes, which is why most of this needs no table: an ORB runs in until its shoulder lands on the boss face, a JIC or AN closes on the 37 degree cone, a weld closes on nothing. Only NPT needs a per-size figure from the standard, and a swage insertion depth is a manufacturer's number that belongs in the catalogue -- asked for, never invented. Gender is modelled because the male half is the one that goes in, so it is the half whose bore the flow actually sees. A run measured off the female halves reports a restriction that is not there. Asked once, on the run, next to the size: - the family, already answered when the line standard names one, so a JIC or NPT line asks nothing at all - the thread size where that differs from the tube's, because 1/2 inch tube into 1/4 NPT is an ordinary thing to build; a swage fitting grips the tube and takes the tube's size, so it is not asked - the length a cone or a shoulder closes on, which is a measurement A fitting that is genuinely an adapter overrides its own two ends. Three things it refuses to do rather than guess: - an unanswered run reports no overlap instead of zero. Zero reads exactly like a checked zero, and the cut tube comes out long by the sum of every joint on the run - a joint that cannot be made reports no overlap either. engagementOf will still answer for one, so a 1/4 male in a 1/2 female came back as a confident 13.57 mm on a joint the panel called impossible - every NPT figure here is seeded from memory and carries verified: false, so a run using one says so in the summary Also fixes a duplicate-id bug found while testing this. nextRowId and nextSegmentId counted in module state that nothing seeded on load -- seedSegmentIds was written for it and never called anywhere -- so a line opened with fit_1 and fit_2 on it got fit_1 again for the next fitting added. Rows are matched by id, so editing one edited both and deleting one deleted both, with a React duplicate-key warning the only symptom. Ids are now derived from the collection, so there is no counter to seed. --- .../src/components/pid/SegmentPanel.tsx | 352 ++++++++++++++++-- .../src/components/pid/segments.test.ts | 274 +++++++++++++- .../frontend/src/components/pid/segments.ts | 316 +++++++++++++++- .../src/components/pid/terminations.test.ts | 172 +++++++++ .../src/components/pid/terminations.ts | 321 ++++++++++++++++ 5 files changed, 1397 insertions(+), 38 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/terminations.test.ts create mode 100644 pid-designer/frontend/src/components/pid/terminations.ts diff --git a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx index 6c584d1b7..55fc9382c 100644 --- a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx +++ b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx @@ -2,10 +2,14 @@ import { useMemo, useState } from 'react'; import { useReadOnly } from '@stardesign-ui'; import { FITTING_KINDS, FITTING_LABELS, LOSS_METHODS, - fittingCount, knownK, methodOf, nextRowId, nextSegmentId, transitionsOf, + cutTubeOf, endsOf, fittingCount, joinFamilyOf, joinSizeOf, jointsForRow, + jointFaultsOf, jointsOf, knownK, methodOf, needsOwnSize, needsThreadLength, + nextRowId, nextSegmentId, overlapOf, transitionsOf, } from './segments'; import type { FittingRow, LineSegment, LossMethod } from './segments'; -import { STANDARDS, TUBE_SIZES, DASH_SIZES, NPT_SIZES, cutLength, loadCatalog, suggestBore, tubeOdForSize } from './catalog'; +import { FAMILY_LABELS, MAKEUP, THREAD_PROMPT, isMissing } from './terminations'; +import type { Family, Gender, Termination } from './terminations'; +import { STANDARDS, TUBE_SIZES, DASH_SIZES, NPT_SIZES, loadCatalog, suggestBore, tubeOdForSize } from './catalog'; import type { Standard } from './catalog'; import { UNITS } from './params'; import type { ParamValue } from './params'; @@ -116,7 +120,7 @@ export function SegmentPanel({ segments, onChange }: { }; const addSegment = () => - onChange([...segments, { id: nextSegmentId(), method: 'itemised', fittings: [], standard: 'tube' }]); + onChange([...segments, { id: nextSegmentId(segments), method: 'itemised', fittings: [], standard: 'tube' }]); /** Picking a size fills the bore in, and records where it came from. */ const pickSize = (i: number, standard: Standard, size: string) => { @@ -224,6 +228,17 @@ export function SegmentPanel({ segments, onChange }: { title={many ? 'Remove this size' : 'Remove'}>×
+ {/* ── How the fittings join ────────────────────────────── + One answer for the run, because a run is built to one joint + standard. Every fitting inherits it and the overlap at every + joint follows, so nobody types an engagement. Four of the + five line standards *are* joint families, and for those this + is already answered by the size row above. */} + patch(i, { joinBy })} + onSize={joinSize => patch(i, { joinSize })} + onThread={joinThreadMm => patch(i, { joinThreadMm })} /> + {/* The trap: a thread size is not a flow diameter. */} {seg.tubeSize && !boreKnown && (

@@ -300,6 +315,7 @@ export function SegmentPanel({ segments, onChange }: { rows={seg.fittings ?? []} readOnly={readOnly} segmentBore={seg.bore?.value} + segment={seg} onChange={rows => patch(i, { fittings: rows })} /> )} @@ -349,27 +365,67 @@ function Summary({ seg }: { seg: LineSegment }) { const k = knownK(seg); if (k) bits.push(`K ${k.toFixed(2)}`); + // How much the joints take out of the sum of the parts -- worked out from + // the standard and the seal, never typed. See `terminations.ts`. + // + // A run with no joints yet (one fitting, or none) is not missing anything, so + // it says nothing. A run that *has* joints but cannot price them says which + // answer is missing, because silence there reads as "no overlap" and sends + // somebody to the bandsaw with a figure that is long by every joint. + const overlap = overlapOf(seg); + const joints = jointsOf(seg); + if (overlap && overlap.mm > 0) { + bits.push(`joints overlap ${overlap.mm.toFixed(1)} mm`); + } + if (seg.lengthBasis === 'overall' && lenMm !== null) { - const flat = (seg.fittings ?? []).flatMap(r => Array.from({ length: r.count }, () => r)); - const cut = cutLength(lenMm, flat); - bits.push(cut === null - ? 'cut length needs a body length on every fitting' - : `cut ${(cut / 1000).toFixed(3)} m`); + const cut = cutTubeOf(seg, lenMm); + bits.push('needs' in cut + ? `cut length needs ${cut.needs}` + : `cut ${(cut.mm / 1000).toFixed(3)} m`); } - if (bits.length === 0) return null; + const faults = jointFaultsOf(seg); + const unpriced = !overlap && faults.length === 0 && joints.length > 0 + ? jointsOf(seg).map(j => j.engagement).find(isMissing)?.needs ?? null + : null; + if (bits.length === 0 && faults.length === 0 && !unpriced) return null; return ( -

- {bits.join(' · ')} -

+
+ {bits.length > 0 && ( +

{bits.join(' · ')}

+ )} + {/* A figure nobody has checked against the standard says so. The number + is still used -- absent would be worse -- but a cut list built on it + should not look like a citation. */} + {overlap && overlap.unverified > 0 && ( +

+ {overlap.unverified} joint{overlap.unverified === 1 ? '' : 's'} using an + unchecked NPT engagement — see terminations.ts +

+ )} + {unpriced && ( +

+ the joints are not accounted for yet — needs {unpriced} +

+ )} + {/* One line per distinct fault, with how many joints it hits. Three + identical elbows used to print the same complaint three times. */} + {faults.map(f => ( +

+ {f.why}{f.joints > 1 && ` — at ${f.joints} joints`} +

+ ))} +
); } /** The fittings in a run: a chip each, with the common ones one click away. */ -function Fittings({ rows, readOnly, segmentBore, onChange }: { +function Fittings({ rows, readOnly, segmentBore, segment, onChange }: { rows: FittingRow[]; readOnly: boolean; segmentBore?: number; + segment: LineSegment; onChange: (rows: FittingRow[]) => void; }) { const [more, setMore] = useState(false); @@ -386,13 +442,18 @@ function Fittings({ rows, readOnly, segmentBore, onChange }: { const add = (kind: (typeof FITTING_KINDS)[number]) => { const existing = rows.find(r => r.kind === kind); if (existing) set(existing.id, { count: existing.count + 1 }); - else onChange([...rows, { id: nextRowId(), kind, count: 1 }]); + else onChange([...rows, { id: nextRowId(rows), kind, count: 1 }]); }; - const numField = (v: number | undefined, onSet: (n: number | undefined) => void, ph: string) => ( + const numField = ( + v: number | undefined, + onSet: (n: number | undefined) => void, + ph: string, + title?: string, + ) => ( { const t = e.target.value.trim(); onSet(t === '' ? undefined : Number.isFinite(Number(t)) ? Number(t) : undefined); @@ -455,16 +516,21 @@ function Fittings({ rows, readOnly, segmentBore, onChange }: { {rows.map(r => openRow === r.id && (
- {FITTING_LABELS[r.kind]} - · bore - {numField(r.boreMm, v => set(r.id, { boreMm: v }), segmentBore ? String(segmentBore.toFixed(2)) : 'mm')} - mm · body - {numField(r.lengthMm, v => set(r.id, { lengthMm: v }), 'mm')} - mm · engages - {numField(r.engagementMm, v => set(r.id, { engagementMm: v }), 'mm')} - mm · K - {numField(r.K, v => set(r.id, { K: v }), 'measured')} + className="ml-[3.25rem] space-y-1.5 rounded bg-[var(--color-bg-primary)] p-1.5"> +
+ {FITTING_LABELS[r.kind]} + · bore + {numField(r.boreMm, v => set(r.id, { boreMm: v }), segmentBore ? String(segmentBore.toFixed(2)) : 'mm')} + mm · body + {numField(r.lengthMm, v => set(r.id, { lengthMm: v }), 'mm')} + mm · K + {numField(r.K, v => set(r.id, { K: v }), '—', + "This fitting's own K, if it was measured. Left blank it adds no loss of its own.")} +
+ set(r.id, { ends })} + onThread={mm => set(r.id, { threadMm: mm })} + onSame={() => set(r.id, { ends: undefined })} />
))}
@@ -472,3 +538,237 @@ function Fittings({ rows, readOnly, segmentBore, onChange }: { } export { fittingCount, knownK }; + +const GENDERS: Gender[] = ['male', 'female']; + +/** The families somebody actually builds a run out of, in that order. */ +const JOIN_CHOICES: Family[] = ['NPT', 'JIC', 'AN', 'ORB', 'swage', 'weld']; + +/** + * How the fittings on this run join, asked once. + * + * This is the control that makes engagement automatic. Answer it and every + * joint on the run has an overlap -- from the standard for NPT, from the seal + * for a cone or a boss, zero for a weld -- with nothing typed per fitting. + * + * When the line standard is itself a joint family there is nothing to ask, so + * this states the answer instead of offering it. That is the common case: a + * JIC run is JIC throughout. + */ +function JoinBy({ segment, readOnly, onChange, onSize, onThread }: { + segment: LineSegment; + readOnly: boolean; + onChange: (family: Family | undefined) => void; + onSize: (size: string | undefined) => void; + onThread: (mm: number | undefined) => void; +}) { + const implied = joinFamilyOf({ ...segment, joinBy: undefined }); + const chosen = joinFamilyOf(segment); + const size = joinSizeOf(segment); + + // The line standard already named the family, so there is nothing to ask. + // An NPT line is NPT throughout at the size in the row above. + // The length a cone or a shoulder closes on, asked once for the run. It is + // a measurement, not a table lookup, so it has to be asked -- but once. + const thread = needsThreadLength(chosen ?? 'unset') && ( +
+ + { + const t = e.target.value.trim(); + onThread(t === '' ? undefined : Number.isFinite(Number(t)) ? Number(t) : undefined); + }} + className={`${field} w-[58px] shrink-0`} + title="The male thread length this family closes on, in mm. Set once for the run." /> + + mm {chosen ? THREAD_PROMPT[chosen] ?? 'of engagement' : 'of engagement'} + +
+ ); + + if (implied && !segment.joinBy) { + return ( +
+

+ joins by {FAMILY_LABELS[implied]} + {' — '}{MAKEUP[implied].note} + {thread ? ', which closes on:' : ', so the overlap at every joint is worked out'} +

+ {thread} +
+ ); + } + + // A thread size is not the tube size, and a run of 1/2 tube into 1/4 NPT is + // an ordinary thing to build -- so a thread gets asked. A swage fitting + // grips the tube and takes the tube's size, so it does not. + const threaded = chosen !== undefined && needsOwnSize(chosen); + + return ( +
+
+ Joins by + + {threaded && ( + + )} + + {!chosen ? 'until this is answered the cut tube cannot be worked out' + : threaded && !size ? 'the thread size, which is not the tube size' + : thread ? `${MAKEUP[chosen].note}, which closes on:` + : `${MAKEUP[chosen].note} — the overlap follows from that`} + +
+ {thread} +
+ ); +} + +/** + * One fitting's two ends, and the joints they make with their neighbours. + * + * Almost always nothing to do: a fitting is the run's joint family at the + * run's size, male into female so the next one screws on, and the engagement + * is derived. Saying that per fitting would be sixty entries of the obvious, + * so it is stated in one line with a way in for the case it exists for -- an + * adapter, where the two ends genuinely differ and the whole question of which + * ID to measure from turns on which half is male. + * + * The joints shown are against the *neighbours*, not between this fitting's + * own two ends. An elbow's inlet and outlet do not screw into each other. + */ +function Ends({ row, segment, readOnly, onChange, onThread, onSame }: { + row: FittingRow; + segment: LineSegment; + readOnly: boolean; + onChange: (ends: { a: Termination; b: Termination }) => void; + onThread: (mm: number | undefined) => void; + onSame: () => void; +}) { + const ends = endsOf(row, segment); + const custom = row.ends !== undefined; + const { inlet, outlet } = jointsForRow(segment, row.id); + + const set = (which: 'a' | 'b', next: Partial) => + onChange({ ...ends, [which]: { ...ends[which], ...next } }); + + const sizesFor = (family: Family) => + family === 'tube' ? TUBE_SIZES.map(t => t.label) + : family === 'NPT' ? [...NPT_SIZES] + : DASH_SIZES.map(d => `-${d}`); + + // Only a male end can owe a thread length: it is the half that goes in, so + // it is the half whose length is the depth. A female's thread is the hole. + const wantsThread = [ends.a, ends.b] + .some(e => e.gender === 'male' && needsThreadLength(e.family)); + + const endRow = (which: 'a' | 'b', label: string) => { + const e = ends[which]; + return ( +
+ {label} + + + +
+ ); + }; + + /** A joint, said in one line: what meets what, and how far in it goes. */ + const jointLine = (label: string, joint: ReturnType['inlet']) => { + if (!joint) return null; + const { engagement, mismatch, restricting } = joint; + return ( +

+ {label}{' '} + {mismatch + ? {mismatch} + : isMissing(engagement) + ? needs {engagement.needs} + : <> + closes{' '} + + {engagement.mm.toFixed(2)} + mm + {!engagement.verified && (unchecked)} + {' · '}bore from the {restricting.gender} side + } +

+ ); + }; + + return ( +
+ {!custom ? ( +

+ ends: {FAMILY_LABELS[ends.a.family]}{ends.a.size ? ` ${ends.a.size}` : ''}, male + into female — same as the run.{' '} + +

+ ) : ( + <> + {endRow('a', 'in')} + {endRow('b', 'out')} + + + )} + + {/* Only for a fitting whose ends were overridden -- otherwise the run's + own figure covers it, and this would be the same number twice. */} + {wantsThread && custom && ( +
+ thread + { + const t = ev.target.value.trim(); + onThread(t === '' ? undefined : Number.isFinite(Number(t)) ? Number(t) : undefined); + }} + className={`${field} w-[58px]`} /> + + mm {THREAD_PROMPT[ends.a.gender === 'male' ? ends.a.family : ends.b.family] + ?? 'of engagement'} + {segment.joinThreadMm !== undefined && ' — blank uses the run\u2019s'} + +
+ )} + + {jointLine('in:', inlet)} + {jointLine('out:', outlet)} +
+ ); +} diff --git a/pid-designer/frontend/src/components/pid/segments.test.ts b/pid-designer/frontend/src/components/pid/segments.test.ts index b66ee2826..fc0a27a83 100644 --- a/pid-designer/frontend/src/components/pid/segments.test.ts +++ b/pid-designer/frontend/src/components/pid/segments.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { transitionBetween, transitionsOf, fittingCount, knownK, methodOf, FITTING_KINDS, FITTING_LABELS } from './segments'; +import { transitionBetween, transitionsOf, fittingCount, knownK, methodOf, FITTING_KINDS, FITTING_LABELS, + cutTubeOf, joinFamilyOf, joinSizeOf, jointFaultsOf, jointsForRow, jointsOf, + mismatchesOf, needsOwnSize, needsThreadLength, nextRowId, nextSegmentId, + overlapOf } from './segments'; +import type { FittingRow } from './segments'; +import { engagementOf, isMissing } from './terminations'; import type { LineSegment } from './segments'; import { boreForTube, cutLength, suggestBore, tubeBoreMm, dashToTubeOdIn } from './catalog'; import type { ParamValue } from './params'; @@ -142,3 +147,270 @@ describe('the fitting tally', () => { expect(Object.keys(FITTING_LABELS).sort()).toEqual([...FITTING_KINDS].sort()); }); }); + +describe('how much the joints take out of a run', () => { + /** A run of `n` identical elbows, each 30 mm long, at one joint standard. */ + const run = (n: number, over: Partial = {}): LineSegment => ({ + id: 's1', standard: 'tube', tubeSize: '1/2 x 0.049', + fittings: [{ id: 'f1', kind: 'elbow_90', count: n, lengthMm: 30 }], + ...over, + }); + + it('refuses a figure when nobody has said how the fittings join', () => { + // The whole point of the `unset` family. A tube run says what the tube is + // and nothing about how the fittings grip it, so there is no overlap to + // report -- and reporting zero would read exactly like a checked zero. + const seg = run(3); + expect(overlapOf(seg)).toBeNull(); + const cut = cutTubeOf(seg, 1000); + expect('needs' in cut).toBe(true); + if ('needs' in cut) expect(cut.needs).toMatch(/how the fittings/); + }); + + it('works the overlap out from one answer on the run', () => { + // Answered once, and three elbows in a row have two joints between them. + const seg = run(3, { standard: 'NPT', tubeSize: '1/2', joinBy: 'NPT' }); + const one = engagementOf( + { family: 'NPT', size: '1/2', gender: 'male' }, + { family: 'NPT', size: '1/2', gender: 'female' }, + ); + expect(isMissing(one)).toBe(false); + if (isMissing(one)) return; + expect(overlapOf(seg)).toEqual({ mm: one.mm * 2, unverified: 2 }); + }); + + it('takes the joint family from the line standard when that names one', () => { + // An NPT line is NPT throughout; the user is asked nothing. + expect(joinFamilyOf({ id: 's', standard: 'NPT' })).toBe('NPT'); + expect(joinFamilyOf({ id: 's', standard: 'JIC' })).toBe('JIC'); + // A tube standard implies no joint at all, on purpose. + expect(joinFamilyOf({ id: 's', standard: 'tube' })).toBeUndefined(); + }); + + it('lets the run override a standard that does name a family', () => { + expect(joinFamilyOf({ id: 's', standard: 'NPT', joinBy: 'weld' })).toBe('weld'); + }); + + it('makes a welded run overlap by nothing, and says so', () => { + const seg = run(3, { joinBy: 'weld' }); + expect(overlapOf(seg)).toEqual({ mm: 0, unverified: 0 }); + // Stated zero, so the cut length is just the tube between the bodies. + expect(cutTubeOf(seg, 1000)).toEqual({ mm: 910, unverified: 0 }); + }); + + it('gives back the tube to cut, joints and bodies both accounted for', () => { + const seg = run(3, { standard: 'NPT', tubeSize: '1/2', joinBy: 'NPT' }); + const cut = cutTubeOf(seg, 1000); + expect('needs' in cut).toBe(false); + if ('needs' in cut) return; + const overlap = overlapOf(seg)!; + // overall - bodies + overlap: the joints give length back. + expect(cut.mm).toBeCloseTo(1000 - 90 + overlap.mm, 3); + expect(cut.mm).toBeGreaterThan(1000 - 90); + }); + + it('will not guess a swage insertion depth', () => { + const seg = run(2, { joinBy: 'swage' }); + expect(overlapOf(seg)).toBeNull(); + const cut = cutTubeOf(seg, 1000); + if ('needs' in cut) expect(cut.needs).toMatch(/insertion depth/); + else throw new Error('a swage depth is a manufacturer number, not ours to invent'); + }); +}); + +describe('the joints either side of one fitting', () => { + const seg: LineSegment = { + id: 's1', standard: 'NPT', tubeSize: '1/2', joinBy: 'NPT', + fittings: [ + { id: 'a', kind: 'elbow_90', count: 1, lengthMm: 30 }, + { id: 'b', kind: 'ball_valve_full', count: 1, lengthMm: 80 }, + { id: 'c', kind: 'elbow_90', count: 1, lengthMm: 30 }, + ], + }; + + it('pairs a fitting against its neighbours, never against itself', () => { + // An elbow's own inlet and outlet do not screw into each other; the panel + // used to show exactly that, which is how this test came to exist. + const { inlet, outlet } = jointsForRow(seg, 'b'); + expect(inlet).not.toBeNull(); + expect(outlet).not.toBeNull(); + expect(inlet!.rowId).toBe('b'); // the joint on b's inlet side + expect(outlet!.rowId).toBe('c'); // b's outlet against c's inlet + }); + + it('gives the first fitting no inlet joint and the last no outlet', () => { + expect(jointsForRow(seg, 'a').inlet).toBeNull(); + expect(jointsForRow(seg, 'c').outlet).toBeNull(); + }); + + it('reads the bore off the male half at every joint', () => { + // The reason gender is modelled at all: the female is a bigger hole with + // threads cut in it, so a run measured off the female halves reports a + // restriction that is not there. + for (const j of jointsOf(seg)) expect(j.restricting.gender).toBe('male'); + }); + + it('has nothing to show on a run with a single fitting', () => { + const one: LineSegment = { ...seg, fittings: [seg.fittings![0]] }; + expect(jointsForRow(one, 'a')).toEqual({ inlet: null, outlet: null }); + expect(overlapOf(one)).toEqual({ mm: 0, unverified: 0 }); + }); +}); + +describe('which size a joint is made at', () => { + it('takes a thread size from the run, never from the tube', () => { + // 1/2 x 0.049 tube into 1/4 NPT ports: ordinary, and `1/2 x 0.049` is not + // an NPT size at all. Without an answer the joint says what it is missing. + const seg: LineSegment = { + id: 's', standard: 'tube', tubeSize: '1/2 x 0.049', joinBy: 'NPT', + fittings: [{ id: 'f', kind: 'elbow_90', count: 2, lengthMm: 30 }], + }; + expect(joinSizeOf(seg)).toBe(''); + const cut = cutTubeOf(seg, 1000); + if ('needs' in cut) expect(cut.needs).toMatch(/thread size/); + else throw new Error('a blank thread size is not a size'); + + expect(joinSizeOf({ ...seg, joinSize: '1/4' })).toBe('1/4'); + expect(overlapOf({ ...seg, joinSize: '1/4' })).not.toBeNull(); + }); + + it('sizes a swage joint by the tube it grips', () => { + // There is no second size: a 1/2 inch swage fitting takes 1/2 inch tube. + const seg: LineSegment = { id: 's', standard: 'tube', tubeSize: '1/2 x 0.049', joinBy: 'swage' }; + expect(joinSizeOf(seg)).toBe('1/2 x 0.049'); + expect(needsOwnSize('swage')).toBe(false); + expect(needsOwnSize('weld')).toBe(false); + expect(needsOwnSize('NPT')).toBe(true); + expect(needsOwnSize('JIC')).toBe(true); + }); + + it('needs nothing said when the line standard is the joint', () => { + const seg: LineSegment = { id: 's', standard: 'NPT', tubeSize: '1/2' }; + expect(joinFamilyOf(seg)).toBe('NPT'); + expect(joinSizeOf(seg)).toBe('1/2'); + }); +}); + +describe('a joint that cannot be made', () => { + /** Three elbows whose outlet is 1/4 while the next inlet is 1/2. */ + const clash: LineSegment = { + id: 's', standard: 'tube', tubeSize: '1/2 x 0.049', joinBy: 'NPT', joinSize: '1/2', + fittings: [ + { id: 'a', kind: 'elbow_90', count: 3, lengthMm: 30, + ends: { a: { family: 'NPT', size: '1/2', gender: 'male' }, + b: { family: 'NPT', size: '1/4', gender: 'female' } } }, + { id: 'b', kind: 'ball_valve_full', count: 1, lengthMm: 80 }, + ], + }; + + it('reports no overlap at all rather than a confident wrong one', () => { + // The bug: `engagementOf` reads the male's size and answers, so a 1/4 male + // in a 1/2 female came back as 13.57 mm -- a number stated as fact on a + // joint the same panel was calling impossible. + expect(mismatchesOf(clash).length).toBeGreaterThan(0); + expect(overlapOf(clash)).toBeNull(); + }); + + it('refuses a cut length and names the joint as the reason', () => { + const cut = cutTubeOf(clash, 1000); + if ('needs' in cut) expect(cut.needs).toMatch(/needs an adapter/); + else throw new Error('a run with an impossible joint has no cut length'); + }); + + it('says each distinct fault once, with how many joints it hits', () => { + // Three identical elbows make the same complaint three times; printing it + // three times reads as three separate faults. + const faults = jointFaultsOf(clash); + expect(faults).toHaveLength(1); + expect(faults[0].why).toMatch(/1\/4 to 1\/2/); + expect(faults[0].joints).toBe(3); + }); + + it('has nothing to complain about on a run that fits together', () => { + const ok: LineSegment = { ...clash, fittings: [ + { id: 'a', kind: 'elbow_90', count: 3, lengthMm: 30 }, + { id: 'b', kind: 'ball_valve_full', count: 1, lengthMm: 80 }, + ] }; + expect(jointFaultsOf(ok)).toEqual([]); + expect(overlapOf(ok)).not.toBeNull(); + }); +}); + +describe('the length a cone or a shoulder closes on', () => { + const jic = (over: Partial = {}): LineSegment => ({ + id: 's', standard: 'JIC', tubeSize: '-8', + fittings: [{ id: 'a', kind: 'elbow_90', count: 2, lengthMm: 30 }], + ...over, + }); + + it('is asked once on the run, not once per fitting', () => { + // A JIC cone stops at a length rather than at a figure from a table, so + // it has to be asked -- but a run of one fitting series has one such + // length, and asking per fitting is that number typed over and over. + expect(overlapOf(jic())).toBeNull(); + expect(overlapOf(jic({ joinThreadMm: 12.7 }))).toEqual({ mm: 12.7, unverified: 0 }); + }); + + it('lets one odd fitting override the run', () => { + const seg = jic({ + joinThreadMm: 12.7, + fittings: [ + { id: 'a', kind: 'elbow_90', count: 1, lengthMm: 30 }, + { id: 'b', kind: 'elbow_90', count: 1, lengthMm: 30, threadMm: 9.5, + ends: { a: { family: 'JIC', size: '-8', gender: 'male' }, + b: { family: 'JIC', size: '-8', gender: 'female' } } }, + ], + }); + // One joint, and its male half is b's -- so b's own figure is the one used. + expect(overlapOf(seg)).toEqual({ mm: 9.5, unverified: 0 }); + }); + + it('says which families need it and which do not', () => { + expect(needsThreadLength('JIC')).toBe(true); + expect(needsThreadLength('AN')).toBe(true); + expect(needsThreadLength('ORB')).toBe(true); + // NPT comes out of the standard, and a weld closes on nothing. + expect(needsThreadLength('NPT')).toBe(false); + expect(needsThreadLength('weld')).toBe(false); + expect(needsThreadLength('swage')).toBe(false); + }); +}); + +describe('ids of things added to a saved drawing', () => { + it('never reissues a fitting id already on the line', () => { + // The bug: the counter behind `nextRowId` was module state that nothing + // seeded when a drawing was opened, so a line loaded with fit_1 and fit_2 + // got fit_1 again for the next fitting. Rows are matched by id, so the + // duplicate meant editing one edited both and deleting one deleted both. + const loaded: FittingRow[] = [ + { id: 'fit_1', kind: 'elbow_90', count: 1 }, + { id: 'fit_2', kind: 'tee_run', count: 1 }, + ]; + const added = nextRowId(loaded); + expect(loaded.some(r => r.id === added)).toBe(false); + }); + + it('keeps finding a free one as a line fills up', () => { + const rows: FittingRow[] = []; + for (let i = 0; i < 25; i++) { + rows.push({ id: nextRowId(rows), kind: 'elbow_90', count: 1 }); + } + expect(new Set(rows.map(r => r.id)).size).toBe(25); + }); + + it('steps over a gap left by a deletion', () => { + // Ids left by a delete are not reused while their neighbours remain. + const rows: FittingRow[] = [ + { id: 'fit_1', kind: 'elbow_90', count: 1 }, + { id: 'fit_3', kind: 'elbow_90', count: 1 }, + ]; + const added = nextRowId(rows); + expect(['fit_1', 'fit_3']).not.toContain(added); + }); + + it('does the same for a second size along a run', () => { + const segs: LineSegment[] = [{ id: 'seg_1' }, { id: 'seg_2' }]; + const added = nextSegmentId(segs); + expect(segs.some(s => s.id === added)).toBe(false); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/segments.ts b/pid-designer/frontend/src/components/pid/segments.ts index 7473502bc..9e0500f35 100644 --- a/pid-designer/frontend/src/components/pid/segments.ts +++ b/pid-designer/frontend/src/components/pid/segments.ts @@ -1,4 +1,8 @@ import type { ParamValue } from './params'; +import { + engagementOf, isMissing, restrictingEnd, whyNotMated, MAKEUP, +} from './terminations'; +import type { Engagement, Family, MissingEngagement, Termination } from './terminations'; /** * What a line is actually made of. @@ -153,6 +157,42 @@ export interface LineSegment { /** What the bore was derived from, when it came from a size. */ tubeSize?: string; standard?: string; + /** + * How the fittings on this run join, asked once for the whole run. + * + * This is the thing that makes engagement automatic. A run is built to one + * joint standard -- a JIC stand is JIC throughout, an NPT one is NPT -- so + * the overlap at every joint follows from a single answer, and nobody types + * a number per fitting. A fitting that really is an adapter overrides it on + * its own `ends`. + * + * Four of the five line standards *are* joint families, so for those this is + * already answered by `standard` and never has to be set. It exists for the + * one that is not: `tube` says what the tube is and nothing about how the + * fittings grip it, which could be swage, flare or weld. + */ + joinBy?: Family; + /** + * The thread size the fittings join at, when it is not the tube's own size. + * + * These are different facts and the drawing has to keep them apart: a run of + * 1/2 x 0.049 tube ending in 1/4 NPT is an ordinary thing to build, and + * `1/2 x 0.049` is not an NPT size at all. Where the line standard *is* the + * joint family the two coincide and this stays empty -- an NPT line's size + * is already the nominal. + */ + joinSize?: string; + /** + * The male thread length these joints close on, in mm, where the family + * needs one: the ORB shoulder and the JIC/AN cone both stop at a length + * rather than at a figure out of a table. + * + * On the run for the same reason as the family and the size -- a run built + * of one size of one fitting series has one such length, and asking per + * fitting would be the same number typed over and over. A fitting that + * differs carries its own `threadMm`. + */ + joinThreadMm?: number; } /** One kind of fitting in a segment, and how many of it. */ @@ -164,6 +204,22 @@ export interface FittingRow { boreMm?: number; /** Centreline length, for the cut list. Never for the friction term. */ lengthMm?: number; + /** + * The male thread length, where the way the joint closes needs it. + * + * An ORB male runs in until its shoulder bottoms, and a JIC male stops on + * the cone -- in both the engagement *is* this length, so one number covers + * the joint and nobody works out an overlap. NPT does not need it: the + * standard fixes that per size. See `terminations.ts`. + */ + threadMm?: number; + /** + * What each end of this fitting is. Absent means "the same thread and size + * as the run, male into female", which is what a plain elbow in a plain run + * is -- so only an adapter ever has to say. + */ + ends?: { a: Termination; b: Termination }; + /** Superseded by `ends` and the makeup rules. Kept so older drawings open. */ engagementMm?: number; /** A measured or published K for this fitting. Beats the correlation. */ K?: number; @@ -171,22 +227,32 @@ export interface FittingRow { partNumber?: string; } -let _seg = 0; -export const nextSegmentId = () => `seg_${++_seg}`; - -/** Advance past the ids already in a loaded diagram. */ -export function seedSegmentIds(segments: LineSegment[] | undefined): void { - for (const s of segments ?? []) { - const m = /^seg_(\d+)$/.exec(s.id); - if (m) _seg = Math.max(_seg, Number(m[1])); - } +/** + * An id no existing member of `taken` is using. + * + * Derived from what is there rather than from a module counter, because a + * counter has to be seeded when a saved drawing is opened and nothing was + * seeding it: a line loaded with `fit_1` and `fit_2` on it got `fit_1` again + * for the next fitting added. Rows are matched by id, so the duplicate meant + * editing one edited both and deleting one deleted both -- with the only + * visible symptom a React duplicate-key warning in the console. + * + * There is no counter to forget now. The id is a fact about the collection. + */ +function freshId(prefix: string, taken: { id: string }[]): string { + let n = taken.length + 1; + const used = new Set(taken.map(t => t.id)); + while (used.has(`${prefix}_${n}`)) n++; + return `${prefix}_${n}`; } +export const nextSegmentId = (segments: LineSegment[] = []) => + freshId('seg', segments); + export const fittingCount = (s: LineSegment): number => (s.fittings ?? []).reduce((n, r) => n + (r.count || 0), 0); -let _row = 0; -export const nextRowId = () => `fit_${++_row}`; +export const nextRowId = (rows: FittingRow[] = []) => freshId('fit', rows); /** The method actually in force, with the default made explicit. */ export const methodOf = (s: LineSegment): LossMethod => s.method ?? 'itemised'; @@ -240,3 +306,231 @@ export function transitionBetween(a: LineSegment, b: LineSegment): Transition | export function transitionsOf(segments: LineSegment[]): (Transition | null)[] { return segments.slice(0, -1).map((s, i) => transitionBetween(s, segments[i + 1])); } + +/** + * What a fitting's ends are, when the drawing has not said. + * + * A plain elbow in a plain run is the same thread and size as the run, one end + * male and the other female, so that a chain of them mates. Saying that per + * fitting would be sixty entries of the obvious; only an adapter differs, and + * only an adapter has to say. + */ +/** The families a line standard names outright, so the run implies the joint. */ +const STANDARD_IS_FAMILY: Record = { + NPT: 'NPT', JIC: 'JIC', AN: 'AN', ORB: 'ORB', +}; + +/** + * What this run's fittings join by, when the fitting does not say. + * + * Ordered by how much each source actually knows: the run's own answer first, + * then the line standard where the standard is itself a joint family. A `tube` + * standard falls through to undefined on purpose -- it does not imply a joint, + * and guessing one here is how a drawing ends up asserting an overlap nobody + * chose. + */ +export function joinFamilyOf(segment: LineSegment): Family | undefined { + return segment.joinBy ?? STANDARD_IS_FAMILY[segment.standard ?? '']; +} + +/** + * Families that grip the tube itself, so their size *is* the tube's size. + * + * A 1/2 inch swage fitting takes 1/2 inch tube -- there is no second size to + * ask for. A thread is the other case: 1/2 inch tube into a 1/4 NPT port is an + * ordinary thing to build, so the thread size is its own fact. + */ +const SIZED_BY_TUBE: Partial> = { + swage: true, tube: true, weld: true, +}; + +/** + * The size the joints are made at. + * + * The run's own answer first, then the tube size -- which is right in two + * cases: the line standard is itself the joint family (an NPT line's size is + * already the nominal), or the family grips the tube and has no separate size. + */ +export function joinSizeOf(segment: LineSegment): string { + if (segment.joinSize) return segment.joinSize; + const family = joinFamilyOf(segment); + if (family && SIZED_BY_TUBE[family]) return segment.tubeSize ?? ''; + return STANDARD_IS_FAMILY[segment.standard ?? ''] ? (segment.tubeSize ?? '') : ''; +} + +/** Does this family's size have to be asked for separately from the tube's? */ +export function needsOwnSize(family: Family): boolean { + return !SIZED_BY_TUBE[family] && MAKEUP[family].rule !== 'unstated'; +} + +/** + * The two ends of a fitting. + * + * Male into female, alternating, so consecutive fittings mate: that is a run + * that can actually be built, and it is what the engagement is computed from. + * Untouched, a fitting is the run's joint family at the run's size -- which is + * what a plain elbow in a plain run is. + */ +export function endsOf(row: FittingRow, segment: LineSegment): { a: Termination; b: Termination } { + if (row.ends) return row.ends; + // `unset`, not `tube`: an unanswered run owes a number it has not been + // given, and the one thing it must not do is hand back zero. + const family = joinFamilyOf(segment) ?? 'unset'; + const size = joinSizeOf(segment); + return { + a: { family, size, gender: 'male' }, + b: { family, size, gender: 'female' }, + }; +} + +/** One place two things screw together, along a run. */ +export interface Joint { + /** Which fitting this joint is on the inlet side of. */ + rowId: string; + a: Termination; + b: Termination; + engagement: Engagement | MissingEngagement; + /** Set when the two ends cannot physically be joined. */ + mismatch: string | null; + /** The end whose bore the flow actually sees. */ + restricting: Termination; +} + +/** + * The joints along a run, in order, with how far each goes together. + * + * Between each fitting and the next: the outlet end of one against the inlet + * end of the next. What this replaces was an `engagementMm` typed onto each + * fitting and subtracted from its own body -- which cannot be right, because + * the same elbow makes up differently depending on what it is screwed into. + */ +export function jointsOf(segment: LineSegment): Joint[] { + const flat = (segment.fittings ?? []).flatMap(r => + Array.from({ length: Math.max(0, r.count) }, () => r)); + const out: Joint[] = []; + for (let i = 0; i < flat.length - 1; i++) { + const left = endsOf(flat[i], segment); + const right = endsOf(flat[i + 1], segment); + const a = left.b; // the outlet end of the one before + const b = right.a; // the inlet end of the next + out.push({ + rowId: flat[i + 1].id, + a, b, + engagement: engagementOf(a, b, { + // The fitting's own figure if it has one, else the run's. + maleThreadMm: (a.gender === 'male' ? flat[i] : flat[i + 1]).threadMm + ?? segment.joinThreadMm, + }), + mismatch: whyNotMated(a, b), + restricting: restrictingEnd(a, b), + }); + } + return out; +} + +/** + * How much shorter the run is than the sum of its parts. + * + * The overlap at every joint, added up. Null when any joint cannot say -- + * because a cut list built on a partial subtraction is a mis-cut part rather + * than an approximate one, which is the same rule `cutLength` already applied + * to body lengths. + */ +/** + * The joints on either side of one fitting row. + * + * A fitting's own two ends do not screw into each other; they screw into its + * neighbours. So the joint worth showing beside an end is the one that end + * forms with what is next to it, which is why this reads `jointsOf` rather + * than mating a row against itself. + * + * A row with a count of three has identical joints between its own instances, + * so the first of each side is the whole story. + */ +export function jointsForRow(segment: LineSegment, rowId: string): { + inlet: Joint | null; + outlet: Joint | null; +} { + const joints = jointsOf(segment); + const flat = (segment.fittings ?? []).flatMap(r => + Array.from({ length: Math.max(0, r.count) }, () => r)); + // `jointsOf` indexes a joint by the row on its *right*, so the joint at + // index i sits between flat[i] and flat[i + 1]. + const inlet = joints.find(j => j.rowId === rowId) ?? null; + const lastHere = flat.reduce((acc, r, i) => (r.id === rowId ? i : acc), -1); + const outlet = lastHere >= 0 && lastHere < joints.length ? joints[lastHere] : null; + return { inlet, outlet }; +} + +export function overlapOf(segment: LineSegment): { mm: number; unverified: number } | null { + let mm = 0; + let unverified = 0; + for (const j of jointsOf(segment)) { + // A joint that cannot be made has no overlap to report. `engagementOf` + // will still answer for one -- it reads the male's size and does the + // arithmetic -- so without this a 1/4 male in a 1/2 female came back as a + // confident 13.57 mm on a joint the same panel was calling impossible. + if (j.mismatch !== null) return null; + if (isMissing(j.engagement)) return null; + mm += j.engagement.mm; + if (!j.engagement.verified) unverified++; + } + return { mm: Math.round(mm * 1000) / 1000, unverified }; +} + +/** + * The distinct things wrong with this run's joints, each with a count. + * + * Distinct because three identical elbows produce the same complaint three + * times, and a panel that prints it three times reads as three faults. + */ +export function jointFaultsOf(segment: LineSegment): { why: string; joints: number }[] { + const seen = new Map(); + for (const j of mismatchesOf(segment)) { + seen.set(j.mismatch!, (seen.get(j.mismatch!) ?? 0) + 1); + } + return [...seen].map(([why, joints]) => ({ why, joints })); +} + +/** Joints the drawing describes but the hardware could not make. */ +export const mismatchesOf = (segment: LineSegment): Joint[] => + jointsOf(segment).filter(j => j.mismatch !== null); + +/** Whether a family needs a thread length stating. See `terminations.ts`. */ +export const needsThreadLength = (family: Family): boolean => + MAKEUP[family].rule === 'bottoms_out' || MAKEUP[family].rule === 'cone_seat'; + +/** + * The straight tube to cut, from an end-to-end measurement. + * + * `overall − Σ(body lengths) + Σ(overlaps)`. The overlap term is the whole + * point of this file: two fittings screwed together occupy less than the sum + * of their lengths, and how much less is fixed by the standard or the seal + * rather than by anybody's judgement. + * + * Refuses rather than approximates. A cut list is a part somebody makes. + */ +export function cutTubeOf( + segment: LineSegment, overallMm: number, +): { mm: number; unverified: number } | { needs: string } { + const flat = (segment.fittings ?? []).flatMap(r => + Array.from({ length: Math.max(0, r.count) }, () => r)); + let bodies = 0; + for (const f of flat) { + if (f.lengthMm === undefined) return { needs: 'a body length on every fitting' }; + bodies += f.lengthMm; + } + const overlap = overlapOf(segment); + if (!overlap) { + // A joint that cannot be made is the first thing to say; an unanswered one + // comes next. Either way no length is offered. + const broken = mismatchesOf(segment)[0]; + if (broken) return { needs: `a joint that can be made — ${broken.mismatch}` }; + const first = jointsOf(segment).map(j => j.engagement).find(isMissing); + return { needs: first ? first.needs : 'how the joints make up' }; + } + return { + mm: Math.round((overallMm - bodies + overlap.mm) * 1000) / 1000, + unverified: overlap.unverified, + }; +} diff --git a/pid-designer/frontend/src/components/pid/terminations.test.ts b/pid-designer/frontend/src/components/pid/terminations.test.ts new file mode 100644 index 000000000..5f4038211 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/terminations.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import { + engagementOf, isMissing, restrictingEnd, whyNotMated, + MAKEUP, NPT_L1_IN, NPT_TPI, NPT_WRENCH_TURNS, FAMILY_LABELS, +} from './terminations'; +import type { Family, Gender, Termination } from './terminations'; + +const end = (family: Family, size: string, gender: Gender): Termination => + ({ family, size, gender }); + +describe('how far a joint goes together', () => { + it('lets an ORB male run all the way in', () => { + // The shoulder bottoms on the boss face, so the thread length *is* the + // engagement -- no table, no guess, nothing left between flats and face. + const e = engagementOf(end('ORB', '-8', 'male'), end('ORB', '-8', 'female'), + { maleThreadMm: 12.7 }); + expect(isMissing(e)).toBe(false); + if (isMissing(e)) return; + expect(e.mm).toBe(12.7); + expect(e.basis).toBe('rule'); + expect(e.verified).toBe(true); + }); + + it('closes a JIC joint on the cone, not the thread', () => { + const e = engagementOf(end('JIC', '-8', 'male'), end('JIC', '-8', 'female'), + { maleThreadMm: 9.5 }); + if (isMissing(e)) throw new Error(e.needs); + expect(e.mm).toBe(9.5); + expect(e.reference).toContain('cone'); + }); + + it('works an NPT joint out from the standard and the pitch', () => { + // L1 plus three turns at 14 TPI, in millimetres. The arithmetic is the + // point: nobody types this, and changing the turns changes every joint. + const e = engagementOf(end('NPT', '1/2', 'male'), end('NPT', '1/2', 'female')); + if (isMissing(e)) throw new Error(e.needs); + const expected = (NPT_L1_IN['1/2'].in + NPT_WRENCH_TURNS / NPT_TPI['1/2']) * 25.4; + expect(e.mm).toBeCloseTo(expected, 3); + expect(e.basis).toBe('standard'); + }); + + it('says an NPT figure is unchecked while it is', () => { + // Seeded from memory on purpose, and it has to admit that: a number + // somebody cuts a tube to cannot quietly look like a citation. + const e = engagementOf(end('NPT', '1/4', 'male'), end('NPT', '1/4', 'female')); + if (isMissing(e)) throw new Error(e.needs); + expect(e.verified).toBe(false); + expect(e.reference).toContain('not yet checked'); + }); + + it('asks for a swage insertion depth rather than inventing one', () => { + // Varies by series, so it is a catalogue number and this file says so. + const e = engagementOf(end('swage', '-8', 'male'), end('swage', '-8', 'female')); + expect(isMissing(e)).toBe(true); + if (!isMissing(e)) return; + expect(e.needs).toContain('catalogue'); + }); + + it('gives a welded joint no overlap at all', () => { + const e = engagementOf(end('weld', '1/2', 'male'), end('tube', '1/2', 'female')); + if (isMissing(e)) throw new Error(e.needs); + expect(e.mm).toBe(0); + }); + + it('asks for the thread length where the rule needs one', () => { + const e = engagementOf(end('ORB', '-8', 'male'), end('ORB', '-8', 'female')); + expect(isMissing(e)).toBe(true); + }); + + it('never returns a length without saying where it came from', () => { + for (const family of Object.keys(MAKEUP) as Family[]) { + const e = engagementOf(end(family, '1/2', 'male'), end(family, '1/2', 'female'), + { maleThreadMm: 10, insertionMm: 8 }); + if (isMissing(e)) continue; + expect(e.reference.length, family).toBeGreaterThan(8); + expect(['rule', 'standard', 'catalogue'], family).toContain(e.basis); + } + }); +}); + +describe('which bore the fluid sees', () => { + it('is the male side, every time', () => { + // The female at a joint is a bigger hole with threads cut in it. Taking + // the bore off that half reports a restriction that is not there. + const male = end('NPT', '1/2', 'male'); + const female = end('NPT', '1/2', 'female'); + expect(restrictingEnd(male, female)).toBe(male); + expect(restrictingEnd(female, male)).toBe(male); + }); +}); + +describe('what will not go together', () => { + it('refuses two of the same gender', () => { + expect(whyNotMated(end('NPT', '1/2', 'male'), end('NPT', '1/2', 'male'))) + .toContain('two male ends'); + expect(whyNotMated(end('NPT', '1/2', 'female'), end('NPT', '1/2', 'female'))) + .toContain('two female ends'); + }); + + it('refuses two different families', () => { + const why = whyNotMated(end('NPT', '1/2', 'male'), end('ORB', '1/2', 'female')); + expect(why).toContain(FAMILY_LABELS.NPT); + expect(why).toContain(FAMILY_LABELS.ORB); + }); + + it('lets the two cone families interchange, because they do', () => { + expect(whyNotMated(end('JIC', '-8', 'male'), end('AN', '-8', 'female'))).toBeNull(); + }); + + it('refuses a size mismatch, and says an adapter is what is missing', () => { + expect(whyNotMated(end('NPT', '1/2', 'male'), end('NPT', '3/8', 'female'))) + .toContain('adapter'); + }); + + it('is happy with a good joint', () => { + expect(whyNotMated(end('NPT', '1/2', 'male'), end('NPT', '1/2', 'female'))).toBeNull(); + expect(whyNotMated(end('ORB', '-8', 'female'), end('ORB', '-8', 'male'))).toBeNull(); + }); + + it('does not police bare tube against bare tube', () => { + expect(whyNotMated(end('tube', '1/2 × 0.049', 'male'), end('tube', '1/2 × 0.049', 'male'))) + .toBeNull(); + }); +}); + +describe('a joint whose size nobody has picked', () => { + it('asks for the size, not for a figure out of the standard', () => { + // The wording bug this exists for: with a blank size the NPT branch asked + // for "hand-tight engagement for NPT " -- a question about a standard, + // when what is missing is one dropdown on the run. + for (const family of ['NPT', 'JIC', 'AN', 'ORB', 'swage'] as const) { + const e = engagementOf( + { family, size: '', gender: 'male' }, + { family, size: '', gender: 'female' }, + { maleThreadMm: 12, insertionMm: 9 }, + ); + expect(isMissing(e), family).toBe(true); + if (isMissing(e)) expect(e.needs, family).toMatch(/thread size/); + } + }); + + it('still gives a weld zero, size or no size', () => { + const e = engagementOf( + { family: 'weld', size: '', gender: 'male' }, + { family: 'weld', size: '', gender: 'female' }, + ); + expect(isMissing(e)).toBe(false); + if (!isMissing(e)) expect(e.mm).toBe(0); + }); + + it('owes nothing at all until somebody says how the run joins', () => { + const e = engagementOf( + { family: 'unset', size: '1/2', gender: 'male' }, + { family: 'unset', size: '1/2', gender: 'female' }, + ); + expect(isMissing(e)).toBe(true); + if (isMissing(e)) expect(e.needs).toMatch(/how the fittings/); + }); + + it('does not call an unanswered end a mismatch', () => { + // Otherwise every fitting on a fresh run wears a red line whose only + // cause is that the run has not been answered yet. + expect(whyNotMated( + { family: 'unset', size: '', gender: 'male' }, + { family: 'unset', size: '', gender: 'female' }, + )).toBeNull(); + expect(whyNotMated( + { family: 'unset', size: '', gender: 'male' }, + { family: 'NPT', size: '1/2', gender: 'female' }, + )).toBeNull(); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/terminations.ts b/pid-designer/frontend/src/components/pid/terminations.ts new file mode 100644 index 000000000..c9a764b8d --- /dev/null +++ b/pid-designer/frontend/src/components/pid/terminations.ts @@ -0,0 +1,321 @@ +/** + * How two fittings go together, and what that does to the length and the bore. + * + * The model this replaces had `engagementMm` on each fitting, subtracted from + * its own body length. That is the wrong shape twice over. Engagement is not a + * property of a fitting -- it is a property of the **joint**, and the same + * elbow makes up differently depending on what it is screwed into. And how far + * it goes in is not something anybody should be typing: for the families used + * on a stand it is either fixed by a published standard or fixed by the + * geometry of the seal. + * + * So a fitting has two **ends**, each with a family, a size and a gender, and + * the joint between two ends is what carries a number. + * + * ## How each family makes up + * + * The families differ in kind, not just in value, and three of the four need + * no table at all: + * + * - **ORB** (SAE straight thread O-ring boss) -- the male runs in until its + * shoulder bottoms on the boss face and the O-ring is trapped under it. It + * really does go all the way in: engagement *is* the male's thread length, + * with nothing left between the flats and the face. No lookup, and no slack + * for anybody to guess at. + * + * - **JIC 37 degree / AN** -- the nut's thread is not what sets the length. The + * two cones seat against each other, so the joint closes at the **cone + * seat**, a fixed feature of both halves. Engagement is measured to that + * seat; the threads are just what holds it there. + * + * - **NPT** -- tapered, and the only family where the answer is a number per + * size rather than a rule. The male wedges into the female and stops where + * the taper binds, which ASME B1.20.1 fixes as hand-tight engagement (L1) + * plus wrench makeup. That is what `NPT_L1_IN` holds, and it is the one + * table here that a person has to be able to check. + * + * - **Swage / compression** -- the tube bottoms on a shoulder inside the body + * and the ferrules grip it. The insertion depth is a manufacturer's number + * that varies by series, so it belongs in the catalogue with through-bore + * and body length, not here. + * + * ## Which bore counts + * + * A male fitting's through-bore is the hole the fluid goes down. The female it + * screws into is, at the joint, a larger hole with threads cut in it -- so the + * restriction at any threaded joint is the **male** side, every time. That is + * why gender is not decoration: without it the drawing does not know which of + * two numbers is the one the flow sees. + * + * ## What this file will not do + * + * Invent a number and call it a standard. See `NPT_L1_IN`. + */ + +import { MM_PER_IN } from './catalog'; + +/** The termination families a stand is plumbed with. */ +export type Family = + | 'NPT' | 'JIC' | 'AN' | 'ORB' | 'swage' | 'tube' | 'weld' + /** + * Nobody has said yet. + * + * Distinct from `tube` and `weld`, which are answers that happen to overlap + * by zero. This one refuses to produce a number, because a drawing that + * quietly assumes zero overlap reads exactly like one where somebody checked + * -- and the cut tube comes out long by the sum of every joint on the run. + */ + | 'unset'; + +export type Gender = 'male' | 'female'; + +export const FAMILY_LABELS: Record = { + NPT: 'NPT', + JIC: 'JIC 37°', + AN: 'AN', + ORB: 'SAE ORB', + swage: 'Swage / compression', + tube: 'Bare tube', + weld: 'Welded', + unset: 'not stated', +}; + +/** One end of a fitting: what thread it is, what size, and which half. */ +export interface Termination { + family: Family; + /** `1/2` for NPT, `-8` for a dash size, a tube label for bare tube. */ + size: string; + gender: Gender; +} + +/** How a family closes. The distinction that makes most of this table-free. */ +export type Makeup = + /** Runs in until a shoulder bottoms out: engagement is the whole thread. */ + | 'bottoms_out' + /** Closes on a mating cone: engagement is measured to the seat. */ + | 'cone_seat' + /** Tapered; binds at a length the standard fixes per size. */ + | 'tapered' + /** Tube bottoms inside the body; depth is a manufacturer's number. */ + | 'insertion' + /** Nothing screws together. */ + | 'none' + /** Not yet answered, so no number is owed. */ + | 'unstated'; + +/** + * What the length a joint closes on is called, per family. + * + * An ORB does not have a seat -- it runs in until the shoulder lands on the + * boss face -- so asking for "flats to the seat" on one is asking the wrong + * question about the right number. + */ +export const THREAD_PROMPT: Partial> = { + ORB: 'of thread — an ORB runs all the way in, so that is the engagement', + JIC: 'flats to the seat on the male half', + AN: 'flats to the seat on the male half', +}; + +export const MAKEUP: Record = { + ORB: { rule: 'bottoms_out', note: 'shoulder bottoms on the boss face' }, + JIC: { rule: 'cone_seat', note: 'closes on the 37° cone' }, + AN: { rule: 'cone_seat', note: 'closes on the 37° cone' }, + NPT: { rule: 'tapered', note: 'binds on the taper' }, + swage: { rule: 'insertion', note: 'tube bottoms inside the body' }, + tube: { rule: 'none', note: 'bare tube' }, + weld: { rule: 'none', note: 'welded' }, + unset: { rule: 'unstated', note: 'say how these join' }, +}; + +/** + * Threads per inch, by NPT nominal size. + * + * These are not in doubt and they are not a transcription risk: 1/8 is 27, + * 1/4 and 3/8 are 18, 1/2 and 3/4 are 14, 1 inch is 11 1/2. Kept because the + * pitch is what turns "three turns past hand tight" into a length. + */ +export const NPT_TPI: Record = { + '1/8': 27, '1/4': 18, '3/8': 18, '1/2': 14, '3/4': 14, '1': 11.5, +}; + +/** NPT taper: 1 in 16 on the diameter, 3/4 inch per foot. */ +export const NPT_TAPER = 1 / 16; + +/** + * Turns past hand tight, which is a workshop convention rather than a length. + * + * ASME B1.20.1 fixes hand-tight engagement; how far past it you go is shop + * practice, and two to three turns is what is taught. Three is used here + * because it is what a stand gets wrenched to, and it is a named constant so + * it can be argued with rather than buried in a sum. + */ +export const NPT_WRENCH_TURNS = 3; + +/** + * Hand-tight engagement (L1) per NPT size, in inches. + * + * **These are recalled, not transcribed, and they say so.** L1 is fixed by + * ASME B1.20.1 Table 8 -- a genuine standard, identical for every + * manufacturer, which is exactly why it belongs in software rather than in + * sixty drawings. But this repo does not contain a copy of the standard, and a + * number remembered to three decimal places is a number somebody will + * eventually cut a tube to. + * + * So they are seeded, because automatic and checkable beats absent, and every + * one carries `verified: false` until somebody sets it against the table. + * `engagementOf` passes the flag through to whatever uses the result, and the + * checks panel counts them. Replacing this block with the real table is a + * ten-minute job for anyone holding B1.20.1, and nothing else has to change. + */ +export const NPT_L1_IN: Record = { + '1/8': { in: 0.180, verified: false }, + '1/4': { in: 0.200, verified: false }, + '3/8': { in: 0.240, verified: false }, + '1/2': { in: 0.320, verified: false }, + '3/4': { in: 0.339, verified: false }, + '1': { in: 0.400, verified: false }, +}; + +export const SOURCE_UNVERIFIED = + 'ASME B1.20.1 Table 8 — from memory, not yet checked against the standard'; + +/** Why two ends cannot be joined, or null if they can. */ +export function whyNotMated(a: Termination, b: Termination): string | null { + // An unanswered end is not a wrong one. Saying "not stated does not mate with + // NPT" would put a red line on every fitting of a run whose only fault is + // that nobody has picked the joint family yet -- and the engagement already + // says so, once, in the place where it can be fixed. + if (a.family === 'unset' || b.family === 'unset') return null; + const threaded = (t: Termination) => MAKEUP[t.family].rule !== 'none'; + if (!threaded(a) && !threaded(b)) return null; // tube to tube: welded or butted + + if (a.family !== b.family) { + // Cone families interchange; nothing else does. + const cone = (f: Family) => MAKEUP[f].rule === 'cone_seat'; + if (!(cone(a.family) && cone(b.family))) { + return `${FAMILY_LABELS[a.family]} does not mate with ${FAMILY_LABELS[b.family]}`; + } + } + if (threaded(a) && threaded(b) && a.gender === b.gender) { + return `two ${a.gender} ends cannot be joined`; + } + if (a.size !== b.size) { + return `${a.size} to ${b.size} needs an adapter`; + } + return null; +} + +export interface Engagement { + /** How much the pair overlaps, so the assembly is shorter by this much. */ + mm: number; + /** Where the number came from, which decides how far to trust it. */ + basis: 'rule' | 'standard' | 'catalogue'; + reference: string; + /** False when the figure is seeded from memory. See `NPT_L1_IN`. */ + verified: boolean; +} + +/** A number this file cannot supply, and what would supply it. */ +export interface MissingEngagement { + needs: string; +} + +export const isMissing = (e: Engagement | MissingEngagement): e is MissingEngagement => + 'needs' in e; + +/** + * How far two ends go into each other. + * + * `maleThreadMm` is the male's thread length, which the bottoming and cone + * families need and the tapered one does not; a caller without it is told what + * is missing rather than handed a guess. + */ +export function engagementOf( + a: Termination, + b: Termination, + opts: { maleThreadMm?: number; insertionMm?: number } = {}, +): Engagement | MissingEngagement { + const male = a.gender === 'male' ? a : b; + const rule = MAKEUP[male.family].rule; + + // Nothing below can be answered for a joint whose size is blank, and each + // branch would otherwise ask its own question with a hole in it. + if (rule !== 'none' && rule !== 'unstated' && !male.size) { + return { needs: 'the thread size these join at' }; + } + + switch (rule) { + case 'bottoms_out': + // Really does go all the way in. Nothing to look up. + return opts.maleThreadMm === undefined + ? { needs: 'the male thread length — an ORB joint closes on its shoulder, so that length *is* the engagement' } + : { + mm: opts.maleThreadMm, + basis: 'rule', + reference: 'SAE ORB bottoms on the boss face: engagement = male thread length', + verified: true, + }; + + case 'cone_seat': + // The threads hold it; the cone decides where it stops. + return opts.maleThreadMm === undefined + ? { needs: 'the distance from the flats to the cone seat' } + : { + mm: opts.maleThreadMm, + basis: 'rule', + reference: `${FAMILY_LABELS[male.family]} closes on the cone: engagement = flats to seat`, + verified: true, + }; + + case 'tapered': { + // No size at all and an unlisted size are different questions. Asking + // for "hand-tight engagement for NPT " when nobody has picked a size + // sends the reader to the standard for something the drawing is simply + // missing. + if (!male.size) return { needs: 'the thread size these join at' }; + const l1 = NPT_L1_IN[male.size]; + if (!l1) return { needs: `hand-tight engagement for NPT ${male.size} (ASME B1.20.1 Table 8)` }; + const tpi = NPT_TPI[male.size]; + if (!tpi) return { needs: `threads per inch for NPT ${male.size}` }; + const inches = l1.in + NPT_WRENCH_TURNS / tpi; + return { + mm: Math.round(inches * MM_PER_IN * 1000) / 1000, + basis: 'standard', + reference: + `NPT ${male.size}: L1 ${l1.in}in + ${NPT_WRENCH_TURNS} turns at ${tpi} TPI` + + (l1.verified ? '' : ` — ${SOURCE_UNVERIFIED}`), + verified: l1.verified, + }; + } + + case 'insertion': + return opts.insertionMm === undefined + ? { needs: "the tube insertion depth for this series — a manufacturer's number, so it belongs in the catalogue" } + : { + mm: opts.insertionMm, + basis: 'catalogue', + reference: 'tube bottoms inside the body: insertion depth', + verified: true, + }; + + case 'none': + return { mm: 0, basis: 'rule', reference: 'nothing screws together', verified: true }; + + case 'unstated': + return { needs: 'how the fittings on this run join — set it once on the run' }; + } +} + +/** + * Which end's bore the fluid actually sees at a joint. + * + * Always the male. The female is, at the joint, a bigger hole with threads cut + * into it -- so a run whose bores were taken off the female halves would be + * reporting a restriction that is not there. This is the whole reason gender + * is part of a termination rather than a note in the margin. + */ +export function restrictingEnd(a: Termination, b: Termination): Termination { + if (a.gender === 'male' && b.gender !== 'male') return a; + if (b.gender === 'male' && a.gender !== 'male') return b; + return a; // tube-to-tube, or a pairing `whyNotMated` has already refused +} From 55c8e8bfe2fc68ee3bd9b8c4cac10603f836da4a Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Fri, 11 Sep 2026 01:08:10 -0700 Subject: [PATCH 39/57] A swage depth comes off the part, not out of this app The insertion family was the one that could never be satisfied: it asked for a depth and nothing ever supplied one, so every swage run refused for good. CatalogPart.engagementMm was already the right field -- "how far the mating part screws or inserts in" -- and nothing read it. The joint model now takes an optional lookup from part id to depth, and the panel builds one from the catalogue the fitting was picked from. The number stays the manufacturer's: a part entered with only a bore is still a valid entry and still has no depth, and a joint using it still refuses. Passed in rather than read inside the model, so the model stays testable and knows nothing about where the catalogue lives. --- .../src/components/pid/SegmentPanel.tsx | 33 ++++++++---- .../src/components/pid/segments.test.ts | 30 +++++++++++ .../frontend/src/components/pid/segments.ts | 54 ++++++++++++------- 3 files changed, 88 insertions(+), 29 deletions(-) diff --git a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx index 55fc9382c..cd0e1b1ba 100644 --- a/pid-designer/frontend/src/components/pid/SegmentPanel.tsx +++ b/pid-designer/frontend/src/components/pid/SegmentPanel.tsx @@ -6,6 +6,7 @@ import { jointFaultsOf, jointsOf, knownK, methodOf, needsOwnSize, needsThreadLength, nextRowId, nextSegmentId, overlapOf, transitionsOf, } from './segments'; +import type { PartDepths } from './segments'; import type { FittingRow, LineSegment, LossMethod } from './segments'; import { FAMILY_LABELS, MAKEUP, THREAD_PROMPT, isMissing } from './terminations'; import type { Family, Gender, Termination } from './terminations'; @@ -83,6 +84,13 @@ export function SegmentPanel({ segments, onChange }: { }) { const readOnly = useReadOnly(); const catalog = useMemo(() => loadCatalog(), []); + // A swage insertion depth is the manufacturer's figure for that series, so + // it comes from the catalogue entry the fitting was picked from -- not from + // the user, and not from a table in this app pretending to know. + const depths = useMemo(() => { + const by = new Map(catalog.map(p => [p.id, p.engagementMm])); + return (partId: string) => by.get(partId); + }, [catalog]); const transitions = useMemo(() => transitionsOf(segments), [segments]); const [showMethod, setShowMethod] = useState(false); /** @@ -316,11 +324,12 @@ export function SegmentPanel({ segments, onChange }: { readOnly={readOnly} segmentBore={seg.bore?.value} segment={seg} + depths={depths} onChange={rows => patch(i, { fittings: rows })} /> )} - +
{transitions[i] && ( @@ -354,7 +363,7 @@ export function SegmentPanel({ segments, onChange }: { * body length, because a partial subtraction is a mis-cut part rather than an * approximate one. */ -function Summary({ seg }: { seg: LineSegment }) { +function Summary({ seg, depths }: { seg: LineSegment; depths: PartDepths }) { const bits: string[] = []; const lenMm = mmOf(seg.length); if (lenMm !== null) { @@ -372,22 +381,22 @@ function Summary({ seg }: { seg: LineSegment }) { // it says nothing. A run that *has* joints but cannot price them says which // answer is missing, because silence there reads as "no overlap" and sends // somebody to the bandsaw with a figure that is long by every joint. - const overlap = overlapOf(seg); - const joints = jointsOf(seg); + const overlap = overlapOf(seg, depths); + const joints = jointsOf(seg, depths); if (overlap && overlap.mm > 0) { bits.push(`joints overlap ${overlap.mm.toFixed(1)} mm`); } if (seg.lengthBasis === 'overall' && lenMm !== null) { - const cut = cutTubeOf(seg, lenMm); + const cut = cutTubeOf(seg, lenMm, depths); bits.push('needs' in cut ? `cut length needs ${cut.needs}` : `cut ${(cut.mm / 1000).toFixed(3)} m`); } - const faults = jointFaultsOf(seg); + const faults = jointFaultsOf(seg, depths); const unpriced = !overlap && faults.length === 0 && joints.length > 0 - ? jointsOf(seg).map(j => j.engagement).find(isMissing)?.needs ?? null + ? joints.map(j => j.engagement).find(isMissing)?.needs ?? null : null; if (bits.length === 0 && faults.length === 0 && !unpriced) return null; return ( @@ -421,11 +430,12 @@ function Summary({ seg }: { seg: LineSegment }) { } /** The fittings in a run: a chip each, with the common ones one click away. */ -function Fittings({ rows, readOnly, segmentBore, segment, onChange }: { +function Fittings({ rows, readOnly, segmentBore, segment, depths, onChange }: { rows: FittingRow[]; readOnly: boolean; segmentBore?: number; segment: LineSegment; + depths: PartDepths; onChange: (rows: FittingRow[]) => void; }) { const [more, setMore] = useState(false); @@ -527,7 +537,7 @@ function Fittings({ rows, readOnly, segmentBore, segment, onChange }: { {numField(r.K, v => set(r.id, { K: v }), '—', "This fitting's own K, if it was measured. Left blank it adds no loss of its own.")}
- set(r.id, { ends })} onThread={mm => set(r.id, { threadMm: mm })} onSame={() => set(r.id, { ends: undefined })} /> @@ -651,17 +661,18 @@ function JoinBy({ segment, readOnly, onChange, onSize, onThread }: { * The joints shown are against the *neighbours*, not between this fitting's * own two ends. An elbow's inlet and outlet do not screw into each other. */ -function Ends({ row, segment, readOnly, onChange, onThread, onSame }: { +function Ends({ row, segment, readOnly, depths, onChange, onThread, onSame }: { row: FittingRow; segment: LineSegment; readOnly: boolean; + depths: PartDepths; onChange: (ends: { a: Termination; b: Termination }) => void; onThread: (mm: number | undefined) => void; onSame: () => void; }) { const ends = endsOf(row, segment); const custom = row.ends !== undefined; - const { inlet, outlet } = jointsForRow(segment, row.id); + const { inlet, outlet } = jointsForRow(segment, row.id, depths); const set = (which: 'a' | 'b', next: Partial) => onChange({ ...ends, [which]: { ...ends[which], ...next } }); diff --git a/pid-designer/frontend/src/components/pid/segments.test.ts b/pid-designer/frontend/src/components/pid/segments.test.ts index fc0a27a83..0f136e53d 100644 --- a/pid-designer/frontend/src/components/pid/segments.test.ts +++ b/pid-designer/frontend/src/components/pid/segments.test.ts @@ -414,3 +414,33 @@ describe('ids of things added to a saved drawing', () => { expect(segs.some(s => s.id === added)).toBe(false); }); }); + +describe('a swage depth comes from the catalogue', () => { + const swaged = (partId?: string): LineSegment => ({ + id: 's', standard: 'tube', tubeSize: '1/2 x 0.049', joinBy: 'swage', + fittings: [{ id: 'a', kind: 'elbow_90', count: 2, lengthMm: 30, partId }], + }); + + it('asks for a part rather than inventing an insertion depth', () => { + // The number is the manufacturer's for that series. This app does not + // know it and must not make one up. + const cut = cutTubeOf(swaged(), 1000); + if ('needs' in cut) expect(cut.needs).toMatch(/insertion depth/); + else throw new Error('an uncatalogued swage joint has no depth'); + }); + + it('uses the depth off the part the fitting was picked from', () => { + const depths = (id: string) => (id === 'SS-810-9' ? 11.4 : undefined); + expect(overlapOf(swaged('SS-810-9'), depths)).toEqual({ mm: 11.4, unverified: 0 }); + const cut = cutTubeOf(swaged('SS-810-9'), 1000, depths); + if ('needs' in cut) throw new Error('a catalogued depth is an answer'); + expect(cut.mm).toBeCloseTo(1000 - 60 + 11.4, 3); + }); + + it('still refuses when the part carries no depth', () => { + // A catalogue entry with only a bore is a valid entry, and it is not a + // depth. Absent means not stated. + const cut = cutTubeOf(swaged('SS-810-9'), 1000, () => undefined); + expect('needs' in cut).toBe(true); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/segments.ts b/pid-designer/frontend/src/components/pid/segments.ts index 9e0500f35..3dd7bd56b 100644 --- a/pid-designer/frontend/src/components/pid/segments.ts +++ b/pid-designer/frontend/src/components/pid/segments.ts @@ -404,7 +404,19 @@ export interface Joint { * fitting and subtracted from its own body -- which cannot be right, because * the same elbow makes up differently depending on what it is screwed into. */ -export function jointsOf(segment: LineSegment): Joint[] { +/** + * How deep a catalogued part's mate inserts, by part id. + * + * A swage insertion depth is the manufacturer's number for that series, so it + * comes from the catalogue entry rather than from this file or from the user. + * Passed in rather than read here so the model stays testable and knows + * nothing about where the catalogue lives. + */ +export type PartDepths = (partId: string) => number | undefined; + +const NO_DEPTHS: PartDepths = () => undefined; + +export function jointsOf(segment: LineSegment, depths: PartDepths = NO_DEPTHS): Joint[] { const flat = (segment.fittings ?? []).flatMap(r => Array.from({ length: Math.max(0, r.count) }, () => r)); const out: Joint[] = []; @@ -413,13 +425,16 @@ export function jointsOf(segment: LineSegment): Joint[] { const right = endsOf(flat[i + 1], segment); const a = left.b; // the outlet end of the one before const b = right.a; // the inlet end of the next + // The half that goes in is the half whose figures apply. + const male = a.gender === 'male' ? flat[i] : flat[i + 1]; out.push({ rowId: flat[i + 1].id, a, b, engagement: engagementOf(a, b, { // The fitting's own figure if it has one, else the run's. - maleThreadMm: (a.gender === 'male' ? flat[i] : flat[i + 1]).threadMm - ?? segment.joinThreadMm, + maleThreadMm: male.threadMm ?? segment.joinThreadMm, + // The catalogue's, for the one family whose depth is a part number. + insertionMm: male.partId ? depths(male.partId) : undefined, }), mismatch: whyNotMated(a, b), restricting: restrictingEnd(a, b), @@ -447,11 +462,10 @@ export function jointsOf(segment: LineSegment): Joint[] { * A row with a count of three has identical joints between its own instances, * so the first of each side is the whole story. */ -export function jointsForRow(segment: LineSegment, rowId: string): { - inlet: Joint | null; - outlet: Joint | null; -} { - const joints = jointsOf(segment); +export function jointsForRow( + segment: LineSegment, rowId: string, depths?: PartDepths, +): { inlet: Joint | null; outlet: Joint | null } { + const joints = jointsOf(segment, depths); const flat = (segment.fittings ?? []).flatMap(r => Array.from({ length: Math.max(0, r.count) }, () => r)); // `jointsOf` indexes a joint by the row on its *right*, so the joint at @@ -462,10 +476,12 @@ export function jointsForRow(segment: LineSegment, rowId: string): { return { inlet, outlet }; } -export function overlapOf(segment: LineSegment): { mm: number; unverified: number } | null { +export function overlapOf( + segment: LineSegment, depths?: PartDepths, +): { mm: number; unverified: number } | null { let mm = 0; let unverified = 0; - for (const j of jointsOf(segment)) { + for (const j of jointsOf(segment, depths)) { // A joint that cannot be made has no overlap to report. `engagementOf` // will still answer for one -- it reads the male's size and does the // arithmetic -- so without this a 1/4 male in a 1/2 female came back as a @@ -484,17 +500,19 @@ export function overlapOf(segment: LineSegment): { mm: number; unverified: numbe * Distinct because three identical elbows produce the same complaint three * times, and a panel that prints it three times reads as three faults. */ -export function jointFaultsOf(segment: LineSegment): { why: string; joints: number }[] { +export function jointFaultsOf( + segment: LineSegment, depths?: PartDepths, +): { why: string; joints: number }[] { const seen = new Map(); - for (const j of mismatchesOf(segment)) { + for (const j of mismatchesOf(segment, depths)) { seen.set(j.mismatch!, (seen.get(j.mismatch!) ?? 0) + 1); } return [...seen].map(([why, joints]) => ({ why, joints })); } /** Joints the drawing describes but the hardware could not make. */ -export const mismatchesOf = (segment: LineSegment): Joint[] => - jointsOf(segment).filter(j => j.mismatch !== null); +export const mismatchesOf = (segment: LineSegment, depths?: PartDepths): Joint[] => + jointsOf(segment, depths).filter(j => j.mismatch !== null); /** Whether a family needs a thread length stating. See `terminations.ts`. */ export const needsThreadLength = (family: Family): boolean => @@ -511,7 +529,7 @@ export const needsThreadLength = (family: Family): boolean => * Refuses rather than approximates. A cut list is a part somebody makes. */ export function cutTubeOf( - segment: LineSegment, overallMm: number, + segment: LineSegment, overallMm: number, depths?: PartDepths, ): { mm: number; unverified: number } | { needs: string } { const flat = (segment.fittings ?? []).flatMap(r => Array.from({ length: Math.max(0, r.count) }, () => r)); @@ -520,13 +538,13 @@ export function cutTubeOf( if (f.lengthMm === undefined) return { needs: 'a body length on every fitting' }; bodies += f.lengthMm; } - const overlap = overlapOf(segment); + const overlap = overlapOf(segment, depths); if (!overlap) { // A joint that cannot be made is the first thing to say; an unanswered one // comes next. Either way no length is offered. - const broken = mismatchesOf(segment)[0]; + const broken = mismatchesOf(segment, depths)[0]; if (broken) return { needs: `a joint that can be made — ${broken.mismatch}` }; - const first = jointsOf(segment).map(j => j.engagement).find(isMissing); + const first = jointsOf(segment, depths).map(j => j.engagement).find(isMissing); return { needs: first ? first.needs : 'how the joints make up' }; } return { From 81f5e0ed22481a3de84481c219055292eb3d6703 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Fri, 11 Sep 2026 21:33:48 -0700 Subject: [PATCH 40/57] Saving a symbol no longer rewrites where its numbers came from Three data-integrity faults in the config dialog, each reachable by opening it and pressing Save without touching anything: - Provenance was collapsed on the way in. A datasheet Cv (`manufacturer`) came back `measured`; a catalogue bore (`default`) came back `estimated`. The two-way select the dialog offers now stands over the four sources feed-twin's run report distinguishes, and only moves a value when somebody moves it. - Every reference was dropped. "Tescom 26-1000 datasheet rev C" is the whole reason a number can be trusted, and Save threw it away. - Spec suggestions were written as values. An untouched line saved K_minor: 0, elevation_change: 0, a roughness and a wall thickness, all tagged estimated -- numbers nobody stated, indistinguishable afterwards from numbers somebody did. The drawing's own rule is that absent means not stated, never zero. Suggestions are placeholders now ("900 J/(kg.K) if blank"), and a blank field stays absent; feed-twin fills it and says so in its report. The draft <-> ParamValue logic is its own module with tests, because the component could not be tested and this is exactly the kind of thing that regresses quietly. Also: - Dropped symbols get a tag of their own. The palette's `ROT_#` was stamped on literally, so every rotary valve was tagged `ROT_#` and the second one tripped the duplicate-tag check; a drawing in this repo has `HQD_#`, `PG_#`, `PR_#` and `ROT_#` saved as the names of real hardware. `ROT_#` is now `ROT-1`, then `ROT-2`, counting past a deleted valve's number rather than reusing it. The four palette entries with no placeholder get one, in the team's own spelling: TK-#, ENG-#, INJ-#, MF-#. - The unsized-line check read only the one-number fields and fired on every line built the recommended way, with its length and bore in segments. - Backspace deletes. A Mac keyboard has no key marked Delete. - `pressure_ratio` joins the dimensions, ahead of the regulator spec fix. --- .../src/components/pid/ConfigDialog.tsx | 55 +++++------ .../src/components/pid/PIDDesigner.tsx | 11 ++- .../frontend/src/components/pid/checks.ts | 8 +- .../src/components/pid/drafts.test.ts | 73 ++++++++++++++ .../frontend/src/components/pid/drafts.ts | 96 +++++++++++++++++++ .../frontend/src/components/pid/params.ts | 5 +- .../frontend/src/components/pid/tags.test.ts | 48 ++++++++++ .../frontend/src/components/pid/tags.ts | 46 +++++++++ .../frontend/src/components/pid/types.ts | 16 +--- 9 files changed, 313 insertions(+), 45 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/drafts.test.ts create mode 100644 pid-designer/frontend/src/components/pid/drafts.ts create mode 100644 pid-designer/frontend/src/components/pid/tags.test.ts create mode 100644 pid-designer/frontend/src/components/pid/tags.ts diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index bd0161dac..251d3717d 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -3,8 +3,10 @@ import { Modal } from '../ui'; import { btn, primaryBtn } from '../../lib/ui'; import { COMPONENT_SPECS, LINE_SPECS, LINE_TYPE_LABELS, PEER_CHOICES } from './spec'; import type { ComponentSpec, OptionSpec, ParamSpec, PortGroupSpec } from './spec'; -import { PROVENANCE_CHOICES, UNITS } from './params'; -import type { ParamValue, Provenance } from './params'; +import { UNITS } from './params'; +import type { ParamValue } from './params'; +import { fromDraft, isVerified, pickProvenance, placeholderFor, toDraft } from './drafts'; +import type { Draft } from './drafts'; import { portIds } from './ports'; import type { PortInfo, PortKind } from './ports'; import { defaultTemperatureK, speciesById } from './fluids'; @@ -57,8 +59,6 @@ interface Props { onSave: (patch: ConfigPatch) => void; } -type Draft = { value: string; unit: string; source: Provenance }; - const EMPTY: Draft = { value: '', unit: '', source: 'estimated' }; const field = @@ -66,23 +66,6 @@ const field = const wide = `${field} w-full`; const rowLabel = 'text-[11px] text-[var(--color-text-secondary)]'; -function toDraft(spec: ParamSpec, existing?: ParamValue): Draft { - const units = UNITS[spec.dimension]; - if (existing) { - return { - value: String(existing.value), - unit: existing.unit || units[0], - source: existing.source === 'measured' || existing.source === 'manufacturer' - ? 'measured' : 'estimated', - }; - } - return { - ...EMPTY, - unit: spec.suggested?.unit ?? units[0], - value: spec.suggested ? String(spec.suggested.value) : '', - }; -} - export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSave }: Props) { const type = data.componentType as ComponentType; @@ -135,7 +118,13 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav const untouched = t.value.trim() === '' || t.value === lastAutoTemp.current; if (!untouched) return d; lastAutoTemp.current = String(k); - return { ...d, temperature: { ...t, value: String(k), unit: 'K' } }; + // Written as what it is: a default that follows from the fluid, with + // the reason named -- so a run report counts it as assumed rather than + // as a measurement somebody made. + return { ...d, temperature: { + ...t, value: String(k), unit: 'K', source: 'default', + reference: `${speciesById(fluid)?.label ?? fluid} at its usual state`, + } }; }); }, [open, fluid, type, spec]); @@ -145,10 +134,8 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav const params: Record = {}; for (const p of spec.params) { if (p.derived) continue; // computed below, never typed - const d = drafts[p.key]; - if (!d || d.value.trim() === '') continue; // absent, not zero - const value = Number(d.value); - if (Number.isFinite(value)) params[p.key] = { value, unit: d.unit, source: d.source }; + const v = fromDraft(drafts[p.key]); // blank is absent, not zero + if (v) params[p.key] = v; } // Counted, not asked for. Only when there is a list to count: with no // segments the drawing has not said, and a zero would be a claim. @@ -403,11 +390,14 @@ function ParamRow({ spec, draft, readOnly, onChange }: {
onChange({ value: e.target.value })} className={`${field} min-w-0`} + title={draft.reference || undefined} /> onChange({ source: e.target.value as Provenance })} + onChange={e => onChange(pickProvenance(draft, e.target.value === 'verified'))} className={`${field} min-w-0`} + title={draft.reference ? `${draft.source}: ${draft.reference}` : draft.source} > - {PROVENANCE_CHOICES.map(c => )} + + ) : }
diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx index 144d1e966..95642a024 100644 --- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx +++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx @@ -36,6 +36,7 @@ import { BranchableEdge } from './BranchableEdge'; import { nextNodeId, seedIdsFrom } from './ids'; import { defFor } from './types'; import type { PIDNodeData } from './types'; +import { numberTag } from './tags'; import { ConfigDialog } from './ConfigDialog'; import type { ConfigPatch } from './ConfigDialog'; import { FluidProvider } from './FluidContext'; @@ -707,7 +708,11 @@ function PIDCanvas({ ? { page } : { componentType: type, - label: def.label, + // `ROT_#` becomes `ROT-3`, or whatever is next. The placeholder used + // to be stamped on as-is, so every rotary valve was tagged `ROT_#` + // and the second one tripped the duplicate-tag check. + label: numberTag(def.label, snapshot.current.nodes + .map(n => (n.data as unknown as PIDNodeData)?.label ?? '')), fluidType: 'default', // The palette entry's preset, plus every option's declared default, // so a symbol is never drawn in a state its own config disagrees @@ -998,7 +1003,9 @@ function PIDCanvas({ nodesConnectable={!readOnly} elementsSelectable={!readOnly} edgesReconnectable={!readOnly} - deleteKeyCode={readOnly ? null : 'Delete'} + // Both, because a Mac keyboard has no key marked Delete -- it has + // Backspace, and pressing it did nothing. + deleteKeyCode={readOnly ? null : ['Delete', 'Backspace']} selectionOnDrag={!readOnly && mode === 'select'} panOnDrag={readOnly || mode !== 'select'} selectionMode={SelectionMode.Partial} diff --git a/pid-designer/frontend/src/components/pid/checks.ts b/pid-designer/frontend/src/components/pid/checks.ts index ff1c1e16b..a7124ce21 100644 --- a/pid-designer/frontend/src/components/pid/checks.ts +++ b/pid-designer/frontend/src/components/pid/checks.ts @@ -193,9 +193,15 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { } // ── Lines ───────────────────────────────────────────────────────────────── + // Sized one of two ways: the one-number fields, or the itemised run, whose + // segments carry the length and bore instead. The check used to read only + // the first and fired on every line built the recommended way. const bare = edges.filter(e => { const d = edgeDataOf(e); - return !d.partNumber && !(d.params?.length && d.params?.bore); + if (d.partNumber) return false; + if (d.params?.length && d.params?.bore) return false; + const segs = d.segments ?? []; + return !(segs.length > 0 && segs.every(s => s.length && s.bore)); }); if (bare.length) { push({ diff --git a/pid-designer/frontend/src/components/pid/drafts.test.ts b/pid-designer/frontend/src/components/pid/drafts.test.ts new file mode 100644 index 000000000..17ff4e469 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/drafts.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { fromDraft, pickProvenance, placeholderFor, toDraft } from './drafts'; +import type { ParamSpec } from './spec'; + +const cv: ParamSpec = { key: 'Cv', label: 'Cv', dimension: 'flow_coefficient' }; +const k: ParamSpec = { + key: 'K_minor', label: 'Lumped fitting K', dimension: 'dimensionless', + suggested: { value: 0, unit: '-' }, +}; + +describe('a number survives being opened and saved', () => { + it('keeps a manufacturer source as manufacturer', () => { + // The bug: opening the dialog turned every datasheet number into + // "measured" and every catalogue default into "estimated". + const out = fromDraft(toDraft(cv, { value: 0.8, unit: 'Cv', source: 'manufacturer', + reference: 'Tescom 26-1000 datasheet rev C' })); + expect(out).toEqual({ value: 0.8, unit: 'Cv', source: 'manufacturer', + reference: 'Tescom 26-1000 datasheet rev C' }); + }); + + it('keeps a catalogue default as default, with its reference', () => { + const out = fromDraft(toDraft(cv, { value: 10.21, unit: 'mm', source: 'default', + reference: 'catalogue: 1/2 x 0.049 tube' })); + expect(out?.source).toBe('default'); + expect(out?.reference).toBe('catalogue: 1/2 x 0.049 tube'); + }); + + it('never drops the reference', () => { + const out = fromDraft(toDraft(cv, { value: 4, unit: 'Cv', source: 'measured', + reference: 'flow bench 2026-08-12' })); + expect(out?.reference).toBe('flow bench 2026-08-12'); + }); +}); + +describe('a suggestion is not a value', () => { + it('leaves a field with a suggestion blank', () => { + // Save on an untouched line used to write K_minor: 0, estimated. + expect(toDraft(k).value).toBe(''); + expect(fromDraft(toDraft(k))).toBeUndefined(); + }); + + it('shows the suggestion as what happens if nothing is typed', () => { + expect(placeholderFor(k)).toBe('0 - if blank'); + expect(placeholderFor(cv)).toBe('—'); + }); + + it('takes the suggested unit so a typed number lands in it', () => { + expect(toDraft({ ...k, suggested: { value: 1.5e-3, unit: 'mm' }, dimension: 'length' }).unit).toBe('mm'); + }); + + it('writes nothing for blank, garbage or whitespace', () => { + expect(fromDraft({ value: '', unit: 'Cv', source: 'estimated' })).toBeUndefined(); + expect(fromDraft({ value: ' ', unit: 'Cv', source: 'estimated' })).toBeUndefined(); + expect(fromDraft({ value: 'abc', unit: 'Cv', source: 'estimated' })).toBeUndefined(); + }); +}); + +describe('the two-way provenance select', () => { + const sheet = { value: '0.8', unit: 'Cv', source: 'manufacturer' as const, reference: 'datasheet' }; + + it('changes nothing when left where it already reads', () => { + expect(pickProvenance(sheet, true)).toBe(sheet); + }); + + it('moves to the plain member of the other pair and drops the reference', () => { + // "Estimate" said of a datasheet number: the sheet no longer describes it. + expect(pickProvenance(sheet, false)).toEqual({ value: '0.8', unit: 'Cv', source: 'estimated' }); + }); + + it('promotes an estimate to measured, not manufacturer', () => { + expect(pickProvenance({ value: '4', unit: 'Cv', source: 'estimated' }, true).source).toBe('measured'); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/drafts.ts b/pid-designer/frontend/src/components/pid/drafts.ts new file mode 100644 index 000000000..94f285d11 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/drafts.ts @@ -0,0 +1,96 @@ +/** + * What a config dialog holds while a number is being edited, and how that + * becomes a `ParamValue` again. + * + * Two rules, both about not losing what was there: + * + * **Provenance survives a round trip.** The dialog offers two choices -- + * verified or estimate -- because that is the question a person can answer. + * Underneath, `feedtwin.model.Param` distinguishes four, and a run report + * cares about all four: a catalogue bore is `default` with a reference naming + * the catalogue, a datasheet Cv is `manufacturer` with the sheet named. The + * old draft collapsed those to `estimated` and `measured` on the way *in*, so + * opening a dialog and pressing Save rewrote the provenance of every number + * on the symbol and dropped every reference -- silently, on the button people + * press most. A draft now carries the source it was given and the reference + * with it, and only changes them when somebody changes them. + * + * **A suggestion is not a value.** `spec.suggested` used to be written into + * the draft as if typed, so Save on an untouched line wrote `K_minor: 0`, + * `elevation_change: 0`, a roughness and a wall thickness, all tagged + * `estimated` -- numbers nobody stated, indistinguishable afterwards from + * numbers somebody did. The drawing's own rule is that absent means not + * stated, never zero. So a suggestion is shown as a placeholder, and a field + * left blank stays absent; feed-twin fills it and says so in its report. + */ + +import type { ParamSpec } from './spec'; +import type { ParamValue, Provenance } from './params'; +import { UNITS } from './params'; + +export interface Draft { + value: string; + unit: string; + source: Provenance; + reference?: string; +} + +/** The two answers the dialog offers, and which of the four each stands for. */ +export const VERIFIED: ReadonlySet = new Set(['measured', 'manufacturer']); + +export const isVerified = (source: Provenance) => VERIFIED.has(source); + +export function toDraft(spec: ParamSpec, existing?: ParamValue): Draft { + const units = UNITS[spec.dimension]; + if (existing) { + return { + value: String(existing.value), + unit: existing.unit || units[0], + source: existing.source, + ...(existing.reference ? { reference: existing.reference } : {}), + }; + } + return { value: '', unit: spec.suggested?.unit ?? units[0], source: 'estimated' }; +} + +/** + * The value a draft stands for, or nothing. + * + * Blank is absent. Not zero, not the suggestion: absent. A reference is kept + * whenever there is one, because "Tescom 26-1000 datasheet rev C" is the + * whole reason the number can be trusted. + */ +export function fromDraft(draft: Draft | undefined): ParamValue | undefined { + if (!draft) return undefined; + const text = draft.value.trim(); + if (text === '') return undefined; + const value = Number(text); + if (!Number.isFinite(value)) return undefined; + return { + value, + unit: draft.unit, + source: draft.source, + ...(draft.reference ? { reference: draft.reference } : {}), + }; +} + +/** + * What the user picked in the two-way select, applied to a draft. + * + * Picking the answer a draft already gives changes nothing -- so a + * `manufacturer` value whose select reads "verified" stays `manufacturer` + * when the select is left where it is. Picking the other answer moves to the + * plain member of that pair, and drops the reference, which was about the + * number's old standing and no longer describes it. + */ +export function pickProvenance(draft: Draft, verified: boolean): Draft { + if (isVerified(draft.source) === verified) return draft; + // Explicitly undefined rather than omitted: the dialog spreads this over + // the draft it holds, and an omitted key would leave the old reference. + return { ...draft, source: verified ? 'measured' : 'estimated', reference: undefined }; +} + +/** The placeholder a blank field shows: the suggestion, said as a suggestion. */ +export function placeholderFor(spec: ParamSpec): string { + return spec.suggested ? `${spec.suggested.value} ${spec.suggested.unit} if blank` : '—'; +} diff --git a/pid-designer/frontend/src/components/pid/params.ts b/pid-designer/frontend/src/components/pid/params.ts index 293be1626..89c977185 100644 --- a/pid-designer/frontend/src/components/pid/params.ts +++ b/pid-designer/frontend/src/components/pid/params.ts @@ -59,7 +59,7 @@ export interface ParamValue { export type Dimension = | 'pressure' | 'temperature' | 'length' | 'volume' | 'flow_coefficient' | 'dimensionless' | 'time' | 'mass' | 'mass_flow' | 'angle' - | 'specific_heat' | 'thermal_conductance'; + | 'specific_heat' | 'thermal_conductance' | 'conductivity' | 'pressure_ratio'; /** * Units offered per dimension, in the spelling `feedtwin.model.units` uses. @@ -78,6 +78,9 @@ export const UNITS: Record = { angle: ['deg', 'rad'], specific_heat: ['J/(kg.K)'], thermal_conductance: ['W/K'], + conductivity: ['W/(m.K)'], + // How a datasheet writes a supply-pressure effect: "17 psi per 1000 psi". + pressure_ratio: ['psi/1000psi', 'psi/100psi', 'psi/psi', 'bar/bar'], }; /** Pressures are absolute. Said in the UI, next to the field. */ diff --git a/pid-designer/frontend/src/components/pid/tags.test.ts b/pid-designer/frontend/src/components/pid/tags.test.ts new file mode 100644 index 000000000..415f1cfa3 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/tags.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { isTemplateTag, numberTag, stemOf } from './tags'; + +describe('a dropped symbol gets a tag of its own', () => { + it('fills the number in', () => { + // The bug: every rotary valve landed as `ROT_#`, and the second one + // tripped the duplicate-tag check on a drawing nobody had done anything + // wrong on. + expect(numberTag('ROT_#', [])).toBe('ROT-1'); + expect(numberTag('PT-HP_#', [])).toBe('PT-HP-1'); + expect(numberTag('TK-#', [])).toBe('TK-1'); + }); + + it('never reissues one already on the drawing', () => { + expect(numberTag('ROT_#', ['ROT-1', 'ROT-2'])).toBe('ROT-3'); + }); + + it('counts past a gap rather than filling it', () => { + // ROT-2 was deleted. A procedure written against it is about a valve that + // is gone, and the next one placed must not inherit that name. + expect(numberTag('ROT_#', ['ROT-1', 'ROT-3'])).toBe('ROT-4'); + }); + + it('keeps stems apart', () => { + // PT-HP-1 does not use up PT-1, and TK-1 does not use up TK-FU. + expect(numberTag('PT_#', ['PT-HP-1', 'PT-HP-2'])).toBe('PT-1'); + expect(numberTag('TK-#', ['TK-FU', 'TK-LOX'])).toBe('TK-1'); + }); + + it('is unmoved by tags people have renamed', () => { + expect(numberTag('ROT_#', ['MV-OX', 'MV-FU', 'ROT-1'])).toBe('ROT-2'); + }); + + it('leaves a label with no placeholder alone', () => { + expect(numberTag('Section', ['Section'])).toBe('Section'); + }); + + it('reads a stem off either spelling of the placeholder', () => { + expect(stemOf('ROT_#')).toBe('ROT'); + expect(stemOf('TK-#')).toBe('TK'); + expect(stemOf('ENG')).toBe('ENG'); + }); + + it('can tell a template from a tag', () => { + expect(isTemplateTag('ROT_#')).toBe(true); + expect(isTemplateTag('ROT-1')).toBe(false); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/tags.ts b/pid-designer/frontend/src/components/pid/tags.ts new file mode 100644 index 000000000..23a3096d3 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/tags.ts @@ -0,0 +1,46 @@ +/** + * Tags for newly placed symbols. + * + * A palette entry's label is a template -- `ROT_#`, `PT-HP_#` -- and the `#` + * was never filled in. Every rotary valve dropped was tagged, literally, + * `ROT_#`; the second one tripped the duplicate-tag check, and a drawing in + * this repo has `HQD_#`, `PG_#`, `PR_#` and `ROT_#` saved as the names of real + * hardware. A tag is what a solve, a report and a procedure all key on, so a + * symbol has to land with one that is its own. + * + * The form follows the team's drawings: a hyphenated stem and a number, + * `ROT-1`, `PT-HP-2`, `TK-1`. People rename most of them to something that + * says what they do -- `MV-OX`, `TK-FU` -- and that is the point: the number + * is a placeholder that is at least unique, not a naming scheme. + */ + +/** `ROT_#` → `ROT`; `TK-#` → `TK`; a label with no `#` is its own stem. */ +export function stemOf(template: string): string { + return template.replace(/[-_]?#\s*$/, ''); +} + +/** + * The next free tag for a template, given every tag already on the drawing. + * + * One past the highest number in use for that stem, never a gap. Filling a + * gap would hand a deleted valve's tag to a new one, and a procedure written + * against the old ROT-3 would then be about a different valve. + */ +export function numberTag(template: string, existing: Iterable): string { + const stem = stemOf(template); + if (!template.includes('#')) return template; + const re = new RegExp(`^${escape(stem)}-(\\d+)$`); + let highest = 0; + for (const tag of existing) { + const m = re.exec(tag.trim()); + if (m) highest = Math.max(highest, Number(m[1])); + } + return `${stem}-${highest + 1}`; +} + +/** Whether a tag is still an unfilled template: the bug this file fixes. */ +export const isTemplateTag = (tag: string) => /#\s*$/.test(tag); + +function escape(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/pid-designer/frontend/src/components/pid/types.ts b/pid-designer/frontend/src/components/pid/types.ts index bcdcfaab3..66de5a022 100644 --- a/pid-designer/frontend/src/components/pid/types.ts +++ b/pid-designer/frontend/src/components/pid/types.ts @@ -10,15 +10,9 @@ export type ComponentType = | 'TEXT' | 'REGION' | 'JUNCTION'; +/** Superseded by species -- see `fluids.ts`. Kept so old drawings still read. */ export type FluidType = 'fuel' | 'lox' | 'pressurant' | 'default'; -export const FLUID_COLORS: Record = { - fuel: '#f97316', - lox: '#60a5fa', - pressurant: '#ef4444', - default: '#94a3b8', -}; - export interface PIDNodeData { componentType: ComponentType; label: string; @@ -128,12 +122,12 @@ export const COMPONENT_DEFS: ComponentDef[] = [ { id: 'QD_H', type: 'QD', label: 'HQD_#', fullName: 'Hydraulic QD', group: 'Flow Control', preset: { service: 'hydraulic' } }, - { id: 'TANK', type: 'TANK', label: 'TANK', fullName: 'Tank', group: 'Hardware' }, + { id: 'TANK', type: 'TANK', label: 'TK-#', fullName: 'Tank', group: 'Hardware' }, { id: 'KBOTTLE', type: 'KBOTTLE', label: 'KB_#', fullName: 'Pressurant bottle', group: 'Supplies', fluid: 'nitrogen' }, { id: 'DEWAR', type: 'DEWAR', label: 'DW_#', fullName: 'Dewar', group: 'Supplies', fluid: 'nitrogen' }, - { id: 'MANIFOLD', type: 'MANIFOLD', label: 'MAN-F', fullName: 'Manifold', group: 'Hardware' }, - { id: 'ENGINE', type: 'ENGINE', label: 'ENG', fullName: 'Injector + chamber', group: 'Hardware' }, - { id: 'INJECTOR', type: 'INJECTOR', label: 'INJ', fullName: 'Injector only', group: 'Hardware' }, + { id: 'MANIFOLD', type: 'MANIFOLD', label: 'MF-#', fullName: 'Manifold', group: 'Hardware' }, + { id: 'ENGINE', type: 'ENGINE', label: 'ENG-#', fullName: 'Injector + chamber', group: 'Hardware' }, + { id: 'INJECTOR', type: 'INJECTOR', label: 'INJ-#', fullName: 'Injector only', group: 'Hardware' }, { id: 'REGION', type: 'REGION', label: 'Section', fullName: 'Section box', group: 'Annotation' }, { id: 'TEXT', type: 'TEXT', label: 'Text', fullName: 'Text', group: 'Annotation' }, From 03b91b00ac551e95d99210fb0656b3f5dea81d55 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Fri, 11 Sep 2026 21:40:00 -0700 Subject: [PATCH 41/57] The regulator dialog now feeds the solver, and the sheet checks its own numbers The regulator spec offered `supply_effect_out` and `supply_effect_in` -- two fields under names that appear nowhere in feed-twin's catalogue and that nothing read. A datasheet number typed there went nowhere. What the catalogue actually reads (`supply_coefficient`, `inlet_reference`, `flow_droop`, `rated_flow`, `lockup_rise`, `min_inlet_differential`) was not settable at all -- and feed-twin's own notes say a regulator with no droop is one it cannot solve transiently, because a perfect regulator's branch equation is true for every mass flow. The spec now carries the catalogue's names; the valve gains xT, FL and seat leak; the check valve gains reverse leak. The parity test that guarded line params now guards the inline components too, in both directions, so PR cannot drift again. Checks that a reviewer does by eye, done by the drawing: - A relief set above its vessel's MAWP is an error: it would not open before the tank failed. Set at or below the operating pressure is also an error: it would be open the whole time. A tank run above its own rating is an error. The vessel is found by walking from the relief through lines, junctions and manifolds and never through a valve, past which it would be protecting something else. Nothing fires unless both numbers are stated -- a missing MAWP is not a fault. - A check valve facing against the flow is a warning. Every symbol's hop count from the nearest source says which side the flow arrives on, and that had better be the inlet. Silent when the two sides are equidistant, which is what a fill line between a dewar and a tank looks like. - Joints priced from an unchecked NPT engagement are counted, so a cut list built on the seeded figures does not read as a citation. - A fresh tank gets one row naming everything it lacks. Two used to put six amber rows in the panel, which is the wall of the same thing said again that people stop reading. On the symbols: the check valve now carries an arrow, because its direction is the whole component and the old ball-and-seat read either way; the regulator draws its setpoint the way the relief draws its set pressure; a junction takes the colour of the pipe it is in rather than sitting on it as a grey dot; and every number on a symbol is formatted by one function, so the engine no longer prints `300psi` next to a relief printing `650 psi`. --- .../src/components/pid/ConfigDialog.tsx | 2 +- .../src/components/pid/checks.test.ts | 121 ++++++++++- .../frontend/src/components/pid/checks.ts | 199 +++++++++++++++++- .../frontend/src/components/pid/fmt.test.ts | 24 +++ .../frontend/src/components/pid/fmt.ts | 25 +++ .../components/pid/nodes/CheckValveNode.tsx | 22 +- .../src/components/pid/nodes/EngineNode.tsx | 3 +- .../src/components/pid/nodes/JunctionNode.tsx | 13 +- .../src/components/pid/nodes/PRNode.tsx | 22 +- .../src/components/pid/nodes/RVNode.tsx | 5 +- .../src/components/pid/nodes/SupplyNode.tsx | 3 +- .../frontend/src/components/pid/params.ts | 18 ++ .../frontend/src/components/pid/ports.ts | 9 + .../frontend/src/components/pid/spec.ts | 33 ++- pid-designer/tests/test_spec_parity.py | 144 +++++++++++++ 15 files changed, 608 insertions(+), 35 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/fmt.test.ts create mode 100644 pid-designer/frontend/src/components/pid/fmt.ts create mode 100644 pid-designer/tests/test_spec_parity.py diff --git a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx index 251d3717d..99bd66406 100644 --- a/pid-designer/frontend/src/components/pid/ConfigDialog.tsx +++ b/pid-designer/frontend/src/components/pid/ConfigDialog.tsx @@ -289,7 +289,7 @@ export function ConfigDialog({ open, onClose, kind, data, peers, readOnly, onSav className="text-[10px] text-[var(--color-text-muted)] underline decoration-dotted hover:text-[var(--color-text-primary)]"> {showAdvanced ? 'fewer' - : `${spec.params.filter(p => p.advanced).length} more (wall, roughness, head)`} + : `${spec.params.filter(p => p.advanced).length} more`} {showAdvanced && spec.params.filter(p => p.advanced && !p.derived).map(p => ( { }); describe('boundary conditions a solve cannot start without', () => { - it('asks a tank for pressure, temperature and a fluid', () => { + it('asks a tank for pressure, temperature and a fluid, in one breath', () => { + // One row naming all three, not three rows. See the grouped form below. const t = titles([node('TK-1', 'TANK')]); - expect(t.some(x => x.includes('operating pressure'))).toBe(true); - expect(t.some(x => x.includes('propellant temperature'))).toBe(true); - expect(t.some(x => x.includes('fluid'))).toBe(true); + const row = t.find(x => x.startsWith('TK-1 has no'))!; + expect(row).toContain('pressure'); + expect(row).toContain('temperature'); + expect(row).toContain('fluid'); + expect(t.filter(x => x.startsWith('TK-1 has no'))).toHaveLength(1); }); it('asks an engine for a chamber pressure', () => { @@ -144,3 +147,113 @@ describe('what the badge counts', () => { (a, b) => ({ error: 0, warning: 1, info: 2 })[a] - ({ error: 0, warning: 1, info: 2 })[b])); }); }); + +describe('a relief valve against the vessel it protects', () => { + const psi = (value: number, source = 'manufacturer') => ({ value, unit: 'psi', source }); + const tank = (id: string, pressure?: number, mawp?: number) => node(id, 'TANK', { + fluid: 'oxygen', + params: { + ...(pressure !== undefined ? { pressure: psi(pressure) } : {}), + temperature: { value: 90, unit: 'K', source: 'measured' }, + ...(mawp !== undefined ? { MAWP: psi(mawp) } : {}), + }, + }); + const rv = (id: string, set: number) => node(id, 'RV', { params: { set_pressure: psi(set) } }); + const onTank = [edge('e', 'RV-1', 'TK-1', 'r', 't2')]; + + it('is quiet when the relief lifts between operating pressure and MAWP', () => { + const found = ids([tank('TK-1', 500, 800), rv('RV-1', 650)], onTank); + expect(found.filter(i => /relief|mawp/.test(i))).toEqual([]); + }); + + it('flags a relief set above the MAWP', () => { + // It would not open before the tank failed. + const found = runChecks([tank('TK-1', 500, 800), rv('RV-1', 900)], onTank); + const f = found.find(x => x.id === 'relief-over-mawp-RV-1')!; + expect(f.severity).toBe('error'); + expect(f.nodeIds).toEqual(['RV-1', 'TK-1']); + }); + + it('flags a relief set at or below the operating pressure', () => { + // It would be open the whole time. + expect(ids([tank('TK-1', 500, 800), rv('RV-1', 500)], onTank)).toContain('relief-under-operating-RV-1'); + }); + + it('flags a tank run above its own rating', () => { + expect(ids([tank('TK-1', 900, 800)])).toContain('vessel-over-mawp-TK-1'); + }); + + it('compares across units', () => { + // 60 bar is 870 psi, above an 800 psi rating. + const t = node('TK-1', 'TANK', { fluid: 'oxygen', params: { + pressure: psi(500), temperature: { value: 90, unit: 'K', source: 'measured' }, MAWP: psi(800) } }); + const r = node('RV-1', 'RV', { params: { set_pressure: { value: 60, unit: 'bar', source: 'manufacturer' } } }); + expect(ids([t, r], onTank)).toContain('relief-over-mawp-RV-1'); + }); + + it('finds the tank through a junction, but not through a valve', () => { + const j = node('J', 'JUNCTION'); + const viaJunction = [edge('a', 'RV-1', 'J', 'r', 'l'), edge('b', 'J', 'TK-1', 't', 't2')]; + expect(ids([tank('TK-1', 500, 800), rv('RV-1', 900), j], viaJunction)).toContain('relief-over-mawp-RV-1'); + // Past a valve it is protecting something else, and this drawing has not + // said what. + const v = node('SOL-1', 'SOL'); + const viaValve = [edge('a', 'RV-1', 'SOL-1', 'r', 'l'), edge('b', 'SOL-1', 'TK-1', 'r', 't2')]; + expect(ids([tank('TK-1', 500, 800), rv('RV-1', 900), v], viaValve)).not.toContain('relief-over-mawp-RV-1'); + }); + + it('says nothing when either number is not stated', () => { + // A missing MAWP is not a fault, it is Tuesday. + expect(ids([tank('TK-1', 500), rv('RV-1', 900)], onTank).filter(i => /relief/.test(i))).toEqual([]); + }); +}); + +describe('a check valve against the flow', () => { + const tank = (id: string) => node(id, 'TANK', { fluid: 'oxygen', params: { + pressure: { value: 500, unit: 'psi', source: 'measured' }, + temperature: { value: 90, unit: 'K', source: 'measured' } } }); + const cv = (id: string) => node(id, 'CV'); + const valve = (id: string) => node(id, 'MAN'); + + it('is quiet when the inlet faces the source', () => { + // tank -> CV(l ... r) -> valve + const edges = [edge('a', 'TK-1', 'CV-1', 'b', 'l'), edge('b', 'CV-1', 'MV-1', 'r', 'l')]; + expect(ids([tank('TK-1'), cv('CV-1'), valve('MV-1')], edges)).not.toContain('cv-backwards-CV-1'); + }); + + it('flags one whose inlet is on the far side from the source', () => { + const edges = [edge('a', 'TK-1', 'CV-1', 'b', 'r'), edge('b', 'CV-1', 'MV-1', 'l', 'l')]; + const f = runChecks([tank('TK-1'), cv('CV-1'), valve('MV-1')], edges).find(x => x.id === 'cv-backwards-CV-1')!; + expect(f.severity).toBe('warning'); + }); + + it('does not judge one plumbed on a single side, or with no source', () => { + expect(ids([tank('TK-1'), cv('CV-1')], [edge('a', 'TK-1', 'CV-1', 'b', 'r')])).not.toContain('cv-backwards-CV-1'); + const edges = [edge('a', 'MV-2', 'CV-1', 'r', 'r'), edge('b', 'CV-1', 'MV-1', 'l', 'l')]; + expect(ids([valve('MV-2'), cv('CV-1'), valve('MV-1')], edges)).not.toContain('cv-backwards-CV-1'); + }); +}); + +describe('what a fresh drawing says about itself', () => { + it('lists everything a tank lacks on one row', () => { + // Two fresh tanks used to put six amber rows in the panel. + const found = runChecks([node('TK-1', 'TANK'), node('TK-2', 'TANK')], []); + const rows = found.filter(f => f.id.startsWith('missing-')); + expect(rows).toHaveLength(2); + expect(rows[0].title).toBe('TK-1 has no fluid, pressure or temperature'); + }); + + it('counts joints priced from an unchecked NPT figure', () => { + const e = { + id: 'L1', source: 'A', target: 'B', + data: { segments: [{ + id: 's', standard: 'NPT', tubeSize: '1/2', joinBy: 'NPT', joinSize: '1/2', + fittings: [{ id: 'f', kind: 'elbow_90', count: 3 }], + }] }, + } as unknown as Edge; + const f = runChecks([node('A', 'MAN'), node('B', 'MAN')], [e]).find(x => x.id === 'joints-unchecked')!; + expect(f.severity).toBe('info'); + expect(f.title).toMatch(/^2 joints/); + expect(f.edgeIds).toEqual(['L1']); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/checks.ts b/pid-designer/frontend/src/components/pid/checks.ts index a7124ce21..1084c2027 100644 --- a/pid-designer/frontend/src/components/pid/checks.ts +++ b/pid-designer/frontend/src/components/pid/checks.ts @@ -3,7 +3,9 @@ import { propagateFluids, speciesById } from './fluids'; import { isInstrument } from './attach'; import { crossPageEdges, listPages, pageOf } from './pages'; import { findVents } from './vents'; -import { portsOf, portIsDrawn } from './ports'; +import { portsOf, portIsDrawn, CV_INLET } from './ports'; +import { toPa } from './params'; +import { overlapOf } from './segments'; import type { PIDNodeData, PIDEdgeData } from './types'; /** @@ -175,23 +177,105 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { } // ── Boundary conditions a solve cannot start without ────────────────────── + // One row per vessel saying everything it lacks. Two fresh tanks used to + // put six amber rows in the panel -- pressure, temperature, fluid, twice -- + // which is the wall of the same thing said again that people stop reading. for (const n of nodes) { const d = dataOf(n); const t = d?.componentType; if (t === 'TANK') { - if (!d.params?.pressure) missing(push, n, 'an operating pressure', - 'A solve starts here; without it there is no boundary condition.'); - if (!d.params?.temperature) missing(push, n, 'a propellant temperature', - 'No temperature, no density, no flow.'); - if (!speciesById(d.fluid)) missing(push, n, 'a fluid', - 'Set it here and every line downstream inherits it.'); + const lacks: string[] = []; + if (!speciesById(d.fluid)) lacks.push('a fluid'); + if (!d.params?.pressure) lacks.push('a pressure'); + if (!d.params?.temperature) lacks.push('a temperature'); + if (lacks.length) missing(push, n, lacks, + 'A solve starts at a tank: its fluid, pressure and temperature are the boundary condition, and every line downstream inherits the fluid.'); } if (t === 'ENGINE') { - if (!d.params?.chamber_pressure) missing(push, n, 'a chamber pressure', + if (!d.params?.chamber_pressure) missing(push, n, ['a chamber pressure'], 'It is the back pressure the whole feed works against.'); } } + // ── Relief against the vessel it protects ───────────────────────────────── + // The one number a reviewer checks first on a sheet: does the thing + // protecting a vessel lift below what the vessel is rated to. Both figures + // are on the drawing, so the drawing can say. Only when both are stated -- + // a missing MAWP is not a fault, it is Tuesday. + const vesselOf = protectedVessels(nodes, edges); + for (const n of nodes) { + const d = dataOf(n); + const t = d?.componentType; + if (t === 'TANK') { + const p = toPa(d.params?.pressure); + const mawp = toPa(d.params?.MAWP); + if (p !== undefined && mawp !== undefined && p > mawp) { + push({ + id: `vessel-over-mawp-${n.id}`, + severity: 'error', + title: `${nameOf(n)} runs above its MAWP`, + detail: `Operating pressure ${fmt(d.params!.pressure!)} against a rating of ${fmt(d.params!.MAWP!)}.`, + nodeIds: [n.id], + }); + } + } + if (t !== 'RV') continue; + const vessel = vesselOf.get(n.id); + if (!vessel) continue; + const v = dataOf(vessel); + const set = toPa(d.params?.set_pressure); + if (set === undefined) continue; + const mawp = toPa(v.params?.MAWP); + const op = toPa(v.params?.pressure); + if (mawp !== undefined && set > mawp) { + push({ + id: `relief-over-mawp-${n.id}`, + severity: 'error', + title: `${nameOf(n)} lifts above ${nameOf(vessel)}'s MAWP`, + detail: `Set at ${fmt(d.params!.set_pressure!)}; the vessel is rated to ${fmt(v.params!.MAWP!)}. It would not open before the tank failed.`, + nodeIds: [n.id, vessel.id], + }); + } else if (op !== undefined && set <= op) { + push({ + id: `relief-under-operating-${n.id}`, + severity: 'error', + title: `${nameOf(n)} is set at or below ${nameOf(vessel)}'s operating pressure`, + detail: `Set at ${fmt(d.params!.set_pressure!)} with the tank run at ${fmt(v.params!.pressure!)}. It would be open the whole time.`, + nodeIds: [n.id, vessel.id], + }); + } + } + + // ── Check valves against the flow ───────────────────────────────────────── + // A check valve drawn backwards is a line that will not flow, and on a + // drawing it looks exactly like one drawn right. The fluid walk knows how + // far every symbol is from a source, so the side nearer a source is the + // side the flow arrives on -- and that had better be the inlet. + const hops = hopsFromSources(nodes, edges); + for (const n of nodes) { + if (dataOf(n)?.componentType !== 'CV') continue; + const at = (handle: string) => { + const e = edges.find(x => + (x.source === n.id && x.sourceHandle === handle) || + (x.target === n.id && x.targetHandle === handle)); + if (!e) return undefined; + const other = e.source === n.id ? e.target : e.source; + return hops.get(other); + }; + const inlet = at(CV_INLET); + const outlet = at(CV_INLET === 'l' ? 'r' : 'l'); + if (inlet === undefined || outlet === undefined) continue; + if (inlet > outlet) { + push({ + id: `cv-backwards-${n.id}`, + severity: 'warning', + title: `${nameOf(n)} points against the flow`, + detail: 'Its inlet is on the side further from the source. Rotate it 180° (R twice), or check which way the fluid actually goes here.', + nodeIds: [n.id], + }); + } + } + // ── Lines ───────────────────────────────────────────────────────────────── // Sized one of two ways: the one-number fields, or the itemised run, whose // segments carry the length and bore instead. The check used to read only @@ -343,6 +427,30 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { }); } + // ── Joints priced from an unchecked figure ──────────────────────────────── + // The NPT engagements in `terminations.ts` are seeded from memory and say so + // on every use. A cut list built on them should not read as a citation. + let unchecked = 0; + const uncheckedOn: string[] = []; + for (const e of edges) { + for (const seg of edgeDataOf(e).segments ?? []) { + const overlap = overlapOf(seg); + if (overlap && overlap.unverified > 0) { + unchecked += overlap.unverified; + uncheckedOn.push(e.id); + } + } + } + if (unchecked) { + push({ + id: 'joints-unchecked', + severity: 'info', + title: `${unchecked} joint${unchecked === 1 ? '' : 's'} priced from an unchecked NPT engagement`, + detail: 'The hand-tight lengths are seeded from memory, not from ASME B1.20.1 Table 8. Check the table before cutting tube to these figures.', + edgeIds: [...new Set(uncheckedOn)], + }); + } + // ── Numbers nobody has checked ──────────────────────────────────────────── const assumed: string[] = []; for (const n of nodes) { @@ -367,11 +475,14 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] { const RANK: Record = { error: 0, warning: 1, info: 2 }; -function missing(push: (f: Finding) => void, n: Node, what: string, why: string) { +function missing(push: (f: Finding) => void, n: Node, whats: string[], why: string) { + const bare = whats.map(w => w.replace(/^an? /, '')); + const list = bare.length <= 1 ? bare.join('') + : `${bare.slice(0, -1).join(', ')} or ${bare[bare.length - 1]}`; push({ - id: `missing-${n.id}-${what.replace(/\s+/g, '-')}`, + id: `missing-${n.id}`, severity: 'warning', - title: `${nameOf(n)} has no ${what.replace(/^an? /, '')}`, + title: `${nameOf(n)} has no ${list}`, // Just the reason. It used to open with " is not set", which is // what the title above it already says -- and a panel that says everything // twice is one people stop reading. @@ -380,6 +491,72 @@ function missing(push: (f: Finding) => void, n: Node, what: string, why: string) }); } +const fmt = (p: { value: number; unit: string }) => `${p.value} ${p.unit}`; + +/** + * Which vessel each relief valve protects. + * + * Walked from the relief through lines, junctions and manifolds -- the things + * a relief is plumbed to a tank through -- and never through a valve or a + * regulator, past which it would be protecting something else. The first + * vessel reached is the one. + */ +function protectedVessels(nodes: Node[], edges: Edge[]): Map { + const byId = new Map(nodes.map(n => [n.id, n])); + const adj = new Map(); + for (const e of edges) { + if (!e.source || !e.target) continue; + (adj.get(e.source) ?? adj.set(e.source, []).get(e.source)!).push(e.target); + (adj.get(e.target) ?? adj.set(e.target, []).get(e.target)!).push(e.source); + } + const through = new Set(['JUNCTION', 'MANIFOLD']); + const out = new Map(); + for (const rv of nodes) { + if (dataOf(rv)?.componentType !== 'RV') continue; + const seen = new Set([rv.id]); + const queue = [...(adj.get(rv.id) ?? [])]; + while (queue.length) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + const n = byId.get(id); + const t = dataOf(n!)?.componentType; + if (t === 'TANK') { out.set(rv.id, n!); break; } + if (t && through.has(t)) queue.push(...(adj.get(id) ?? [])); + } + } + return out; +} + +/** + * How many lines each symbol is from the nearest source, walking any line in + * either direction. What "upstream" means on a drawing with no arrows. + */ +function hopsFromSources(nodes: Node[], edges: Edge[]): Map { + const adj = new Map(); + for (const e of edges) { + if (!e.source || !e.target) continue; + (adj.get(e.source) ?? adj.set(e.source, []).get(e.source)!).push(e.target); + (adj.get(e.target) ?? adj.set(e.target, []).get(e.target)!).push(e.source); + } + const sources = new Set(['TANK', 'KBOTTLE', 'DEWAR']); + const dist = new Map(); + const queue: string[] = []; + for (const n of nodes) { + if (sources.has(dataOf(n)?.componentType ?? '')) { dist.set(n.id, 0); queue.push(n.id); } + } + while (queue.length) { + const id = queue.shift()!; + const d = dist.get(id)!; + for (const next of adj.get(id) ?? []) { + if (dist.has(next)) continue; + dist.set(next, d + 1); + queue.push(next); + } + } + return dist; +} + /** What the badge shows: things that are actually wrong. */ export const countProblems = (findings: Finding[]) => findings.filter(f => f.severity !== 'info').length; diff --git a/pid-designer/frontend/src/components/pid/fmt.test.ts b/pid-designer/frontend/src/components/pid/fmt.test.ts new file mode 100644 index 000000000..a25be5dbe --- /dev/null +++ b/pid-designer/frontend/src/components/pid/fmt.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { fmtParam, fmtValue } from './fmt'; + +describe('numbers drawn on symbols', () => { + it('puts a space before the unit', () => { + // The engine printed 300psi and the relief 650 psi on the same sheet. + expect(fmtParam({ value: 300, unit: 'psi', source: 'estimated' })).toBe('300 psi'); + }); + + it('draws a dimensionless value bare', () => { + expect(fmtParam({ value: 1.7, unit: '-', source: 'estimated' })).toBe('1.7'); + }); + + it('cuts a long decimal to what fits', () => { + expect(fmtValue(10.213456)).toBe('10.2'); + expect(fmtValue(0.61234)).toBe('0.612'); + expect(fmtValue(2000)).toBe('2000'); + expect(fmtValue(1234.56)).toBe('1235'); + }); + + it('draws nothing for nothing', () => { + expect(fmtParam(undefined)).toBe(''); + }); +}); diff --git a/pid-designer/frontend/src/components/pid/fmt.ts b/pid-designer/frontend/src/components/pid/fmt.ts new file mode 100644 index 000000000..5ce8ae411 --- /dev/null +++ b/pid-designer/frontend/src/components/pid/fmt.ts @@ -0,0 +1,25 @@ +/** + * A number and its unit, as drawn on a symbol. + * + * One rule for all of them. The engine printed `300psi`, the relief `650 psi` + * and the bottle `2000 psi`, which is three ways to write one thing on one + * sheet. A unit reads with a space before it; a dimensionless value reads + * bare; a long decimal is cut to what a symbol has room for. + */ + +import type { ParamValue } from './params'; + +export function fmtValue(value: number, digits = 3): string { + if (!Number.isFinite(value)) return '—'; + if (Number.isInteger(value)) return String(value); + const abs = Math.abs(value); + const fixed = abs >= 100 ? value.toFixed(0) : abs >= 10 ? value.toFixed(1) : value.toPrecision(digits); + // `toPrecision` leaves trailing zeros; a symbol has no room for them. + return String(Number(fixed)); +} + +export function fmtParam(p: ParamValue | undefined): string { + if (!p) return ''; + const unit = p.unit === '-' || !p.unit ? '' : ` ${p.unit}`; + return `${fmtValue(p.value)}${unit}`; +} diff --git a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx index 93a056901..35a51b146 100644 --- a/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/CheckValveNode.tsx @@ -7,6 +7,16 @@ import { turn } from '../route'; const W = 60, H = 60; +/** + * A check valve, and which way it lets flow go. + * + * Drawn with an arrow, because the direction is the whole component. The old + * artwork was a ball against a seat with no arrow, and the two readings of + * it were about equally common -- so a valve drawn backwards looked exactly + * like one drawn right. Flow is from `l` to `r`, as `ports.CV_INLET` says, + * and the checks panel reads the same constant to say when the drawing has + * one facing the wrong way. + */ export function CheckValveNode({ id, data, selected }: NodeProps) { const { label, labelOffset, rotation } = data as unknown as PIDNodeData; const stroke = selected ? '#3b82f6' : '#94a3b8'; @@ -20,10 +30,14 @@ export function CheckValveNode({ id, data, selected }: NodeProps) { } > - - - - + {/* seat, and the ball resting against it */} + + + {/* the run through the body */} + + + {/* the arrow: the only reading there is */} + ); diff --git a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx index 0f48d1016..e7167a569 100644 --- a/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/EngineNode.tsx @@ -3,6 +3,7 @@ import { Frame, TurnedPort } from './Frame'; import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; import { Upright } from './Upright'; +import { fmtParam } from '../fmt'; const W = 72, H = 120; @@ -58,7 +59,7 @@ export function EngineNode({ id, data, selected }: NodeProps) { {pc && ( - {pc.value}{pc.unit === '-' ? '' : pc.unit} + {fmtParam(pc)} )} diff --git a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx index eaa037a33..8af0632ef 100644 --- a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx @@ -1,5 +1,7 @@ import { Position, type NodeProps } from '@xyflow/react'; import { Port } from './Port'; +import { useNodeFluid } from '../FluidContext'; +import { colorForSpecies } from '../fluids'; /** * A branch point: the tee, as the drawing says it. @@ -10,7 +12,12 @@ import { Port } from './Port'; * *selects* a node as well as the part that moves it: the dot could not be * picked at all, and pressing Delete over it did nothing. */ -export function JunctionNode({ selected }: NodeProps) { +export function JunctionNode({ id, selected }: NodeProps) { + // A junction is a point *in* a run, so it is drawn in the run's own colour. + // A grey dot on an orange line read as something foreign sitting on the + // pipe rather than a tee in it. + const fluid = useNodeFluid(id); + const tint = fluid?.species ? colorForSpecies(fluid.species) : '#94a3b8'; const handleStyle = { width: 10, height: 10, @@ -24,9 +31,9 @@ export function JunctionNode({ selected }: NodeProps) { width: 10, height: 10, borderRadius: '50%', - background: selected ? '#3b82f6' : '#94a3b8', + background: selected ? '#3b82f6' : tint, border: '2px solid #0f172a', - boxShadow: `0 0 0 2px ${selected ? '#3b82f6' : '#475569'}`, + boxShadow: `0 0 0 2px ${selected ? '#3b82f6' : tint}`, position: 'relative', cursor: 'grab', }} diff --git a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx index 39884a125..907c64fc0 100644 --- a/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/PRNode.tsx @@ -6,11 +6,13 @@ import { DraggableLabel } from './DraggableLabel'; import { Frame } from './Frame'; import { turn } from '../route'; import { Upright } from './Upright'; +import { fmtParam } from '../fmt'; const W = 60, H = 60; export function PRNode({ id, data, selected }: NodeProps) { - const { label, labelOffset, rotation, options } = data as unknown as PIDNodeData; + const { label, labelOffset, rotation, options, params } = data as unknown as PIDNodeData; + const setpoint = params?.setpoint; const stroke = selected ? '#3b82f6' : '#94a3b8'; // A dome-loaded regulator has a third connection, and which one it is // matters: the dome sets the outlet, so a line run to it by mistake is a @@ -27,7 +29,23 @@ export function PRNode({ id, data, selected }: NodeProps) { {domeLoaded && } - + {/* The setpoint, on the face of it. A regulator's number is what a + reviewer scans a sheet for, and two clicks into a dialog is two + clicks nobody takes while scanning. */} + {setpoint && ( + + {fmtParam(setpoint)} + + )} + } > diff --git a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx index 0d4e329f2..4a3c2a33f 100644 --- a/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/RVNode.tsx @@ -4,6 +4,7 @@ import type { PIDNodeData } from '../types'; import { DraggableLabel } from './DraggableLabel'; import { Frame } from './Frame'; import { turn } from '../route'; +import { fmtParam } from '../fmt'; const W = 60, H = 60; @@ -38,11 +39,11 @@ export function RVNode({ id, data, selected }: NodeProps) { pointerEvents: 'none', }} > - {set && <>{set.value} {set.unit}} + {set && <>{fmtParam(set)}} {reseat && ( <>
- ↺ {reseat.value} {reseat.unit} + ↺ {fmtParam(reseat)} )} diff --git a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx index 6adae168f..150a58f1f 100644 --- a/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx +++ b/pid-designer/frontend/src/components/pid/nodes/SupplyNode.tsx @@ -5,6 +5,7 @@ import { useNodeFluid } from '../FluidContext'; import { DraggableLabel } from './DraggableLabel'; import { Frame, TurnedPort } from './Frame'; import { Upright } from './Upright'; +import { fmtParam } from '../fmt'; /** * Where the propellant and the pressurant come from: a K-bottle, or a dewar. @@ -86,7 +87,7 @@ export function SupplyNode({ id, data, selected }: NodeProps) { color: '#94a3b8', whiteSpace: 'nowrap', pointerEvents: 'none', }} > - {p.value} {p.unit} + {fmtParam(p)} )} diff --git a/pid-designer/frontend/src/components/pid/params.ts b/pid-designer/frontend/src/components/pid/params.ts index 89c977185..66b656639 100644 --- a/pid-designer/frontend/src/components/pid/params.ts +++ b/pid-designer/frontend/src/components/pid/params.ts @@ -85,3 +85,21 @@ export const UNITS: Record = { /** Pressures are absolute. Said in the UI, next to the field. */ export const ABSOLUTE_NOTE = 'absolute, not gauge'; + +/** Pascals per unit, for the pressures the dialog offers. Absolute throughout. */ +const PA_PER: Record = { + Pa: 1, kPa: 1e3, MPa: 1e6, bar: 1e5, atm: 101325, psi: 6894.757293168361, +}; + +/** + * A pressure in pascals, or nothing if its unit is not one this file knows. + * + * For comparing two pressures on the drawing -- a relief against a MAWP -- + * which is only meaningful once both are in the same unit. Nothing here is + * offered to the solver; feed-twin converts for itself. + */ +export function toPa(p: ParamValue | undefined): number | undefined { + if (!p) return undefined; + const k = PA_PER[p.unit]; + return k === undefined ? undefined : p.value * k; +} diff --git a/pid-designer/frontend/src/components/pid/ports.ts b/pid-designer/frontend/src/components/pid/ports.ts index 918a3c440..6049d8661 100644 --- a/pid-designer/frontend/src/components/pid/ports.ts +++ b/pid-designer/frontend/src/components/pid/ports.ts @@ -22,6 +22,15 @@ import type { PIDNodeData } from './types'; export type PortKind = 'flow' | 'instrument' | 'plug'; +/** + * Which of a check valve's two ports is the inlet. + * + * The symbol is drawn with its flow arrow pointing from `l` to `r`, and the + * checks read the same constant -- so the artwork and the rule that a check + * valve must face the flow cannot disagree about which way that is. + */ +export const CV_INLET = 'l'; + /** * The ports that are instrument tappings, worked out from what is on them. * diff --git a/pid-designer/frontend/src/components/pid/spec.ts b/pid-designer/frontend/src/components/pid/spec.ts index 5da3fe9a4..324d1c53e 100644 --- a/pid-designer/frontend/src/components/pid/spec.ts +++ b/pid-designer/frontend/src/components/pid/spec.ts @@ -101,6 +101,11 @@ function valveSpec(): ComponentSpec { P('Cv', 'Cv', 'flow_coefficient'), P('bore', 'Bore', 'length'), P('travel_time', 'Travel time', 'time', { value: 0.05, unit: 's' }), + // Where a gas or a liquid chokes through it, IEC 60534. Datasheet + // numbers; the defaults are a globe/ball valve and feed-twin says so. + A('xT', 'xT (gas choke)', 'dimensionless', { value: 0.7, unit: '-' }), + A('FL', 'FL (liquid recovery)', 'dimensionless', { value: 0.9, unit: '-' }), + A('leak_closed', 'Seat leak when shut', 'flow_coefficient'), ], options: [ { key: 'failState', label: 'Unpowered position', default: 'closed', @@ -126,6 +131,11 @@ export const COMPONENT_SPECS: Partial> = { P('wall_mass', 'Wall mass', 'mass'), P('wall_capacity', 'Wall specific heat', 'specific_heat', { value: 900, unit: 'J/(kg.K)' }), P('wall_conductance', 'Gas-to-wall hA', 'thermal_conductance'), + // What stands between the tank and the room. Left blank the tank is + // bare, and a bare LOX tank boils six times faster than one under an + // inch of fiberglass. + P('insulation_thickness', 'Insulation thickness', 'length'), + P('insulation_conductivity', 'Insulation conductivity', 'conductivity', { value: 0.04, unit: 'W/(m.K)' }), ], options: [ { key: 'portsTop', label: 'Top ports', default: '1', @@ -177,12 +187,22 @@ export const COMPONENT_SPECS: Partial> = { P('setpoint', 'Setpoint', 'pressure'), P('Cv', 'Cv', 'flow_coefficient'), P('bore', 'Orifice', 'length'), - // Supply-pressure effect as a datasheet states it: outlet rises this - // much for that much inlet decay. Two pressures rather than the - // dimensionless ratio the physics core wants, because nobody reads - // "0.0147" off a spec sheet -- they read "14.7 psi per 1000 psi". - P('supply_effect_out', 'Outlet rise', 'pressure'), - P('supply_effect_in', ' per inlet drop', 'pressure'), + // A regulator with no droop holds its setpoint at any flow, which makes + // its branch equation true for every mass flow -- the flow is genuinely + // indeterminate and feed-twin cannot solve it transiently. These two + // are the datasheet's droop curve in two numbers. + P('flow_droop', 'Droop at rated flow', 'pressure'), + P('rated_flow', 'Rated flow', 'mass_flow'), + // Supply-pressure effect, written the way the datasheet writes it: + // "17 psi per 1000 psi of inlet". The unit carries the "per", so the + // number is the one printed on the sheet. Two pressure fields used to + // stand here under names feed-twin never read, so a typed value went + // nowhere; these are the catalogue's own names. + A('supply_coefficient', 'Supply effect', 'pressure_ratio'), + A('inlet_reference', ' at inlet', 'pressure'), + // What a downstream relief actually sees between firings. + A('lockup_rise', 'Lockup rise', 'pressure'), + A('min_inlet_differential', 'Dropout (min in−out)', 'pressure'), // Dome-loaded only. `dome_pressure` is superseded when a loading // regulator is drawn — feed-twin takes that one's setpoint — so it is // for a dome set from a panel that is not on the drawing. @@ -211,6 +231,7 @@ export const COMPONENT_SPECS: Partial> = { P('cracking_pressure', 'Cracking pressure', 'pressure', { value: 3, unit: 'psi' }), P('Cv', 'Cv', 'flow_coefficient'), P('bore', 'Bore', 'length'), + A('leak_reverse', 'Reverse seat leak', 'flow_coefficient'), ], }, diff --git a/pid-designer/tests/test_spec_parity.py b/pid-designer/tests/test_spec_parity.py new file mode 100644 index 000000000..301f74b26 --- /dev/null +++ b/pid-designer/tests/test_spec_parity.py @@ -0,0 +1,144 @@ +"""`spec.ts` is a hand-maintained mirror of `components.toml`. Keep it honest. + +The drawing tool declares what a component's fields are in TypeScript, and the +physics library declares the same thing in TOML. Nothing links them, so a +parameter added to the catalogue simply never appears in the UI -- which is +exactly what happened to the line-wall model: `wall_thickness` and +`fitting_mass` were catalogued, read by the solver, and unreachable from the +drawing tool, so the only way to set them was to hand-edit JSON. + +This test is the link. It fails when the two drift. +""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CATALOG = ROOT / "lib" / "feedtwin" / "feedtwin" / "model" / "components.toml" +SPEC = ROOT / "pid-designer" / "frontend" / "src" / "components" / "pid" / "spec.ts" + +#: Catalogue entries the drawing tool is not expected to expose, with the reason. +#: Anything not listed here has to be reachable from the UI. +NOT_DRAWN = { + # Solver-facing, chosen by the model rather than typed by a user. + "model", +} + + +def catalogue() -> dict[str, set[str]]: + if not CATALOG.exists(): + pytest.skip("components.toml is not present") + data = tomllib.loads(CATALOG.read_text()) + out: dict[str, set[str]] = {} + for name, body in data.items(): + params = body.get("params") + if isinstance(params, dict): + out[name] = set(params) - NOT_DRAWN + return out + + +def spec_text() -> str: + if not SPEC.exists(): + pytest.skip("spec.ts is not present") + return SPEC.read_text() + + +@pytest.mark.parametrize("line", ["pipe", "flex_hose"]) +def test_every_catalogued_line_param_is_reachable_in_the_ui(line: str) -> None: + """A parameter the solver reads but the tool cannot set is invisible. + + It is worse than missing: the value silently stays at its default, the run + completes, and the number that was never entered looks like a modelling + result. + """ + params = catalogue().get(line) + if not params: + pytest.skip(f"no {line} in the catalogue") + text = spec_text() + missing = sorted(p for p in params if f"'{p}'" not in text) + assert not missing, ( + f"{line}: catalogued but unreachable from the drawing tool: " + f"{', '.join(missing)}. Add them to LINE_SPECS in spec.ts." + ) + + +def test_the_thermal_params_specifically() -> None: + """Named, because these are the ones it happened to.""" + text = spec_text() + for param in ("wall_thickness", "fitting_count", "fitting_mass"): + assert f"'{param}'" in text, f"{param} is not settable on a line" + + +def test_vessel_walls_are_settable_on_every_vessel_symbol() -> None: + """The wall is what decides how much a bottle cools on blowdown. It was + three bare numbers in feed-twin with no way to say otherwise from the + drawing; now every vessel symbol carries the three fields.""" + text = spec_text() + for symbol in ("TANK: {", "KBOTTLE: {", "DEWAR: {"): + start = text.index(symbol) + block = text[start : text.index("\n },", start)] + for param in ("wall_mass", "wall_capacity", "wall_conductance"): + assert f"'{param}'" in block, f"{symbol[:-3]} cannot declare {param}" + + +def test_spec_dimensions_are_ones_feedtwin_knows() -> None: + """A dimension the unit table has never heard of cannot be converted.""" + units = ROOT / "lib" / "feedtwin" / "feedtwin" / "model" / "units.py" + if not units.exists(): + pytest.skip("units.py is not present") + known = set(re.findall(r'_u\("[^"]+",\s*"(\w+)"', units.read_text())) + used = set(re.findall(r"P\('[\w]+',\s*'[^']*',\s*'(\w+)'", spec_text())) + unknown = sorted(used - known - {"dimensionless"}) + assert not unknown, f"spec.ts uses dimensions feedtwin cannot convert: {unknown}" + + +#: Which drawing symbols stand for which catalogue components. The reader's +#: own table (`feedtwin.pid.network.BRANCH_KINDS`), restated here so this test +#: does not import the physics library to check a text file. +SYMBOL_OF = { + "regulator": "PR", + "valve": "MAN", + "check_valve": "CV", +} + + +@pytest.mark.parametrize("component", sorted(SYMBOL_OF)) +def test_every_catalogued_inline_param_is_reachable_in_the_ui(component: str) -> None: + """The regulator is why this exists. + + `spec.ts` carried `supply_effect_out` and `supply_effect_in` -- two fields + feed-twin never read, under names that appear nowhere in the catalogue -- + while `flow_droop`, `rated_flow`, `supply_coefficient` and + `inlet_reference`, which it does read, were not settable at all. A + datasheet number typed into the dialog went nowhere, and a regulator with + no droop is one feed-twin cannot solve transiently. The line params were + already guarded; the inline ones were not. + """ + params = catalogue().get(component) + if not params: + pytest.skip(f"no {component} in the catalogue") + text = spec_text() + missing = sorted(p for p in params if f"'{p}'" not in text) + assert not missing, ( + f"{component} ({SYMBOL_OF[component]}): catalogued but unreachable from " + f"the drawing tool: {', '.join(missing)}. Add them to COMPONENT_SPECS." + ) + + +def test_spec_declares_no_param_the_catalogue_lacks_for_the_regulator() -> None: + """The other direction: a field the drawing offers that the solver ignores + is a number somebody typed that went nowhere. Regulator only, because that + is where it happened; the tank carries drawing-side fields (MAWP) on + purpose.""" + text = spec_text() + start = text.index("PR: {") + block = text[start : text.index("\n },", start)] + declared = set(re.findall(r"[PAD]\('(\w+)'", block)) + known = catalogue().get("regulator", set()) | {"model"} + orphans = sorted(declared - known) + assert not orphans, f"PR offers fields feed-twin never reads: {orphans}" From 0fc256e8c47fc2fb36c69d2e02883422216859d4 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Fri, 11 Sep 2026 21:56:01 -0700 Subject: [PATCH 42/57] A sheet you can copy on, print, and find your way around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy, paste, duplicate. A stand has eight solenoid valves that are the same solenoid valve, and there was no way to copy one -- the eighth was configured from the palette like the first. Cmd+C / Cmd+V / Cmd+D, and Cmd+A for everything on the page. What lands is a new symbol, not a reference: fresh ids, because an id is the tag a solver keys on; a fresh tag numbered from the same stem, so a copy of SOL-3 is the next free SOL and not SOL-3-1 or a second SOL-3; only the lines whose both ends came along; a probe clipped to a copied host stays clipped to the copy. The copy lands selected, so a drag straight after moves it and not the original. Nothing fires while a field has focus. Export. It meant the JSON, which is the drawing for a machine. A P&ID that cannot be printed for a review or pasted into a test plan is one that lives only in the tool it was drawn in. The menu now offers a PNG and an SVG of the current sheet, framed on its symbols, with a title block along the bottom -- drawing, sheet, revision, date -- and the JSON, named after the diagram rather than `pid_diagram.json` as every export of every drawing was. Rendered from the live canvas with html-to-image, so the export and the screen cannot disagree. The PNG is rasterised on our own canvas from the SVG rather than by the library's toPng: that hands a 2 MB SVG to HTMLImageElement.decode(), which a background tab defers indefinitely, while the same SVG fires onload in a millisecond. A render is bounded at twenty seconds, so the menu cannot say "Rendering…" for as long as anyone cares to wait. A title block in the corner of the sheet, saying the same four things the export prints. What every engineering drawing has and this one did not. A minimap, under the checks badge. A drawing with forty symbols is bigger than a screen. Restore asks through the same dialog Clear does, not a browser confirm() that lands wherever the browser puts it. The toolbar and the palette move onto the shared colour tokens rather than their own slate hexes. --- pid-designer/frontend/package-lock.json | 6 + pid-designer/frontend/package.json | 1 + .../src/components/pid/ComponentPalette.tsx | 6 +- .../src/components/pid/PIDDesigner.tsx | 130 +++++++++-- .../src/components/pid/PIDToolbar.tsx | 166 +++++++++---- .../src/components/pid/TitleBlock.tsx | 29 +++ .../src/components/pid/clipboard.test.ts | 83 +++++++ .../frontend/src/components/pid/clipboard.ts | 100 ++++++++ .../src/components/pid/exportImage.test.ts | 22 ++ .../src/components/pid/exportImage.ts | 218 ++++++++++++++++++ .../frontend/src/components/pid/tags.test.ts | 10 +- .../frontend/src/components/pid/tags.ts | 11 + pid-designer/frontend/src/lib/gating.test.ts | 3 + 13 files changed, 720 insertions(+), 65 deletions(-) create mode 100644 pid-designer/frontend/src/components/pid/TitleBlock.tsx create mode 100644 pid-designer/frontend/src/components/pid/clipboard.test.ts create mode 100644 pid-designer/frontend/src/components/pid/clipboard.ts create mode 100644 pid-designer/frontend/src/components/pid/exportImage.test.ts create mode 100644 pid-designer/frontend/src/components/pid/exportImage.ts diff --git a/pid-designer/frontend/package-lock.json b/pid-designer/frontend/package-lock.json index 65246301e..48f0cc535 100644 --- a/pid-designer/frontend/package-lock.json +++ b/pid-designer/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@xyflow/react": "^12.4.4", + "html-to-image": "^1.11.13", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1212,6 +1213,11 @@ "dev": true, "license": "ISC" }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", diff --git a/pid-designer/frontend/package.json b/pid-designer/frontend/package.json index 4225e1eb5..48037ba95 100644 --- a/pid-designer/frontend/package.json +++ b/pid-designer/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@xyflow/react": "^12.4.4", + "html-to-image": "^1.11.13", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/pid-designer/frontend/src/components/pid/ComponentPalette.tsx b/pid-designer/frontend/src/components/pid/ComponentPalette.tsx index 60894d881..994afaf67 100644 --- a/pid-designer/frontend/src/components/pid/ComponentPalette.tsx +++ b/pid-designer/frontend/src/components/pid/ComponentPalette.tsx @@ -146,8 +146,8 @@ export function ComponentPalette() { }; return ( -