Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -0,0 +1,348 @@
"use client";
import { useRef, useState } from "react";
import {
Play,
Loader2,
FileDown,
FileUp,
Eraser,
Boxes,
Grid3x3,
RotateCw,
Layers,
FlipHorizontal2,
FlipVertical2,
} from "lucide-react";
import { useDiagramStore } from "@/store/diagramStore";
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
import { computeDofLedger } from "@/lib/dofLedger";
import { simulate } from "@/lib/api";
import type { EntropykNodeData } from "@/lib/configBuilder";
import type { Node } from "@xyflow/react";
import MultiRunPanel from "@/components/panels/MultiRunPanel";
const FLUIDS = ["R410A", "R134a", "R290", "R744", "R32", "R1234yf", "Water", "Air"];
const BACKENDS = ["CoolProp", "Test"];
const SOLVERS = [
{ value: "newton", label: "Newton" },
{ value: "picard", label: "Picard" },
];
const EXAMPLES = [
{ file: "hx_air_water_4port.json", label: "HX airwater" },
{ file: "chiller_flooded_4port_watercooled.json", label: "Chiller flooded" },
{ file: "chiller_watercooled_r410a.json", label: "Chiller R410A" },
{ file: "chiller_aircooled_r134a.json", label: "Chiller air-cooled" },
{ file: "bphx_evaporator_condenser.json", label: "BPHX cycle" },
{ file: "heatpump_r410a_reversing_valve.json", label: "Heat pump 4-way" },
{ file: "chiller_r134a_exv_orifice.json", label: "EXV orifice" },
{ file: "chiller_r410a_full_physics.json", label: "R410A full physics" },
{ file: "capillary_tube_r134a.json", label: "Capillary smoke" },
];
export default function Toolbar() {
const {
nodes,
edges,
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
snapToGrid,
selectedNodeId,
setFluid,
setFluidBackend,
setSolverStrategy,
setLastConfig,
toggleSnapToGrid,
rotateNode,
flipNodeH,
flipNodeV,
setResult,
setSimulating,
simulating,
loadFromConfig,
clear,
} = useDiagramStore();
const [issues, setIssues] = useState<string[]>([]);
const [multiOpen, setMultiOpen] = useState(false);
const [exampleFile, setExampleFile] = useState(EXAMPLES[0].file);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const onSimulate = async () => {
const problems = validateConfig(nodes, edges);
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
// Hard block only when over-constrained (no free lunch). Under-constrained
// diagrams are common while editing — surface as a soft issue after simulate
// via the status bar; the CLI still hard-fails on non-square systems.
if (ledger.balance === "over-constrained") {
problems.push(
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
`Remove a FIX (quality control, extra outlet closure) or FREE an actuator.`,
);
}
setIssues(problems);
if (problems.length > 0) return;
const config = buildScenarioConfig(nodes, edges, {
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
});
setSimulating(true);
setLastConfig(config);
setResult(null, null);
try {
const resp = await simulate(config);
if (resp.ok && resp.result) {
setResult(resp.result, null);
if (resp.result.dof && resp.result.dof.n_equations !== resp.result.dof.n_unknowns) {
setIssues([
`Server DoF: ${resp.result.dof.n_equations} eqs vs ${resp.result.dof.n_unknowns} unk (${resp.result.dof.balance})`,
]);
}
} else setResult(null, resp.error || "Simulation failed");
} catch (e) {
setResult(null, e instanceof Error ? e.message : String(e));
} finally {
setSimulating(false);
}
};
const onLoadExample = async () => {
try {
const res = await fetch(`/examples/${exampleFile}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
loadFromConfig(await res.json());
setIssues([]);
} catch (e) {
setIssues([`Could not load example: ${e instanceof Error ? e.message : e}`]);
}
};
const onImportJsonFile = async (file: File | undefined) => {
if (!file) return;
try {
const text = await file.text();
loadFromConfig(JSON.parse(text));
setIssues([]);
} catch (e) {
setIssues([`Could not import JSON: ${e instanceof Error ? e.message : e}`]);
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<header className="flex h-11 items-center gap-2 border-b border-black/40 bg-[var(--bar)] px-3 text-[var(--bar-ink)]">
<div className="flex items-center gap-2 pr-1">
<Boxes size={16} className="text-[var(--bar-accent)]" />
<span className="mono text-[12.5px] font-semibold tracking-[0.14em] text-white">
ENTROPYK
</span>
</div>
<div className="bar-sep" />
<BarField label="Fluid">
<select value={fluid} onChange={(e) => setFluid(e.target.value)} className="bar-select mono">
{FLUIDS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</BarField>
<BarField label="Props">
<select
value={fluidBackend}
onChange={(e) => setFluidBackend(e.target.value)}
className="bar-select mono"
>
{BACKENDS.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</BarField>
<BarField label="Solver">
<select
value={solverStrategy}
onChange={(e) => setSolverStrategy(e.target.value)}
className="bar-select mono"
>
{SOLVERS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</BarField>
<div className="bar-sep" />
{/* Orientation — icon group, works on the selected part */}
<div className="flex items-center overflow-hidden rounded-[4px] border border-white/12">
<button
onClick={() => selectedNodeId && rotateNode(selectedNodeId, 1)}
disabled={!selectedNodeId}
className="bar-icon"
title="Rotation 90° (R) — sélectionne dabord une pièce"
>
<RotateCw size={13} />
</button>
<button
onClick={() => selectedNodeId && flipNodeH(selectedNodeId)}
disabled={!selectedNodeId}
className="bar-icon border-l border-white/12"
title="Miroir horizontal (H)"
>
<FlipHorizontal2 size={13} />
</button>
<button
onClick={() => selectedNodeId && flipNodeV(selectedNodeId)}
disabled={!selectedNodeId}
className="bar-icon border-l border-white/12"
title="Miroir vertical (V)"
>
<FlipVertical2 size={13} />
</button>
<button
onClick={toggleSnapToGrid}
className="bar-icon border-l border-white/12"
data-active={snapToGrid}
title="Aimanter à la grille"
>
<Grid3x3 size={13} />
</button>
</div>
<div className="flex-1" />
{issues.length > 0 && (
<div
className="mono max-w-[240px] truncate text-[11px] text-amber-300"
title={issues.join("\n")}
>
{issues.length} · {issues[0]}
</div>
)}
<select
value={exampleFile}
onChange={(e) => setExampleFile(e.target.value)}
className="bar-select mono max-w-[150px]"
title="Exemple de scénario"
>
{EXAMPLES.map((ex) => (
<option key={ex.file} value={ex.file}>
{ex.label}
</option>
))}
</select>
<button onClick={onLoadExample} className="bar-btn" title="Charger lexemple">
<FileDown size={13} /> Load
</button>
<button onClick={() => fileInputRef.current?.click()} className="bar-btn" title="Importer un JSON">
<FileUp size={13} /> Import
</button>
<button onClick={() => setMultiOpen(true)} className="bar-btn" title="Balayage paramétrique parallèle">
<Layers size={13} /> Multi-run
</button>
<button onClick={clear} className="bar-btn" title="Vider la feuille">
<Eraser size={13} />
</button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => void onImportJsonFile(event.target.files?.[0])}
/>
<button
onClick={onSimulate}
disabled={simulating}
className="ml-1 flex items-center gap-1.5 rounded-[4px] bg-[var(--bar-accent)] px-4 py-[7px] text-[12.5px] font-semibold text-[#0c1420] transition-all hover:brightness-110 disabled:opacity-50"
>
{simulating ? <Loader2 size={13} className="animate-spin" /> : <Play size={13} />}
{simulating ? "Solving…" : "Solve"}
</button>
{multiOpen && <MultiRunPanel onClose={() => setMultiOpen(false)} />}
<style jsx>{`
.bar-sep {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.12);
margin: 0 4px;
}
.bar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
padding: 5px 9px;
font-size: 12px;
color: var(--bar-ink);
transition: all 0.12s ease;
}
.bar-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bar-icon {
display: grid;
place-items: center;
width: 28px;
height: 26px;
color: var(--bar-ink);
background: rgba(255, 255, 255, 0.04);
transition: all 0.12s ease;
}
.bar-icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.bar-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.bar-icon[data-active="true"] {
background: rgba(94, 176, 255, 0.22);
color: var(--bar-accent);
}
:global(.bar-select) {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 4px 6px;
font-size: 11.5px;
color: var(--bar-ink);
}
:global(.bar-select option) {
color: var(--ink);
background: #fff;
}
`}</style>
</header>
);
}
function BarField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex items-center gap-1.5 leading-none">
<span className="mono text-[9px] uppercase tracking-[0.1em] text-white/45">{label}</span>
{children}
</label>
);
}