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>
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
"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 air–water" },
|
||
{ 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 d’abord 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 l’exemple">
|
||
<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>
|
||
);
|
||
}
|