Initial project import

This commit is contained in:
drjones
2026-06-13 17:36:44 -07:00
commit ad2a18cc8d
18471 changed files with 4497570 additions and 0 deletions

440
src/App.tsx Normal file
View File

@@ -0,0 +1,440 @@
import { useEffect, useState, useMemo } from 'react';
import { SceneCanvas, clientToGround } from './components/SceneCanvas';
import { PartLibrary } from './components/PartLibrary';
import { PropertiesPanel } from './components/PropertiesPanel';
import { Toolbar } from './components/Toolbar';
import { StatusPanel } from './components/StatusPanel';
import { ColorPopover } from './components/ColorPopover';
import { BomPanel } from './bom';
import { DesignCheckPanel, validateSystem } from './validation';
import { getHotbarPartForKey } from './parts/hotbar';
import { useSimulation } from './simulation/flowSimulator';
import { snapValue, useBuilder } from './store/builderStore';
import { autosave, loadAutosave } from './utils/serializer';
import { demoParts } from './utils/demoProject';
import type { PartType } from './types';
import type { Finding } from './validation';
import { AuthProvider, useAuth } from './auth/AuthContext';
import { useCollab } from './hooks/useCollab';
import { LoginModal } from './auth/LoginModal';
import { ToastContainer, addToast } from './components/Toast';
import { useSaveStatus } from './store/saveStatusStore';
/** Restore the autosaved design, or seed the demo system on first visit. */
/** Restore the autosaved design, or seed the demo system on first visit. */
function useBootstrap(email?: string, loading?: boolean) {
const { user } = useAuth();
const hasUpgraded = user && user.purchased_slots > 0;
useEffect(() => {
if (loading) return;
const saved = hasUpgraded ? loadAutosave(email) : null;
if (saved && saved.parts.length > 0) {
useBuilder.getState().loadParts(saved.parts, saved.name);
} else {
useBuilder.getState().loadParts(demoParts(), 'Demo: pump loop');
}
}, [email, loading, hasUpgraded]);
}
/** Debounced autosave whenever parts change. */
function useAutosave(email?: string, loading?: boolean) {
const { user } = useAuth();
const hasUpgraded = user && user.purchased_slots > 0;
const setStatus = useSaveStatus((s) => s.setStatus);
useEffect(() => {
if (loading) return;
if (!hasUpgraded) {
setStatus('unsaved');
return;
}
let timer: ReturnType<typeof setTimeout> | undefined;
let hasUnsavedChanges = false;
const unsub = useBuilder.subscribe((s, prev) => {
if (s.parts !== prev.parts) {
hasUnsavedChanges = true;
setStatus('unsaved');
}
const isDragging = s.draggingId !== null || s.gizmoDragging;
const wasDragging = prev.draggingId !== null || prev.gizmoDragging;
const dragEnded = wasDragging && !isDragging;
const partsChangedWhileNotDragging = (s.parts !== prev.parts) && !isDragging;
if (hasUnsavedChanges && (dragEnded || partsChangedWhileNotDragging)) {
clearTimeout(timer);
setStatus('saving');
timer = setTimeout(() => {
autosave(useBuilder.getState().projectName, Object.values(useBuilder.getState().parts), email);
hasUnsavedChanges = false;
setStatus('saved');
}, 600);
} else if (isDragging) {
clearTimeout(timer);
timer = undefined;
}
});
return () => {
unsub();
clearTimeout(timer);
};
}, [email, loading, hasUpgraded, setStatus]);
}
/** Global keyboard shortcuts. */
function useShortcuts() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
const s = useBuilder.getState();
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault();
e.shiftKey ? s.redo() : s.undo();
return;
}
if (mod && e.key.toLowerCase() === 'y') {
e.preventDefault();
s.redo();
return;
}
if (mod && e.key.toLowerCase() === 'a') {
e.preventDefault();
s.selectAll();
return;
}
if (mod && e.key.toLowerCase() === 'c') {
const selectedParts = s.selectedIds.map(id => s.parts[id]).filter(Boolean);
if (selectedParts.length > 0) {
e.preventDefault();
navigator.clipboard.writeText(JSON.stringify({ app: 'hydro-builder-clip', parts: selectedParts }));
addToast(`Copied ${selectedParts.length} parts`, 'info');
}
return;
}
if (mod && e.key.toLowerCase() === 'v') {
e.preventDefault();
navigator.clipboard.readText().then((text) => {
try {
const data = JSON.parse(text);
if (data && data.app === 'hydro-builder-clip' && Array.isArray(data.parts)) {
const pastedIds = s.pasteParts(data.parts);
addToast(`Pasted ${pastedIds.length} parts`, 'success');
}
} catch (err) {
addToast('Clipboard does not contain valid hydro parts', 'error');
}
}).catch(() => {
addToast('Could not read clipboard', 'error');
});
return;
}
if (mod) return; // don't hijack other browser shortcuts
const hotbarType = getHotbarPartForKey(e.key);
if (hotbarType) {
e.preventDefault();
if (s.tool !== 'select') s.setTool('select');
s.setPlacingType(s.placingType === hotbarType ? null : hotbarType);
return;
}
switch (e.key) {
case ' ':
e.preventDefault(); // keep Space from scrolling / re-clicking buttons
s.toggleSimRunning();
break;
case 'Escape':
if (s.colorPopover) {
s.closeColorPopover();
} else {
s.setTool('select');
s.setSelected(null);
}
break;
case 'Delete':
case 'Backspace':
s.removeParts(s.selectedIds);
break;
case 'r':
case 'R':
s.rotateParts(s.selectedIds, 1, (e.shiftKey ? -1 : 1) * (Math.PI / 2));
break;
case 'd':
case 'D':
e.preventDefault();
s.duplicateParts(s.selectedIds);
break;
case 'q':
case 'Q':
s.translateParts(s.selectedIds, [0, -s.gridSize, 0], 'nudge');
break;
case 'e':
case 'E':
s.translateParts(s.selectedIds, [0, s.gridSize, 0], 'nudge');
break;
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
}
export function BuilderApp() {
const { user, loading } = useAuth();
const email = user?.email;
useBootstrap(email, loading);
useAutosave(email, loading);
useShortcuts();
const { remoteCursors } = useCollab();
const placingType = useBuilder((s) => s.placingType);
const tool = useBuilder((s) => s.tool);
const selectedCount = useBuilder((s) => s.selectedIds.length);
const viewMode = useBuilder((s) => s.viewMode);
const renderMode = useBuilder((s) => s.renderMode);
const parts = useBuilder((s) => s.parts);
const activeRoomId = useBuilder((s) => s.activeRoomId);
const sim = useSimulation();
const [bomOpen, setBomOpen] = useState(false);
const [checkFindings, setCheckFindings] = useState<Finding[] | null>(null);
const roomParts = useMemo(() => {
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
}, [parts, activeRoomId]);
const runDesignCheck = () => {
setCheckFindings(validateSystem(roomParts, sim));
};
const applyFindingFix = (finding: Finding) => {
const s = useBuilder.getState();
if (finding.id.startsWith('dead-leg:')) {
s.fixDeadLeg(finding.partIds);
} else if (finding.id.startsWith('diameter-mismatch:')) {
s.fixDiameterMismatch(finding.partIds);
} else if (finding.id.startsWith('pump-dead:')) {
const pumpId = finding.partIds[0];
if (pumpId) {
const p = s.parts[pumpId];
if (p && (p.params.on === 0 || p.params.enabled === 0)) {
s.updatePart(pumpId, { params: { ...p.params, on: 1, enabled: 1 } });
} else {
s.fixPumpDeadEnd(pumpId);
}
}
} else if (finding.id.startsWith('pump-head:')) {
const pumpId = finding.partIds[0];
if (pumpId) {
const p = s.parts[pumpId];
if (p) {
const requiredHead = Math.ceil((p.params.maxHeadFt || 8) * 1.5);
s.updatePart(pumpId, { params: { ...p.params, maxHeadFt: Math.min(30, requiredHead) } });
}
}
} else if (finding.id.startsWith('unsupported-span:')) {
const pipeId = finding.partIds[0];
if (pipeId) {
const p = s.parts[pipeId];
if (p) {
const y = p.position[1];
const supportHeight = Math.max(0.5, y - 0.1);
const latticeId = s.addPart('lattice', [p.position[0], supportHeight / 2, p.position[2]], { snap: false });
const lattice = useBuilder.getState().parts[latticeId];
if (lattice) {
s.updatePart(latticeId, { params: { ...lattice.params, height: supportHeight } });
}
}
}
} else if (finding.id.startsWith('drain-capacity:')) {
const drainId = finding.partIds[0];
if (drainId) {
const p = s.parts[drainId];
if (p) {
const reported = sim.drains?.find((d) => d?.partId === drainId)?.inflowGph;
const inflow = reported ?? sim.flows?.[drainId] ?? 0;
const needed = Math.ceil(inflow / 25) * 25;
s.updatePart(drainId, { params: { ...p.params, capacityGph: Math.min(2000, needed) } });
}
}
} else if (finding.id.startsWith('res-temp-high:')) {
const chillers = Object.values(s.parts).filter((p) => p.type === 'waterChiller');
for (const chiller of chillers) {
s.updatePart(chiller.id, { params: { ...chiller.params, on: 1, targetTemp: 68 } });
}
} else if (finding.id.startsWith('res-ph-low:') || finding.id.startsWith('res-ph-high:')) {
const resId = finding.partIds[0];
if (resId) {
const p = s.parts[resId];
if (p) s.updatePart(resId, { params: { ...p.params, ph: 6.0 } });
}
} else if (finding.id.startsWith('res-ec-low:') || finding.id.startsWith('res-ec-high:')) {
const resId = finding.partIds[0];
if (resId) {
const p = s.parts[resId];
if (p) s.updatePart(resId, { params: { ...p.params, ec: 1.2 } });
}
} else if (finding.id.startsWith('room-co2-open:') || finding.id.startsWith('room-co2-missing:') || finding.id.startsWith('room-co2-starved:')) {
const tentId = finding.partIds[0];
if (tentId) {
const p = s.parts[tentId];
if (p) {
// If the CO2 open warning is active, seal the room. If CO2 is starved/missing, unseal the room to swap with fresh air.
const isCo2Open = finding.id.startsWith('room-co2-open:');
s.updatePart(tentId, { params: { ...p.params, sealed: isCo2Open ? 1 : 0 } });
}
}
} else if (finding.id.startsWith('room-sealed-exhaust:')) {
const tentId = finding.partIds[0];
if (tentId) {
const p = s.parts[tentId];
if (p) s.updatePart(tentId, { params: { ...p.params, sealed: 0 } });
}
} else if (finding.id.startsWith('room-vent-low:')) {
const fans = Object.values(s.parts).filter((p) => p.type === 'inlineFan');
for (const fan of fans) {
s.updatePart(fan.id, { params: { ...fan.params, cfm: 600 } });
}
} else if (finding.id.startsWith('room-light-low:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } });
}
} else if (finding.id.startsWith('room-light-high:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 200) } });
}
} else if (finding.id.startsWith('room-dli-low:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.min(24, (light.params.photoperiod ?? 18) + 4) } });
}
} else if (finding.id.startsWith('room-dli-high:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.max(0, (light.params.photoperiod ?? 18) - 6) } });
}
} else if (finding.id.startsWith('room-vpd-low:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } });
}
} else if (finding.id.startsWith('room-vpd-high:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 150) } });
}
} else if (finding.id.startsWith('closed-valve:')) {
const valveId = finding.partIds[0];
if (valveId) {
const p = s.parts[valveId];
if (p) s.updatePart(valveId, { params: { ...p.params, open: 100 } });
}
} else if (finding.id.startsWith('part-overlap:')) {
const ids = finding.partIds;
if (ids.length >= 2) {
s.removeParts([ids[1]]);
}
} else if (finding.id.startsWith('orphan:')) {
s.removeParts(finding.partIds);
}
setTimeout(() => {
const nextParts = Object.values(useBuilder.getState().parts);
setCheckFindings(validateSystem(nextParts, sim));
}, 50);
};
/** Handle HTML5 drag-and-drop from the parts library onto the canvas. */
const onDrop = (e: React.DragEvent) => {
const type = e.dataTransfer.getData('application/x-hydro-part') as PartType;
if (!type) return;
e.preventDefault();
const ground = clientToGround(e.clientX, e.clientY);
if (!ground) return;
const s = useBuilder.getState();
const snap = (v: number) => (s.snapToGrid ? snapValue(v, s.gridSize) : v);
const y = ['pipe', 'elbow', 'tee', 'valve', 'emitter'].includes(type) ? 0.25 : 0;
s.addPart(type, [snap(ground[0]), y, snap(ground[2])]);
};
if (loading) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-zinc-950 text-zinc-100">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-sky-500 border-t-transparent mb-4" />
<span className="text-sm font-semibold tracking-wider text-zinc-400">Hydro Builder initializing...</span>
</div>
);
}
if (!user) {
return <LoginModal />;
}
return (
<div className="flex h-screen flex-col bg-zinc-950 text-zinc-100 select-none">
<Toolbar onOpenBom={() => setBomOpen(true)} onCheckBuild={runDesignCheck} />
<div className="flex min-h-0 flex-1">
<PartLibrary />
<main
className="relative min-w-0 flex-1"
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
>
<SceneCanvas remoteCursors={remoteCursors} />
{/* Contextual hint overlay */}
{(placingType || tool === 'measure' || tool === 'pipeRun') && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 rounded-full border border-sky-800 bg-sky-950/90 px-4 py-1.5 text-xs font-medium text-sky-200 shadow-lg">
{placingType
? 'Click to place · Shift-click to stamp copies · 1-9 switches parts · Esc to cancel'
: tool === 'pipeRun'
? 'Pipe Run: click points · Shift-click to keep routing · Esc to exit'
: 'Measure: click two points · Esc to exit'}
</div>
)}
{/* Selection hint */}
{!placingType && tool !== 'measure' && selectedCount > 0 && (
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-zinc-800 bg-zinc-950/85 px-4 py-1.5 text-[11px] font-medium text-zinc-400 shadow-lg backdrop-blur">
{selectedCount > 1
? `${selectedCount} parts — drag moves all · ⌥drag clones · ⇧click adds/removes · ⌘click color · D duplicate · ⌫ delete`
: `Drag body or gizmo to move · double-click plumbing to grab the whole run · selected pipes show stretch handles · drag a connector dot to snap${viewMode === 'orbit' ? ' · ⇧drag for vertical' : ''} · ${renderMode === 'xray' ? 'X-Ray shows internals' : renderMode === 'water' ? 'Water mode isolates flow' : 'Builder mode keeps solids primary'} · ⌥drag clones · ⌘click color · R rotate`}
</div>
)}
<ColorPopover />
</main>
<PropertiesPanel />
</div>
<StatusPanel />
{bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
{checkFindings && (
<DesignCheckPanel
findings={checkFindings}
onSelectPart={(id) => {
const s = useBuilder.getState();
s.setSelected(id);
if (id) {
const part = s.parts[id];
if (part) s.setFocusTarget(part.position);
}
}}
onApplyFix={applyFindingFix}
onClose={() => setCheckFindings(null)}
/>
)}
<ToastContainer />
</div>
);
}
export default function App() {
return (
<AuthProvider>
<BuilderApp />
</AuthProvider>
);
}