Initial project import
This commit is contained in:
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
440
src/App.tsx
Normal file
440
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/auth/AuthContext.tsx
Normal file
172
src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||
import { addToast } from '../components/Toast';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
purchased_slots: number;
|
||||
total_slots: number;
|
||||
project_count: number;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
signup: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshProfile: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('hydro-token'));
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch user details when token changes or on load
|
||||
const fetchUser = async (authToken: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
} else {
|
||||
// Token might be invalid or expired
|
||||
logout();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user info:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchUser(token);
|
||||
} else {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Handle Stripe redirect URL parameters
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('checkout_success') === 'true') {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('checkout_success');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
addToast('Payment successful! Your Premium Upgrade has been unlocked.', 'success');
|
||||
if (token) {
|
||||
fetchUser(token);
|
||||
}
|
||||
} else if (params.get('checkout_cancel') === 'true') {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('checkout_cancel');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
addToast('Payment cancelled.', 'info');
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Login failed');
|
||||
}
|
||||
|
||||
localStorage.setItem('hydro-token', data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown login error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const signup = async (email: string, password: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Signup failed');
|
||||
}
|
||||
|
||||
localStorage.setItem('hydro-token', data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown signup error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('hydro-token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const refreshProfile = async () => {
|
||||
if (token) {
|
||||
await fetchUser(token);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
token,
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
login,
|
||||
signup,
|
||||
logout,
|
||||
refreshProfile,
|
||||
clearError
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
209
src/auth/LoginModal.tsx
Normal file
209
src/auth/LoginModal.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export const LoginModal: React.FC = () => {
|
||||
const { login, signup, error, clearError } = useAuth();
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uiError, setUiError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
setUiError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setUiError(null);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
if (isSignUp) {
|
||||
await signup(email, password);
|
||||
} else {
|
||||
await login(email, password);
|
||||
}
|
||||
} catch (err) {
|
||||
setUiError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-zinc-950 overflow-y-auto px-4 py-8">
|
||||
{/* Background radial accent */}
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 bg-[radial-gradient(circle_at_30%_20%,rgba(14,165,233,0.1),transparent_40%)] pointer-events-none" />
|
||||
|
||||
<div className="w-full max-w-5xl grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 items-center relative z-10 my-auto">
|
||||
|
||||
{/* Left Side: Product Feature Hype */}
|
||||
<div className="lg:col-span-7 flex flex-col text-left space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-sky-400 to-blue-600 text-sm font-black text-white shadow-md shadow-sky-500/10">
|
||||
H
|
||||
</span>
|
||||
<span className="text-sm font-bold tracking-wider text-sky-400 uppercase">
|
||||
Hydro Builder
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-black text-white leading-tight tracking-tight">
|
||||
Stop Guessing. <br className="hidden sm:inline" />
|
||||
<span className="bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">
|
||||
Precision-Plumb
|
||||
</span> Your Grow.
|
||||
</h1>
|
||||
|
||||
<p className="text-sm sm:text-base text-zinc-400 max-w-xl leading-relaxed">
|
||||
Eliminate design errors, head-loss issues, and unnecessary trips to the hardware store. Design, simulate, and size your entire water system in full interactive 3D.
|
||||
</p>
|
||||
|
||||
{/* Feature Teasers List */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5 pt-2">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">💧</span>
|
||||
<h3 className="text-sm font-bold text-zinc-200">Real-Time Fluid Simulation</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
|
||||
Instant calculations for GPH flow rates, water velocities (ft/s), and system pressures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">📋</span>
|
||||
<h3 className="text-sm font-bold text-zinc-200">BOM & Cut List Generator</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
|
||||
One-click material summaries of every fitting, adapter, and pipe length. Your design is your shopping list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">📐</span>
|
||||
<h3 className="text-sm font-bold text-zinc-200">Auto-Snapping & Route Tools</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
|
||||
Automatic elbow insertions, grid snaps, and open-ended pipe caps. Plumb runs in seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">🔍</span>
|
||||
<h3 className="text-sm font-bold text-zinc-200">Pro Design Check Diagnostics</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
|
||||
Automated error checks for dead-legs, backwards slopes, and diameter size mismatches.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Value Prop Banner */}
|
||||
<div className="border border-zinc-800 bg-zinc-900/30 rounded-xl p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 max-w-xl">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-sky-400 uppercase tracking-widest block">Pricing model</span>
|
||||
<span className="text-sm font-bold text-zinc-200">1 free workspace forever</span>
|
||||
</div>
|
||||
<div className="hidden sm:block h-6 w-px bg-zinc-800" />
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-widest block">Unlimited building</span>
|
||||
<span className="text-sm font-bold text-zinc-200">Only $5 per additional build slot</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Auth Panel Card */}
|
||||
<div className="lg:col-span-5 w-full flex justify-center">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-zinc-850 bg-zinc-900/80 p-8 shadow-2xl backdrop-blur-md">
|
||||
<div className="flex flex-col items-center mb-6 text-center">
|
||||
<h2 className="text-xl font-bold text-zinc-100">
|
||||
{isSignUp ? 'Create your free account' : 'Welcome Back'}
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
{isSignUp ? 'Get instant access to your free build slot' : 'Sign in to access your designs'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(uiError || error) && (
|
||||
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-400">
|
||||
{uiError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1" htmlFor="email">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3.5 py-2.5 text-sm text-zinc-200 outline-none transition focus:border-sky-500 focus:bg-zinc-950"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete={isSignUp ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3.5 py-2.5 text-sm text-zinc-200 outline-none transition focus:border-sky-500 focus:bg-zinc-950"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-gradient-to-r from-sky-500 to-blue-600 py-3 text-sm font-semibold text-white transition hover:from-sky-400 hover:to-blue-500 disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer shadow-lg shadow-sky-500/10"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
) : isSignUp ? (
|
||||
'Sign Up & Build'
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSignUp(!isSignUp);
|
||||
setUiError(null);
|
||||
clearError();
|
||||
}}
|
||||
className="text-xs font-medium text-sky-400 hover:text-sky-300 transition"
|
||||
>
|
||||
{isSignUp ? 'Already have an account? Sign In' : "Don't have an account? Sign Up"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
321
src/bom/BomPanel.tsx
Normal file
321
src/bom/BomPanel.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { computeBom, formatDiameter, formatInches, formatMoney } from './computeBom';
|
||||
import { bomToText, downloadBomCsv } from './csv';
|
||||
import { downloadBomPdf } from './pdf';
|
||||
import type { BomLine, CutList } from './types';
|
||||
import { exportToGLTF, exportToOBJ } from '../utils/cadExporter';
|
||||
|
||||
/**
|
||||
* BomPanel — self-contained Bill of Materials drawer.
|
||||
*
|
||||
* Renders a fixed-position overlay (no portals) with a right-hand drawer:
|
||||
* summary header, per-category line-item table, expandable cut lists, and
|
||||
* Export CSV / Copy as text / Print actions. Recomputes via useMemo whenever
|
||||
* the parts in the builder store change.
|
||||
*
|
||||
* Mount it conditionally, e.g.:
|
||||
* {bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
|
||||
*/
|
||||
export function BomPanel({ onClose }: { onClose: () => void }) {
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const projectName = useBuilder((s) => s.projectName);
|
||||
const scene = useBuilder((s) => s.scene);
|
||||
|
||||
const roomParts = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
|
||||
}, [parts, activeRoomId]);
|
||||
|
||||
const bom = useMemo(() => computeBom(roomParts), [roomParts]);
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(bomToText(bom));
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable (permissions) — ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
const win = window.open('', '_blank', 'width=720,height=900');
|
||||
if (!win) return;
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
win.document.write(
|
||||
`<!doctype html><title>${esc(projectName)} — Bill of Materials</title>` +
|
||||
`<pre style="font: 12px/1.5 ui-monospace, Menlo, monospace; padding: 24px;">` +
|
||||
esc(bomToText(bom)) +
|
||||
`</pre>`,
|
||||
);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
win.print();
|
||||
};
|
||||
|
||||
// Group lines by category for sectioned rendering.
|
||||
const sections = useMemo(() => {
|
||||
const map = new Map<string, BomLine[]>();
|
||||
for (const line of bom.lines) {
|
||||
const arr = map.get(line.category);
|
||||
if (arr) arr.push(line);
|
||||
else map.set(line.category, [line]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}, [bom]);
|
||||
|
||||
const subtotalOf = (name: string) =>
|
||||
bom.categories.find((c) => c.name === name)?.subtotal ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="relative flex h-full w-full max-w-xl flex-col border-l border-zinc-800 bg-zinc-950 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 border-b border-zinc-800 px-5 py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wide text-zinc-100">
|
||||
Bill of Materials
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">{projectName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close (Esc)"
|
||||
className="rounded-md px-2 py-1 text-sm text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-end justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-sky-400 tabular-nums">
|
||||
{formatMoney(bom.totalCost)}
|
||||
<span className="ml-1.5 text-xs font-medium text-zinc-500">est. total</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 tabular-nums">
|
||||
{bom.partCount} parts · {bom.lines.length} line items
|
||||
{bom.cutLists.length > 0 &&
|
||||
` · ${bom.cutLists.reduce((s, c) => s + c.sticksToBuy, 0)} PVC sticks`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 justify-end">
|
||||
<ActionBtn onClick={() => downloadBomCsv(bom, projectName)} title="Download as CSV">
|
||||
Export CSV
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={() => downloadBomPdf(bom, projectName)} title="Download as PDF">
|
||||
Export PDF
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={handleCopy} title="Copy shopping list to clipboard">
|
||||
{copied ? 'Copied' : 'Copy as text'}
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={handlePrint} title="Print shopping list">
|
||||
Print
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => scene && exportToGLTF(scene, `${projectName}.gltf`)}
|
||||
title={scene ? "Export 3D layout as glTF" : "Scene loading..."}
|
||||
disabled={!scene}
|
||||
>
|
||||
Export glTF
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => scene && exportToOBJ(scene, `${projectName}.obj`)}
|
||||
title={scene ? "Export 3D layout as OBJ" : "Scene loading..."}
|
||||
disabled={!scene}
|
||||
>
|
||||
Export OBJ
|
||||
</ActionBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{bom.partCount === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-zinc-600">
|
||||
Nothing here yet — place some parts in the scene to build a shopping list.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([category, lines]) => (
|
||||
<section key={category} className="mb-5">
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
{category}
|
||||
</h3>
|
||||
<span className="text-[11px] font-semibold text-zinc-400 tabular-nums">
|
||||
{formatMoney(subtotalOf(category))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800">
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={line.key}
|
||||
className={`flex items-start justify-between gap-3 px-3 py-2 ${
|
||||
i % 2 === 1 ? 'bg-zinc-900/40' : 'bg-zinc-900/10'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-zinc-200">
|
||||
{line.description}
|
||||
{line.qty > 1 || line.unit === 'stick' ? (
|
||||
<span className="ml-1.5 text-zinc-500">×{line.qty}</span>
|
||||
) : null}
|
||||
{line.estimated && (
|
||||
<span className="ml-1.5 rounded bg-amber-950/80 px-1 py-px text-[9px] font-semibold text-amber-400 ring-1 ring-amber-900">
|
||||
est.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{line.spec && (
|
||||
<div className="mt-0.5 text-[10px] text-zinc-500">{line.spec}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-xs font-semibold text-zinc-200 tabular-nums">
|
||||
{formatMoney(line.totalCost)}
|
||||
</div>
|
||||
{line.qty > 1 && (
|
||||
<div className="text-[10px] text-zinc-500 tabular-nums">
|
||||
{formatMoney(line.unitCost)} / {line.unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{bom.cutLists.length > 0 && (
|
||||
<section className="mb-5">
|
||||
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Cut list — 10 ft sticks
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{bom.cutLists.map((list) => (
|
||||
<CutListCard key={list.diameterIn} list={list} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 border-t border-zinc-800 px-5 py-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">
|
||||
Prices are rough US retail estimates, not quotes.
|
||||
</span>
|
||||
<span className="font-bold text-zinc-100 tabular-nums">
|
||||
{formatMoney(bom.totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expandable per-diameter cut plan card. */
|
||||
function CutListCard({ list }: { list: CutList }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/30">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left transition hover:bg-zinc-800/50"
|
||||
>
|
||||
<span className="text-xs font-semibold text-zinc-200">
|
||||
{formatDiameter(list.diameterIn)} PVC
|
||||
<span className="ml-2 font-normal text-zinc-500">
|
||||
buy {list.sticksToBuy} stick{list.sticksToBuy === 1 ? '' : 's'} · {list.cuts.length}{' '}
|
||||
cuts · {formatInches(list.totalLeftoverIn)} scrap
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-500">{open ? 'Hide' : 'Show'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-zinc-800 px-3 py-2">
|
||||
{list.sticks.map((stick) => (
|
||||
<div key={stick.index} className="flex items-baseline gap-2 py-0.5">
|
||||
<span className="w-14 shrink-0 text-[10px] font-semibold text-zinc-500">
|
||||
Stick {stick.index}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-300 tabular-nums">
|
||||
{stick.cuts.map((c) => formatInches(c)).join(', ')}
|
||||
<span className="ml-1.5 text-zinc-500">
|
||||
— {formatInches(stick.leftoverIn)} left
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{list.spliceCount > 0 && (
|
||||
<p className="mt-1 text-[10px] text-amber-400/90">
|
||||
{list.spliceCount} coupling{list.spliceCount === 1 ? '' : 's'} needed to splice runs
|
||||
longer than 10 ft.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBtn({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition ${
|
||||
disabled
|
||||
? 'bg-zinc-800 text-zinc-500 ring-1 ring-zinc-700 cursor-not-allowed opacity-50'
|
||||
: 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700 hover:bg-sky-500/25'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
344
src/bom/computeBom.ts
Normal file
344
src/bom/computeBom.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { PlacedPart } from '../types';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
import {
|
||||
STICK_LENGTH_IN,
|
||||
UNKNOWN_PART_PRICE,
|
||||
cuFtToGallons,
|
||||
equipmentPrice,
|
||||
fittingPrice,
|
||||
pvcStickPrice,
|
||||
round2,
|
||||
} from './prices';
|
||||
import type { Bom, BomLine, CutList, CutStick } from './types';
|
||||
|
||||
/**
|
||||
* computeBom — pure function turning placed parts into a shopping list,
|
||||
* per-diameter PVC cut plans, and a cost estimate.
|
||||
*
|
||||
* Grouping rules:
|
||||
* - `pipe` parts are aggregated per diameter into a CutList (10-ft sticks,
|
||||
* first-fit-decreasing bin packing) and one "sticks to buy" line.
|
||||
* - Fitting-like parts (elbow, tee, valve, ... anything small with a
|
||||
* `diameter` param) are grouped by type + diameter (+ angle for elbows).
|
||||
* - Equipment/grow parts are grouped by type + full param signature so
|
||||
* identical units collapse into one line with their key specs.
|
||||
* - Unknown part types never crash: they get the catalog label/category if
|
||||
* present at runtime, otherwise a prettified type name and "Other", and a
|
||||
* flat fallback price flagged as estimated.
|
||||
*/
|
||||
export function computeBom(parts: PlacedPart[]): Bom {
|
||||
const lines: BomLine[] = [];
|
||||
|
||||
const pipesByDiameter = new Map<number, PlacedPart[]>();
|
||||
const groups = new Map<string, PlacedPart[]>();
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'pipe') {
|
||||
const d = part.params.diameter ?? 1;
|
||||
pushMap(pipesByDiameter, d, part);
|
||||
} else {
|
||||
pushMap(groups, groupKey(part), part);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pipe lines + cut lists ---
|
||||
const cutLists: CutList[] = [];
|
||||
for (const [diameter, pipes] of [...pipesByDiameter.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const cutList = buildCutList(diameter, pipes);
|
||||
cutLists.push(cutList);
|
||||
|
||||
const unitCost = pvcStickPrice(diameter);
|
||||
lines.push({
|
||||
key: `pipe:${diameter}`,
|
||||
partType: 'pipe',
|
||||
category: categoryOf('pipe'),
|
||||
description: `${formatDiameter(diameter)} PVC Pipe — 10 ft stick`,
|
||||
spec: `${formatInches(cutList.totalCutIn)} total run, ${cutList.cuts.length} cuts across ${pipes.length} pipe${pipes.length === 1 ? '' : 's'}`,
|
||||
qty: cutList.sticksToBuy,
|
||||
unit: 'stick',
|
||||
unitCost,
|
||||
totalCost: round2(unitCost * cutList.sticksToBuy),
|
||||
estimated: false,
|
||||
partIds: pipes.map((p) => p.id),
|
||||
});
|
||||
|
||||
// Runs longer than one stick need couplings to splice the pieces.
|
||||
if (cutList.spliceCount > 0) {
|
||||
const couplingCost = fittingPrice('coupling', diameter) ?? 1;
|
||||
lines.push({
|
||||
key: `coupling:${diameter}`,
|
||||
partType: 'coupling',
|
||||
category: categoryOf('pipe'),
|
||||
description: `${formatDiameter(diameter)} PVC Coupling`,
|
||||
spec: 'splices for runs longer than 10 ft',
|
||||
qty: cutList.spliceCount,
|
||||
unit: 'each',
|
||||
unitCost: couplingCost,
|
||||
totalCost: round2(couplingCost * cutList.spliceCount),
|
||||
estimated: false,
|
||||
partIds: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Everything else ---
|
||||
for (const members of groups.values()) {
|
||||
lines.push(buildLine(members));
|
||||
}
|
||||
|
||||
sortLines(lines);
|
||||
|
||||
// --- Rollups ---
|
||||
const byCategory = new Map<string, number>();
|
||||
for (const line of lines) {
|
||||
byCategory.set(line.category, (byCategory.get(line.category) ?? 0) + line.totalCost);
|
||||
}
|
||||
const categories = [...byCategory.entries()]
|
||||
.map(([name, subtotal]) => ({ name, subtotal: round2(subtotal) }))
|
||||
.sort((a, b) => categoryRank(a.name) - categoryRank(b.name));
|
||||
|
||||
return {
|
||||
lines,
|
||||
cutLists,
|
||||
categories,
|
||||
totalCost: round2(lines.reduce((sum, l) => sum + l.totalCost, 0)),
|
||||
partCount: parts.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- grouping ----------
|
||||
|
||||
/** Part types priced as one-off equipment with a spec string. */
|
||||
const EQUIPMENT_SPEC: Record<string, (p: Record<string, number>) => string> = {
|
||||
pump: (p) => `${p.gph ?? 400} GPH, ${p.maxHeadFt ?? 8} ft max head`,
|
||||
reservoir: (p) => {
|
||||
const w = p.width ?? 2, d = p.depth ?? 1.4, h = p.height ?? 1;
|
||||
return `${w} × ${d} × ${h} ft, ${cuFtToGallons(w * d * h).toFixed(1)} gal`;
|
||||
},
|
||||
tower: (p) => `${p.height ?? 4} ft tall, ${p.sites ?? 6} plant sites`,
|
||||
tray: (p) => `${p.length ?? 3} × ${p.width ?? 1.2} ft`,
|
||||
wallPanel: (p) =>
|
||||
`${p.width ?? 3} × ${p.height ?? 4} ft, ${(p.rows ?? 4) * (p.cols ?? 5)} pockets`,
|
||||
lattice: (p) => `${p.width ?? 2.5} × ${p.height ?? 3} ft`,
|
||||
netPot: (p) => `size ${p.size ?? 1}`,
|
||||
drain: (p) => `${p.capacityGph ?? 250} GPH capacity`,
|
||||
};
|
||||
|
||||
/** Grouping key: identical purchasable items share a key. */
|
||||
function groupKey(part: PlacedPart): string {
|
||||
const p = part.params;
|
||||
if (part.type === 'reducer') {
|
||||
return `${part.type}:${p.diameterA ?? 1.5}:${p.diameterB ?? 1}`;
|
||||
}
|
||||
if (hasParam(p, 'diameter') && isFittingLike(part.type)) {
|
||||
// Fittings: diameter (and bend angle) define the SKU.
|
||||
const angle = hasParam(p, 'angle') ? `:${p.angle}` : '';
|
||||
return `${part.type}:${p.diameter}${angle}`;
|
||||
}
|
||||
// Equipment & unknowns: full param signature, ignoring purely cosmetic ones.
|
||||
const sig = Object.keys(p)
|
||||
.filter((k) => k !== 'level' && k !== 'open')
|
||||
.sort()
|
||||
.map((k) => `${k}=${p[k]}`)
|
||||
.join(',');
|
||||
return `${part.type}:${sig}`;
|
||||
}
|
||||
|
||||
function isFittingLike(type: string): boolean {
|
||||
if (type in EQUIPMENT_SPEC) return false;
|
||||
return fittingPrice(type, 1) !== null || !(type in PART_DEFS);
|
||||
}
|
||||
|
||||
/** Build one BomLine from a group of identical parts. */
|
||||
function buildLine(members: PlacedPart[]): BomLine {
|
||||
const part = members[0];
|
||||
const { type, params } = part;
|
||||
const def = (PART_DEFS as Record<string, { label: string; category: string } | undefined>)[type];
|
||||
const label = def?.label ?? prettifyType(type);
|
||||
const category = categoryOf(type);
|
||||
const qty = members.length;
|
||||
|
||||
let unitCost: number;
|
||||
let estimated = false;
|
||||
let description = label;
|
||||
let spec: string | undefined;
|
||||
|
||||
const equipCost = equipmentPrice(type, params);
|
||||
const fitDiameter = fittingDiameter(type, params);
|
||||
const fitCost = fittingPrice(type, fitDiameter);
|
||||
|
||||
if (equipCost !== null) {
|
||||
unitCost = equipCost;
|
||||
spec = EQUIPMENT_SPEC[type]?.(params);
|
||||
} else if (fitCost !== null && type in PART_DEFS) {
|
||||
unitCost = fitCost;
|
||||
if (hasParam(params, 'diameter')) description = `${formatDiameter(params.diameter)} ${label}`;
|
||||
if (type === 'reducer') {
|
||||
description = `${formatDiameter(params.diameterA ?? 1.5)} × ${formatDiameter(params.diameterB ?? 1)} ${label}`;
|
||||
}
|
||||
if (hasParam(params, 'angle') && params.angle !== 90) description += ` (${params.angle}°)`;
|
||||
} else if (type in PART_DEFS && hasParam(params, 'diameter')) {
|
||||
// Known catalog type we have no price row for, but it looks like a fitting.
|
||||
unitCost = fittingPrice('generic', params.diameter) ?? UNKNOWN_PART_PRICE;
|
||||
estimated = true;
|
||||
description = `${formatDiameter(params.diameter)} ${label}`;
|
||||
} else {
|
||||
// Unknown / unpriced type: fallback price, clearly marked estimated.
|
||||
unitCost = UNKNOWN_PART_PRICE;
|
||||
estimated = true;
|
||||
const sig = Object.entries(params)
|
||||
.map(([k, v]) => `${k} ${v}`)
|
||||
.join(', ');
|
||||
spec = sig || undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
key: groupKey(part),
|
||||
partType: type,
|
||||
category,
|
||||
description,
|
||||
spec,
|
||||
qty,
|
||||
unit: 'each',
|
||||
unitCost: round2(unitCost),
|
||||
totalCost: round2(unitCost * qty),
|
||||
estimated,
|
||||
partIds: members.map((m) => m.id),
|
||||
};
|
||||
}
|
||||
|
||||
function fittingDiameter(type: string, params: Record<string, number>): number {
|
||||
if (type === 'reducer') return Math.max(params.diameterA ?? 1.5, params.diameterB ?? 1);
|
||||
return params.diameter ?? 1;
|
||||
}
|
||||
|
||||
// ---------- cut list ----------
|
||||
|
||||
/**
|
||||
* Build the cut plan for one diameter: every pipe length (ft → in) becomes a
|
||||
* cut; runs longer than a stick are split into full sticks plus a remainder
|
||||
* (each split adds a coupling/splice). Remaining cuts are bin-packed into
|
||||
* 120-in sticks with first-fit decreasing.
|
||||
*/
|
||||
function buildCutList(diameterIn: number, pipes: PlacedPart[]): CutList {
|
||||
const cuts: number[] = [];
|
||||
let spliceCount = 0;
|
||||
|
||||
for (const pipe of pipes) {
|
||||
let remaining = round2((pipe.params.length ?? 2) * 12);
|
||||
if (remaining <= 0) continue;
|
||||
const pieces: number[] = [];
|
||||
while (remaining > STICK_LENGTH_IN) {
|
||||
pieces.push(STICK_LENGTH_IN);
|
||||
remaining = round2(remaining - STICK_LENGTH_IN);
|
||||
}
|
||||
if (remaining > 0) pieces.push(remaining);
|
||||
spliceCount += pieces.length - 1;
|
||||
cuts.push(...pieces);
|
||||
}
|
||||
|
||||
const sticks = firstFitDecreasing(cuts, STICK_LENGTH_IN);
|
||||
const totalCutIn = round2(cuts.reduce((s, c) => s + c, 0));
|
||||
|
||||
return {
|
||||
diameterIn,
|
||||
stickLengthIn: STICK_LENGTH_IN,
|
||||
cuts: [...cuts].sort((a, b) => b - a),
|
||||
sticks,
|
||||
sticksToBuy: sticks.length,
|
||||
totalCutIn,
|
||||
totalLeftoverIn: round2(sticks.reduce((s, st) => s + st.leftoverIn, 0)),
|
||||
spliceCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Classic first-fit-decreasing bin packing into fixed-capacity sticks. */
|
||||
function firstFitDecreasing(cuts: number[], capacity: number): CutStick[] {
|
||||
const sorted = [...cuts].sort((a, b) => b - a);
|
||||
const sticks: CutStick[] = [];
|
||||
|
||||
for (const cut of sorted) {
|
||||
let placed = false;
|
||||
for (const stick of sticks) {
|
||||
// Tiny epsilon so float dust never overflows a stick.
|
||||
if (stick.usedIn + cut <= capacity + 1e-9) {
|
||||
stick.cuts.push(cut);
|
||||
stick.usedIn = round2(stick.usedIn + cut);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
sticks.push({ index: sticks.length + 1, cuts: [cut], usedIn: cut, leftoverIn: 0 });
|
||||
}
|
||||
}
|
||||
for (const stick of sticks) stick.leftoverIn = round2(capacity - stick.usedIn);
|
||||
return sticks;
|
||||
}
|
||||
|
||||
// ---------- formatting & ordering helpers ----------
|
||||
|
||||
const CATEGORY_ORDER = ['Plumbing', 'Flow Control', 'Growing', 'Structure', 'Other'];
|
||||
|
||||
function categoryRank(name: string): number {
|
||||
const i = CATEGORY_ORDER.indexOf(name);
|
||||
return i === -1 ? CATEGORY_ORDER.length : i;
|
||||
}
|
||||
|
||||
/** Runtime category lookup — unknown types land in 'Other'. */
|
||||
function categoryOf(type: string): string {
|
||||
const def = (PART_DEFS as Record<string, { category?: string } | undefined>)[type];
|
||||
return def?.category ?? 'Other';
|
||||
}
|
||||
|
||||
function sortLines(lines: BomLine[]) {
|
||||
lines.sort(
|
||||
(a, b) =>
|
||||
categoryRank(a.category) - categoryRank(b.category) ||
|
||||
a.description.localeCompare(b.description),
|
||||
);
|
||||
}
|
||||
|
||||
/** 'growTent' → 'Grow Tent'. */
|
||||
function prettifyType(type: string): string {
|
||||
return type
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
const DIAMETER_FRACTIONS: Record<string, string> = {
|
||||
'0.25': '1/4', '0.5': '1/2', '0.75': '3/4', '1.25': '1-1/4',
|
||||
'1.5': '1-1/2', '1.75': '1-3/4', '2.5': '2-1/2', '3.5': '3-1/2',
|
||||
};
|
||||
|
||||
/** 0.75 → '3/4 in', 1.5 → '1-1/2 in', 2 → '2 in'. */
|
||||
export function formatDiameter(diameterIn: number): string {
|
||||
if (Number.isInteger(diameterIn)) return `${diameterIn} in`;
|
||||
return `${DIAMETER_FRACTIONS[String(diameterIn)] ?? String(diameterIn)} in`;
|
||||
}
|
||||
|
||||
/** Inches → feet+inches display: 46.5 → 3' 10.5", 24 → 2', 8 → 8". */
|
||||
export function formatInches(inches: number): string {
|
||||
const ft = Math.floor(inches / 12);
|
||||
const rest = round2(inches - ft * 12);
|
||||
if (ft === 0) return `${trimNum(rest)}"`;
|
||||
if (rest === 0) return `${ft}'`;
|
||||
return `${ft}' ${trimNum(rest)}"`;
|
||||
}
|
||||
|
||||
/** USD display with cents. */
|
||||
export function formatMoney(usd: number): string {
|
||||
return `$${usd.toFixed(2)}`;
|
||||
}
|
||||
|
||||
const trimNum = (n: number) => String(round2(n));
|
||||
|
||||
function hasParam(params: Record<string, number>, key: string): boolean {
|
||||
return typeof params[key] === 'number';
|
||||
}
|
||||
|
||||
function pushMap<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(value);
|
||||
else map.set(key, [value]);
|
||||
}
|
||||
136
src/bom/csv.ts
Normal file
136
src/bom/csv.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { formatDiameter, formatInches, formatMoney } from './computeBom';
|
||||
import type { Bom } from './types';
|
||||
|
||||
/**
|
||||
* Exporters: CSV (sectioned spreadsheet), plain-text shopping list, and a
|
||||
* blob download helper following the pattern in utils/serializer.ts.
|
||||
*/
|
||||
|
||||
/** Escape one CSV cell (quotes cells containing commas/quotes/newlines). */
|
||||
function cell(value: string | number): string {
|
||||
const s = String(value);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
const row = (...cells: (string | number)[]) => cells.map(cell).join(',');
|
||||
|
||||
/**
|
||||
* Serialize a Bom to CSV with three sections: LINE ITEMS, CUT LIST, TOTALS.
|
||||
* Sections are separated by blank lines and introduced by a single-cell
|
||||
* heading row so the file reads cleanly in any spreadsheet app.
|
||||
*/
|
||||
export function bomToCsv(bom: Bom): string {
|
||||
const out: string[] = [];
|
||||
|
||||
out.push(row('LINE ITEMS'));
|
||||
out.push(row('Category', 'Item', 'Spec', 'Qty', 'Unit', 'Unit Cost', 'Total', 'Pricing'));
|
||||
for (const line of bom.lines) {
|
||||
out.push(
|
||||
row(
|
||||
line.category,
|
||||
line.description,
|
||||
line.spec ?? '',
|
||||
line.qty,
|
||||
line.unit,
|
||||
line.unitCost.toFixed(2),
|
||||
line.totalCost.toFixed(2),
|
||||
line.estimated ? 'est.' : 'listed',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (bom.cutLists.length > 0) {
|
||||
out.push('');
|
||||
out.push(row('CUT LIST'));
|
||||
out.push(row('Diameter', 'Stick', 'Cuts (in)', 'Used (in)', 'Leftover (in)'));
|
||||
for (const list of bom.cutLists) {
|
||||
for (const stick of list.sticks) {
|
||||
out.push(
|
||||
row(
|
||||
formatDiameter(list.diameterIn),
|
||||
`Stick ${stick.index}`,
|
||||
stick.cuts.join(' | '),
|
||||
stick.usedIn,
|
||||
stick.leftoverIn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push('');
|
||||
out.push(row('TOTALS'));
|
||||
for (const cat of bom.categories) {
|
||||
out.push(row(cat.name, cat.subtotal.toFixed(2)));
|
||||
}
|
||||
out.push(row('Grand total', bom.totalCost.toFixed(2)));
|
||||
out.push(row('Part count', bom.partCount));
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** Printable plain-text shopping list (monospace-friendly). */
|
||||
export function bomToText(bom: Bom): string {
|
||||
const out: string[] = [];
|
||||
const hr = '─'.repeat(58);
|
||||
|
||||
out.push('HYDRO BUILDER — SHOPPING LIST');
|
||||
out.push(`${bom.partCount} parts · estimated total ${formatMoney(bom.totalCost)}`);
|
||||
out.push(hr);
|
||||
|
||||
let currentCategory = '';
|
||||
for (const line of bom.lines) {
|
||||
if (line.category !== currentCategory) {
|
||||
currentCategory = line.category;
|
||||
out.push('');
|
||||
out.push(`${currentCategory.toUpperCase()}`);
|
||||
}
|
||||
const name = line.qty > 1 || line.unit === 'stick'
|
||||
? `${line.description} ×${line.qty}`
|
||||
: line.description;
|
||||
const price = `${formatMoney(line.totalCost)}${line.estimated ? ' est.' : ''}`;
|
||||
out.push(` ${name.padEnd(44)} ${price}`);
|
||||
if (line.spec) out.push(` ${line.spec}`);
|
||||
}
|
||||
|
||||
if (bom.cutLists.length > 0) {
|
||||
out.push('');
|
||||
out.push(hr);
|
||||
out.push('CUT LIST (10 ft sticks)');
|
||||
for (const list of bom.cutLists) {
|
||||
out.push('');
|
||||
out.push(
|
||||
`${formatDiameter(list.diameterIn)} PVC — buy ${list.sticksToBuy} stick${list.sticksToBuy === 1 ? '' : 's'}, ` +
|
||||
`${formatInches(list.totalCutIn)} of cuts, ${formatInches(list.totalLeftoverIn)} scrap`,
|
||||
);
|
||||
for (const stick of list.sticks) {
|
||||
const cuts = stick.cuts.map((c) => formatInches(c)).join(', ');
|
||||
out.push(` Stick ${stick.index}: ${cuts} — ${formatInches(stick.leftoverIn)} left`);
|
||||
}
|
||||
if (list.spliceCount > 0) {
|
||||
out.push(` (${list.spliceCount} coupling${list.spliceCount === 1 ? '' : 's'} needed for runs over 10 ft)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push('');
|
||||
out.push(hr);
|
||||
for (const cat of bom.categories) {
|
||||
out.push(`${cat.name.padEnd(46)} ${formatMoney(cat.subtotal)}`);
|
||||
}
|
||||
out.push(`${'TOTAL (estimated)'.padEnd(46)} ${formatMoney(bom.totalCost)}`);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** Trigger a browser download of the BOM as a CSV file. */
|
||||
export function downloadBomCsv(bom: Bom, projectName: string) {
|
||||
const blob = new Blob([bomToCsv(bom)], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const safe = projectName.replace(/[^a-z0-9-_ ]/gi, '').trim() || 'hydro-system';
|
||||
a.download = `${safe}-bom.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
20
src/bom/index.ts
Normal file
20
src/bom/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Bill of Materials module — public API.
|
||||
*
|
||||
* Pure logic:
|
||||
* computeBom(parts) -> Bom (line items, cut lists, costs)
|
||||
* bomToCsv(bom) -> sectioned CSV string
|
||||
* bomToText(bom) -> printable plain-text shopping list
|
||||
* downloadBomCsv(bom, name) -> trigger a browser .csv download
|
||||
*
|
||||
* UI:
|
||||
* <BomPanel onClose={...} /> -> self-contained drawer reading useBuilder
|
||||
*
|
||||
* Formatting helpers (used by the panel, exported for reuse):
|
||||
* formatDiameter, formatInches, formatMoney
|
||||
*/
|
||||
export { computeBom, formatDiameter, formatInches, formatMoney } from './computeBom';
|
||||
export { bomToCsv, bomToText, downloadBomCsv } from './csv';
|
||||
export { downloadBomPdf } from './pdf';
|
||||
export { BomPanel } from './BomPanel';
|
||||
export type { Bom, BomLine, CutList, CutStick, CategorySubtotal } from './types';
|
||||
215
src/bom/pdf.ts
Normal file
215
src/bom/pdf.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { formatDiameter, formatInches, formatMoney } from './computeBom';
|
||||
import type { Bom } from './types';
|
||||
|
||||
/**
|
||||
* Generates and downloads a professional PDF report of the Bill of Materials.
|
||||
*/
|
||||
export function downloadBomPdf(bom: Bom, projectName: string) {
|
||||
const doc = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
});
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 15;
|
||||
|
||||
// --- 1. Header Section ---
|
||||
doc.setFillColor(15, 23, 42); // slate-900 bg
|
||||
doc.rect(0, 0, pageWidth, 40, 'F');
|
||||
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(18);
|
||||
doc.text('HYDRO BUILDER', margin, 18);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(148, 163, 184); // slate-400
|
||||
doc.text('BILL OF MATERIALS', margin, 24);
|
||||
|
||||
// Project details on the right side of header
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
const nameText = projectName.length > 30 ? projectName.substring(0, 27) + '...' : projectName;
|
||||
doc.text(nameText, pageWidth - margin, 18, { align: 'right' });
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(148, 163, 184);
|
||||
doc.text(`Date: ${new Date().toLocaleDateString()}`, pageWidth - margin, 24, { align: 'right' });
|
||||
|
||||
let currentY = 50;
|
||||
|
||||
// --- 2. Summary Stats Card ---
|
||||
doc.setFillColor(248, 250, 252); // slate-50
|
||||
doc.setDrawColor(226, 232, 240); // slate-200
|
||||
doc.rect(margin, currentY, pageWidth - 2 * margin, 22, 'FD');
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100, 116, 139); // slate-500
|
||||
|
||||
doc.text('TOTAL ESTIMATED COST', margin + 8, currentY + 7);
|
||||
doc.text('PART COUNT', margin + 65, currentY + 7);
|
||||
doc.text('LINE ITEMS', margin + 115, currentY + 7);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(14, 116, 144); // cyan-700
|
||||
doc.text(formatMoney(bom.totalCost), margin + 8, currentY + 15);
|
||||
|
||||
doc.setTextColor(15, 23, 42); // slate-900
|
||||
doc.text(`${bom.partCount} parts`, margin + 65, currentY + 15);
|
||||
doc.text(`${bom.lines.length} items`, margin + 115, currentY + 15);
|
||||
|
||||
if (bom.cutLists.length > 0) {
|
||||
const totalSticks = bom.cutLists.reduce((s, c) => s + c.sticksToBuy, 0);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.text('PVC STICKS (10 FT)', margin + 150, currentY + 7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.text(`${totalSticks} sticks`, margin + 150, currentY + 15);
|
||||
}
|
||||
|
||||
currentY += 32;
|
||||
|
||||
// --- 3. Line Items Table ---
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.text('Line Items', margin, currentY);
|
||||
currentY += 4;
|
||||
|
||||
const lineColumns = ['Category', 'Description', 'Spec', 'Qty', 'Unit', 'Unit Cost', 'Total'];
|
||||
const lineRows = bom.lines.map((l) => [
|
||||
l.category,
|
||||
l.description,
|
||||
l.spec ?? '',
|
||||
l.qty,
|
||||
l.unit,
|
||||
formatMoney(l.unitCost),
|
||||
formatMoney(l.totalCost) + (l.estimated ? ' (est.)' : ''),
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: currentY,
|
||||
head: [lineColumns],
|
||||
body: lineRows,
|
||||
margin: { left: margin, right: margin },
|
||||
theme: 'grid',
|
||||
styles: { fontSize: 8, cellPadding: 2, font: 'helvetica' },
|
||||
headStyles: { fillColor: [39, 39, 42], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 25 },
|
||||
1: { cellWidth: 50 },
|
||||
2: { cellWidth: 40 },
|
||||
3: { cellWidth: 12, halign: 'center' },
|
||||
4: { cellWidth: 15, halign: 'center' },
|
||||
5: { cellWidth: 18, halign: 'right' },
|
||||
6: { cellWidth: 20, halign: 'right' },
|
||||
},
|
||||
});
|
||||
|
||||
currentY = (doc as any).lastAutoTable.finalY + 10;
|
||||
|
||||
// --- 4. Cut Lists (Bin Packing) Section ---
|
||||
if (bom.cutLists.length > 0) {
|
||||
// Check if we need a page break
|
||||
if (currentY > doc.internal.pageSize.getHeight() - 40) {
|
||||
doc.addPage();
|
||||
currentY = 20;
|
||||
}
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.text('PVC Cut Lists (10 ft / 120 in sticks)', margin, currentY);
|
||||
currentY += 4;
|
||||
|
||||
const cutColumns = ['Diameter', 'Stick', 'Cuts (in)', 'Used', 'Leftover'];
|
||||
const cutRows: any[] = [];
|
||||
|
||||
for (const list of bom.cutLists) {
|
||||
for (const stick of list.sticks) {
|
||||
const cutsStr = stick.cuts.map((c) => formatInches(c)).join(', ');
|
||||
cutRows.push([
|
||||
formatDiameter(list.diameterIn),
|
||||
`Stick ${stick.index}`,
|
||||
cutsStr,
|
||||
formatInches(stick.usedIn),
|
||||
formatInches(stick.leftoverIn),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
startY: currentY,
|
||||
head: [cutColumns],
|
||||
body: cutRows,
|
||||
margin: { left: margin, right: margin },
|
||||
theme: 'grid',
|
||||
styles: { fontSize: 8, cellPadding: 2, font: 'helvetica' },
|
||||
headStyles: { fillColor: [71, 85, 105], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 25 },
|
||||
1: { cellWidth: 25 },
|
||||
2: { cellWidth: 90 },
|
||||
3: { cellWidth: 20, halign: 'right' },
|
||||
4: { cellWidth: 20, halign: 'right' },
|
||||
},
|
||||
});
|
||||
|
||||
currentY = (doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// --- 5. Cost Summary & Totals ---
|
||||
if (currentY > doc.internal.pageSize.getHeight() - 50) {
|
||||
doc.addPage();
|
||||
currentY = 20;
|
||||
}
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(15, 23, 42);
|
||||
doc.text('Category Subtotals', margin, currentY);
|
||||
currentY += 4;
|
||||
|
||||
const totalColumns = ['Category', 'Subtotal'];
|
||||
const totalRows = bom.categories.map((c) => [c.name, formatMoney(c.subtotal)]);
|
||||
totalRows.push(['Grand Total (Estimated)', formatMoney(bom.totalCost)]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: currentY,
|
||||
head: [totalColumns],
|
||||
body: totalRows,
|
||||
margin: { left: margin, right: pageWidth - 100 }, // right aligned summary table
|
||||
theme: 'grid',
|
||||
styles: { fontSize: 8, cellPadding: 2.5, font: 'helvetica' },
|
||||
headStyles: { fillColor: [15, 23, 42], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 50 },
|
||||
1: { cellWidth: 35, halign: 'right', fontStyle: 'bold' },
|
||||
},
|
||||
didParseCell: (data) => {
|
||||
// Highlight Grand Total row
|
||||
if (data.row.index === totalRows.length - 1) {
|
||||
data.cell.styles.fillColor = [241, 245, 249];
|
||||
data.cell.styles.fontStyle = 'bold';
|
||||
if (data.column.index === 1) {
|
||||
data.cell.styles.textColor = [14, 116, 144];
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- 6. Save & Download ---
|
||||
const safeName = projectName.replace(/[^a-z0-9-_ ]/gi, '').trim() || 'hydro-system';
|
||||
doc.save(`${safeName}-bom.pdf`);
|
||||
}
|
||||
126
src/bom/prices.ts
Normal file
126
src/bom/prices.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Hardware-store price book for the BOM cost estimate.
|
||||
*
|
||||
* Numbers are ballpark US retail (big-box store, schedule-40 PVC) and exist
|
||||
* to give a useful estimate, not a quote. Every price helper degrades
|
||||
* gracefully: unknown part types get `UNKNOWN_PART_PRICE` and are flagged
|
||||
* "est." in the output.
|
||||
*/
|
||||
|
||||
/** Standard purchasable PVC stick length (inches) = 10 ft. */
|
||||
export const STICK_LENGTH_IN = 120;
|
||||
|
||||
/** Fallback price for part types this module doesn't know about (USD). */
|
||||
export const UNKNOWN_PART_PRICE = 5;
|
||||
|
||||
/** Schedule-40 PVC pipe, USD per linear foot, keyed by nominal diameter (in). */
|
||||
const PVC_PER_FOOT: [maxDiameterIn: number, perFoot: number][] = [
|
||||
[0.5, 0.45],
|
||||
[0.75, 0.55],
|
||||
[1, 0.75],
|
||||
[1.25, 0.95],
|
||||
[1.5, 1.1],
|
||||
[2, 1.55],
|
||||
[3, 2.9],
|
||||
[4, 3.8],
|
||||
];
|
||||
|
||||
/** USD per foot of PVC pipe at the given diameter (inches). */
|
||||
export function pvcPerFoot(diameterIn: number): number {
|
||||
for (const [max, price] of PVC_PER_FOOT) {
|
||||
if (diameterIn <= max) return price;
|
||||
}
|
||||
// Beyond 4 in: extrapolate roughly $1/ft per extra inch.
|
||||
return 3.8 + (diameterIn - 4) * 1;
|
||||
}
|
||||
|
||||
/** Price of one full 10-ft stick at the given diameter (USD). */
|
||||
export function pvcStickPrice(diameterIn: number): number {
|
||||
return round2(pvcPerFoot(diameterIn) * (STICK_LENGTH_IN / 12));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fitting prices by diameter tier: small (≤1 in), medium (≤2 in), large (>2 in).
|
||||
* Types not listed here fall back to the generic-fitting row.
|
||||
*/
|
||||
const FITTING_PRICES: Record<string, [small: number, medium: number, large: number]> = {
|
||||
elbow: [0.6, 1.4, 3.6],
|
||||
tee: [0.85, 1.9, 4.6],
|
||||
cross: [1.6, 3.2, 7.5],
|
||||
yJoint: [1.4, 2.8, 6.5],
|
||||
wye: [1.4, 2.8, 6.5],
|
||||
reducer: [0.9, 1.8, 4.2],
|
||||
coupling: [0.45, 0.95, 2.4],
|
||||
valve: [6, 11, 24],
|
||||
emitter: [1.25, 1.25, 1.25],
|
||||
reverseOsmosis: [15, 30, 65],
|
||||
generic: [1, 2, 5],
|
||||
};
|
||||
|
||||
/** Price for a fitting of `type` at `diameterIn` inches; null if not a known fitting. */
|
||||
export function fittingPrice(type: string, diameterIn: number): number | null {
|
||||
const row = FITTING_PRICES[type];
|
||||
if (!row) return null;
|
||||
const tier = diameterIn <= 1 ? 0 : diameterIn <= 2 ? 1 : 2;
|
||||
return row[tier];
|
||||
}
|
||||
|
||||
/** Submersible/inline pump price by rated flow (GPH). */
|
||||
export function pumpPrice(gph: number): number {
|
||||
if (gph <= 200) return 18;
|
||||
if (gph <= 400) return 28;
|
||||
if (gph <= 800) return 45;
|
||||
if (gph <= 1200) return 70;
|
||||
return 110;
|
||||
}
|
||||
|
||||
/** Reservoir/tank price by capacity (US gallons). */
|
||||
export function reservoirPrice(gallons: number): number {
|
||||
if (gallons <= 10) return 15;
|
||||
if (gallons <= 20) return 25;
|
||||
if (gallons <= 40) return 45;
|
||||
if (gallons <= 75) return 80;
|
||||
return 130;
|
||||
}
|
||||
|
||||
/** Cubic feet → US gallons. */
|
||||
export const cuFtToGallons = (cuFt: number) => cuFt * 7.48052;
|
||||
|
||||
/**
|
||||
* Price an individual equipment/grow part from its params. Returns null for
|
||||
* types handled elsewhere (pipes, fittings) or unknown to the price book.
|
||||
*/
|
||||
export function equipmentPrice(type: string, params: Record<string, number>): number | null {
|
||||
switch (type) {
|
||||
case 'pump':
|
||||
return pumpPrice(params.gph ?? 400);
|
||||
case 'reservoir': {
|
||||
const gal = cuFtToGallons((params.width ?? 2) * (params.depth ?? 1.4) * (params.height ?? 1));
|
||||
return reservoirPrice(gal);
|
||||
}
|
||||
case 'tower':
|
||||
// Tower shell + per-foot extrusion + per-site cups.
|
||||
return round2(18 + (params.height ?? 4) * 5 + (params.sites ?? 6) * 1);
|
||||
case 'tray':
|
||||
return round2(12 + (params.length ?? 3) * (params.width ?? 1.2) * 6);
|
||||
case 'wallPanel':
|
||||
return round2(20 + (params.rows ?? 4) * (params.cols ?? 5) * 3);
|
||||
case 'lattice':
|
||||
return round2(10 + (params.width ?? 2.5) * (params.height ?? 3) * 4);
|
||||
case 'netPot':
|
||||
return round2(0.75 * (params.size ?? 1));
|
||||
case 'drain':
|
||||
return (params.capacityGph ?? 250) <= 250 ? 8 : 14;
|
||||
case 'growTent':
|
||||
case 'tent':
|
||||
return round2(90 + (params.width ?? 4) * (params.depth ?? 4) * (params.height ?? 6.5) * 1.2);
|
||||
case 'light':
|
||||
case 'growLight':
|
||||
return round2(40 + (params.watts ?? 100) * 0.6);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Round to cents. */
|
||||
export const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
170
src/bom/selftest.ts
Normal file
170
src/bom/selftest.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* BOM self-test — run with: npx tsx src/bom/selftest.ts
|
||||
*
|
||||
* Exercises computeBom, the cut-list bin packing, and the CSV/text exporters
|
||||
* against a synthetic parts list (including an unknown future part type).
|
||||
* Prints PASS/FAIL per check and exits non-zero on any failure.
|
||||
*/
|
||||
import type { PlacedPart } from '../types';
|
||||
import { computeBom } from './computeBom';
|
||||
import { bomToCsv, bomToText } from './csv';
|
||||
import { STICK_LENGTH_IN, UNKNOWN_PART_PRICE } from './prices';
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function check(name: string, ok: boolean, detail = '') {
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${!ok && detail ? ` — ${detail}` : ''}`);
|
||||
if (!ok) failures++;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
function part(type: string, params: Record<string, number>): PlacedPart {
|
||||
return {
|
||||
id: `t${nextId++}`,
|
||||
type: type as PlacedPart['type'],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- synthetic design ----------
|
||||
|
||||
const parts: PlacedPart[] = [
|
||||
// 1-in pipes: 46.5" + 24" + 18" + 60" + 96" = 244.5" of cuts.
|
||||
part('pipe', { length: 46.5 / 12, diameter: 1 }),
|
||||
part('pipe', { length: 2, diameter: 1 }),
|
||||
part('pipe', { length: 1.5, diameter: 1 }),
|
||||
part('pipe', { length: 5, diameter: 1 }),
|
||||
part('pipe', { length: 8, diameter: 1 }),
|
||||
// A 12-ft run of 2-in pipe: must split into 120" + 24" with one splice.
|
||||
part('pipe', { length: 12, diameter: 2 }),
|
||||
// Fittings: six 1-in elbows (one at 45°), two 1-in tees, one 2-in tee.
|
||||
...Array.from({ length: 5 }, () => part('elbow', { angle: 90, diameter: 1 })),
|
||||
part('elbow', { angle: 45, diameter: 1 }),
|
||||
part('tee', { diameter: 1 }),
|
||||
part('tee', { diameter: 1 }),
|
||||
part('tee', { diameter: 2 }),
|
||||
// Equipment.
|
||||
part('valve', { diameter: 1, open: 100 }),
|
||||
part('pump', { gph: 400, maxHeadFt: 8 }),
|
||||
part('reservoir', { width: 2, depth: 1.4, height: 1, level: 70 }),
|
||||
part('tower', { height: 4, sites: 6 }),
|
||||
part('netPot', { size: 1 }),
|
||||
part('netPot', { size: 1 }),
|
||||
part('emitter', {}),
|
||||
// Unknown future part type — must not crash, must get fallback price.
|
||||
part('flummoxValve', { weirdness: 11 }),
|
||||
];
|
||||
|
||||
const bom = computeBom(parts);
|
||||
|
||||
// ---------- counts & grouping ----------
|
||||
|
||||
check('partCount matches input', bom.partCount === parts.length,
|
||||
`got ${bom.partCount}, want ${parts.length}`);
|
||||
|
||||
const elbow90 = bom.lines.find((l) => l.partType === 'elbow' && l.key.endsWith(':90'));
|
||||
const elbow45 = bom.lines.find((l) => l.partType === 'elbow' && l.key.endsWith(':45'));
|
||||
check('90° elbows grouped (×5)', elbow90?.qty === 5, `got ${elbow90?.qty}`);
|
||||
check('45° elbow split into its own line (×1)', elbow45?.qty === 1, `got ${elbow45?.qty}`);
|
||||
|
||||
const tee1 = bom.lines.find((l) => l.partType === 'tee' && l.key === 'tee:1');
|
||||
const tee2 = bom.lines.find((l) => l.partType === 'tee' && l.key === 'tee:2');
|
||||
check('1-in tees grouped (×2)', tee1?.qty === 2, `got ${tee1?.qty}`);
|
||||
check('2-in tee separate (×1)', tee2?.qty === 1, `got ${tee2?.qty}`);
|
||||
|
||||
const pots = bom.lines.find((l) => l.partType === 'netPot');
|
||||
check('identical net pots grouped (×2)', pots?.qty === 2, `got ${pots?.qty}`);
|
||||
|
||||
const pump = bom.lines.find((l) => l.partType === 'pump');
|
||||
check('pump line carries spec', !!pump?.spec && pump.spec.includes('400 GPH'),
|
||||
`spec: ${pump?.spec}`);
|
||||
|
||||
const reservoir = bom.lines.find((l) => l.partType === 'reservoir');
|
||||
check('reservoir spec includes gallons', !!reservoir?.spec && /gal/.test(reservoir.spec),
|
||||
`spec: ${reservoir?.spec}`);
|
||||
|
||||
// ---------- cut lists ----------
|
||||
|
||||
const list1 = bom.cutLists.find((c) => c.diameterIn === 1);
|
||||
const list2 = bom.cutLists.find((c) => c.diameterIn === 2);
|
||||
check('cut list per diameter (2 lists)', bom.cutLists.length === 2,
|
||||
`got ${bom.cutLists.length}`);
|
||||
|
||||
check('1-in cuts total 244.5 in', list1?.totalCutIn === 244.5, `got ${list1?.totalCutIn}`);
|
||||
|
||||
const allCutsCovered =
|
||||
!!list1 &&
|
||||
list1.sticks.flatMap((s) => s.cuts).sort((a, b) => a - b).join() ===
|
||||
[...list1.cuts].sort((a, b) => a - b).join();
|
||||
check('every 1-in cut appears in exactly one stick', allCutsCovered);
|
||||
|
||||
const noOverflow = bom.cutLists.every((cl) =>
|
||||
cl.sticks.every((s) => s.usedIn <= STICK_LENGTH_IN + 1e-9 &&
|
||||
Math.abs(s.usedIn + s.leftoverIn - STICK_LENGTH_IN) < 0.02),
|
||||
);
|
||||
check('no stick exceeds 120 in; used+leftover = 120', noOverflow);
|
||||
|
||||
// 244.5" of cuts can't fit in 2 sticks (240"); FFD should land on exactly 3.
|
||||
check('1-in plan needs 3 sticks', list1?.sticksToBuy === 3, `got ${list1?.sticksToBuy}`);
|
||||
|
||||
check('12-ft 2-in run split with 1 splice', list2?.spliceCount === 1 && list2.sticksToBuy === 2,
|
||||
`splices ${list2?.spliceCount}, sticks ${list2?.sticksToBuy}`);
|
||||
|
||||
const couplingLine = bom.lines.find((l) => l.partType === 'coupling');
|
||||
check('splice adds a coupling line item', couplingLine?.qty === 1, `got ${couplingLine?.qty}`);
|
||||
|
||||
// ---------- unknown part type ----------
|
||||
|
||||
const unknown = bom.lines.find((l) => l.partType === 'flummoxValve');
|
||||
check('unknown type lands in output', !!unknown);
|
||||
check('unknown type gets fallback price, marked est.',
|
||||
unknown?.unitCost === UNKNOWN_PART_PRICE && unknown.estimated === true,
|
||||
`cost ${unknown?.unitCost}, estimated ${unknown?.estimated}`);
|
||||
check('unknown type categorized as Other', unknown?.category === 'Other',
|
||||
`got ${unknown?.category}`);
|
||||
|
||||
// ---------- totals ----------
|
||||
|
||||
const lineSum = Math.round(bom.lines.reduce((s, l) => s + l.totalCost, 0) * 100) / 100;
|
||||
check('grand total equals sum of lines', Math.abs(bom.totalCost - lineSum) < 0.005,
|
||||
`total ${bom.totalCost}, sum ${lineSum}`);
|
||||
|
||||
const catSum = Math.round(bom.categories.reduce((s, c) => s + c.subtotal, 0) * 100) / 100;
|
||||
check('category subtotals add to grand total', Math.abs(bom.totalCost - catSum) < 0.01,
|
||||
`total ${bom.totalCost}, cats ${catSum}`);
|
||||
|
||||
// ---------- CSV / text sanity ----------
|
||||
|
||||
const csv = bomToCsv(bom);
|
||||
const csvRows = csv.split('\n');
|
||||
const stickRows = bom.cutLists.reduce((s, c) => s + c.sticks.length, 0);
|
||||
// 3 section headers + 2 column-header rows + lines + sticks + cats + total + count + 2 blanks.
|
||||
const expectedRows = 3 + 2 + bom.lines.length + stickRows + bom.categories.length + 2 + 2;
|
||||
check('CSV row count matches structure', csvRows.length === expectedRows,
|
||||
`got ${csvRows.length}, want ${expectedRows}`);
|
||||
check('CSV contains all three sections',
|
||||
csv.includes('LINE ITEMS') && csv.includes('CUT LIST') && csv.includes('TOTALS'));
|
||||
|
||||
const text = bomToText(bom);
|
||||
check('text export mentions stick cut sequence', /Stick 1: .*left/.test(text));
|
||||
check('text export includes grand total', text.includes('TOTAL (estimated)'));
|
||||
|
||||
// ---------- empty input ----------
|
||||
|
||||
const empty = computeBom([]);
|
||||
check('empty input yields empty BOM',
|
||||
empty.partCount === 0 && empty.lines.length === 0 && empty.totalCost === 0);
|
||||
|
||||
// ---------- result ----------
|
||||
|
||||
console.log('');
|
||||
if (failures === 0) {
|
||||
console.log('ALL CHECKS PASSED');
|
||||
} else {
|
||||
console.log(`${failures} CHECK(S) FAILED`);
|
||||
// No @types/node in this project — reach process through globalThis.
|
||||
const proc = (globalThis as { process?: { exitCode?: number } }).process;
|
||||
if (proc) proc.exitCode = 1;
|
||||
}
|
||||
88
src/bom/types.ts
Normal file
88
src/bom/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Bill-of-Materials domain types.
|
||||
*
|
||||
* Units follow the app convention: lengths in the scene are feet, pipe
|
||||
* diameters are inches. Cut-list math is done in INCHES (a standard PVC
|
||||
* stick = 120 in = 10 ft). All money values are USD, rounded to cents.
|
||||
*/
|
||||
|
||||
/** One shopping-list line (a group of identical purchasable items). */
|
||||
export interface BomLine {
|
||||
/** Stable grouping key: part type + SKU-relevant params. */
|
||||
key: string;
|
||||
/** Raw part type ('pipe', 'elbow', ... or an unknown future type). */
|
||||
partType: string;
|
||||
/** Catalog category ('Plumbing', 'Growing', ...) or 'Other' for unknown types. */
|
||||
category: string;
|
||||
/** Human-readable item name, e.g. "1 in PVC Elbow". */
|
||||
description: string;
|
||||
/** Key specs, e.g. "400 GPH, 8 ft max head". */
|
||||
spec?: string;
|
||||
/** How many to buy (for pipe: number of 10-ft sticks). */
|
||||
qty: number;
|
||||
/** Purchase unit. */
|
||||
unit: 'each' | 'stick';
|
||||
/** Price per unit (USD). */
|
||||
unitCost: number;
|
||||
/** qty * unitCost (USD). */
|
||||
totalCost: number;
|
||||
/** True when the price is a blind fallback for an unknown part type. */
|
||||
estimated: boolean;
|
||||
/** Ids of the placed parts folded into this line. */
|
||||
partIds: string[];
|
||||
}
|
||||
|
||||
/** One physical 10-ft stick and the cuts assigned to it. */
|
||||
export interface CutStick {
|
||||
/** 1-based stick number within its cut list. */
|
||||
index: number;
|
||||
/** Cut lengths in inches, in cutting order (longest first). */
|
||||
cuts: number[];
|
||||
/** Total inches consumed. */
|
||||
usedIn: number;
|
||||
/** Scrap remaining on this stick (inches). */
|
||||
leftoverIn: number;
|
||||
}
|
||||
|
||||
/** Cut plan for all pipes of one diameter. */
|
||||
export interface CutList {
|
||||
/** Pipe diameter (inches). */
|
||||
diameterIn: number;
|
||||
/** Stock stick length (inches) — 120 for standard 10-ft sticks. */
|
||||
stickLengthIn: number;
|
||||
/** Every cut to make (inches), after splitting oversize runs. */
|
||||
cuts: number[];
|
||||
/** Bin-packed sticks (first-fit decreasing). */
|
||||
sticks: CutStick[];
|
||||
/** Number of sticks to buy (= sticks.length). */
|
||||
sticksToBuy: number;
|
||||
/** Sum of all cuts (inches). */
|
||||
totalCutIn: number;
|
||||
/** Total scrap across all sticks (inches). */
|
||||
totalLeftoverIn: number;
|
||||
/**
|
||||
* Number of couplings implied by runs longer than one stick (a 12-ft pipe
|
||||
* is supplied as a full stick + a 2-ft piece joined by a coupling).
|
||||
*/
|
||||
spliceCount: number;
|
||||
}
|
||||
|
||||
/** Per-category cost rollup. */
|
||||
export interface CategorySubtotal {
|
||||
name: string;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
/** Complete bill of materials for a design. */
|
||||
export interface Bom {
|
||||
/** Shopping-list lines, sorted by category then description. */
|
||||
lines: BomLine[];
|
||||
/** One cut plan per pipe diameter present in the design. */
|
||||
cutLists: CutList[];
|
||||
/** Cost subtotal per category, in display order. */
|
||||
categories: CategorySubtotal[];
|
||||
/** Grand total (USD). */
|
||||
totalCost: number;
|
||||
/** Number of placed parts that went into this BOM. */
|
||||
partCount: number;
|
||||
}
|
||||
566
src/buildsheets/blueprints.ts
Normal file
566
src/buildsheets/blueprints.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* Blueprint generator — renders an orthographic projection of the placed
|
||||
* parts as a dimensioned, print-friendly SVG sheet (black-on-white with
|
||||
* subtle part-color fills), including a 1-ft grid, overall dimension lines,
|
||||
* pipe length callouts, elevation callouts, numbered part keys, and a
|
||||
* legend table.
|
||||
*/
|
||||
import type { PlacedPart, Vec3 } from '../types';
|
||||
import {
|
||||
assignKeys,
|
||||
escapeXml,
|
||||
fmtFtIn,
|
||||
mergedParams,
|
||||
partColor,
|
||||
partDef,
|
||||
partLabel,
|
||||
partSizeText,
|
||||
rotateVec,
|
||||
safeConnectors,
|
||||
worldAABB,
|
||||
type Box3,
|
||||
} from './common';
|
||||
|
||||
export type BlueprintView = 'top' | 'front' | 'side';
|
||||
|
||||
export interface BlueprintOptions {
|
||||
/** Sheet width in px (default 1100). */
|
||||
width?: number;
|
||||
/** Title shown in the sheet header (default "Hydro Builder"). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface LegendRow {
|
||||
partId: string;
|
||||
ref: string;
|
||||
label: string;
|
||||
type: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface BlueprintSvg {
|
||||
svg: string;
|
||||
width: number;
|
||||
height: number;
|
||||
view: BlueprintView;
|
||||
/** Drawing scale actually used. */
|
||||
pxPerFt: number;
|
||||
/** Projected content extents in feet (width × height of the drawing). */
|
||||
extents: { w: number; h: number };
|
||||
legend: LegendRow[];
|
||||
}
|
||||
|
||||
// Sheet palette — black-on-white for print, blue dimensions, light grid.
|
||||
const INK = '#0f172a';
|
||||
const DIM = '#1d4ed8';
|
||||
const GRID = '#e2e8f0';
|
||||
const GRID5 = '#cbd5e1';
|
||||
const FRAME = '#64748b';
|
||||
const TEXT = '#475569';
|
||||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
|
||||
|
||||
const VIEW_NAME: Record<BlueprintView, string> = {
|
||||
top: 'TOP VIEW (PLAN)',
|
||||
front: 'FRONT ELEVATION',
|
||||
side: 'SIDE ELEVATION',
|
||||
};
|
||||
|
||||
/** Project a world point/vector to sheet coords (u right, v up), in feet. */
|
||||
function proj(view: BlueprintView, p: Vec3): [number, number] {
|
||||
if (view === 'top') return [p[0], -p[2]];
|
||||
if (view === 'front') return [p[0], p[1]];
|
||||
return [p[2], p[1]];
|
||||
}
|
||||
|
||||
interface Rect2 {
|
||||
u0: number;
|
||||
u1: number;
|
||||
v0: number;
|
||||
v1: number;
|
||||
}
|
||||
|
||||
function projBox(view: BlueprintView, b: Box3): Rect2 {
|
||||
const [ua, va] = proj(view, b.min);
|
||||
const [ub, vb] = proj(view, b.max);
|
||||
return {
|
||||
u0: Math.min(ua, ub),
|
||||
u1: Math.max(ua, ub),
|
||||
v0: Math.min(va, vb),
|
||||
v1: Math.max(va, vb),
|
||||
};
|
||||
}
|
||||
|
||||
const isTentType = (t: string) => t.toLowerCase().includes('tent');
|
||||
const VESSEL_TYPES = new Set(['reservoir', 'tray', 'wallPanel', 'lattice', 'tower']);
|
||||
|
||||
const n2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export function generateBlueprint(
|
||||
parts: PlacedPart[],
|
||||
view: BlueprintView,
|
||||
opts: BlueprintOptions = {},
|
||||
): BlueprintSvg {
|
||||
const W = opts.width ?? 1100;
|
||||
const title = opts.title ?? 'Hydro Builder';
|
||||
const keys = assignKeys(parts);
|
||||
const ref = (id: string) => keys.get(id)?.ref ?? '?';
|
||||
|
||||
// ---------------------------------------------------------- sheet layout
|
||||
const margin = 24;
|
||||
const headerH = 54;
|
||||
const dimL = 64; // left gutter for the vertical dimension
|
||||
const dimR = 96; // right gutter for elevation callouts
|
||||
const dimB = 54; // bottom gutter for the horizontal dimension
|
||||
const x0 = margin + dimL;
|
||||
const y0 = headerH + 14;
|
||||
const drawW = W - x0 - margin - dimR;
|
||||
const drawH = 520;
|
||||
|
||||
// ------------------------------------------------- content bounds (feet)
|
||||
let u0c = Infinity, u1c = -Infinity, v0c = Infinity, v1c = -Infinity;
|
||||
const boxes = new Map<string, Rect2>();
|
||||
for (const p of parts) {
|
||||
const r = projBox(view, worldAABB(p));
|
||||
boxes.set(p.id, r);
|
||||
u0c = Math.min(u0c, r.u0);
|
||||
u1c = Math.max(u1c, r.u1);
|
||||
v0c = Math.min(v0c, r.v0);
|
||||
v1c = Math.max(v1c, r.v1);
|
||||
}
|
||||
if (!parts.length) {
|
||||
u0c = 0; u1c = 8; v0c = 0; v1c = 6;
|
||||
}
|
||||
// Ground line at 0 is meaningful in elevations — include it.
|
||||
if (view !== 'top') v0c = Math.min(v0c, 0);
|
||||
|
||||
const pad = 0.6;
|
||||
const bw = u1c - u0c + pad * 2;
|
||||
const bh = v1c - v0c + pad * 2;
|
||||
const s = Math.min(drawW / bw, drawH / bh, 120);
|
||||
const uLeft = u0c - pad - (drawW / s - bw) / 2;
|
||||
const vTop = v1c + pad + (drawH / s - bh) / 2;
|
||||
const X = (u: number) => n2(x0 + (u - uLeft) * s);
|
||||
const Y = (v: number) => n2(y0 + (vTop - v) * s);
|
||||
|
||||
const out: string[] = [];
|
||||
const arrId = `bsarr-${view}`;
|
||||
|
||||
// ------------------------------------------------------------ grid+frame
|
||||
out.push(`<rect x="${x0}" y="${y0}" width="${drawW}" height="${drawH}" fill="#ffffff"/>`);
|
||||
const uG0 = Math.ceil(uLeft);
|
||||
const uG1 = Math.floor(uLeft + drawW / s);
|
||||
for (let u = uG0; u <= uG1; u++) {
|
||||
out.push(
|
||||
`<line x1="${X(u)}" y1="${y0}" x2="${X(u)}" y2="${y0 + drawH}" stroke="${u % 5 === 0 ? GRID5 : GRID}" stroke-width="1"/>`,
|
||||
);
|
||||
}
|
||||
const vG1 = Math.floor(vTop);
|
||||
const vG0 = Math.ceil(vTop - drawH / s);
|
||||
for (let v = vG0; v <= vG1; v++) {
|
||||
out.push(
|
||||
`<line x1="${x0}" y1="${Y(v)}" x2="${x0 + drawW}" y2="${Y(v)}" stroke="${v % 5 === 0 ? GRID5 : GRID}" stroke-width="1"/>`,
|
||||
);
|
||||
}
|
||||
// Floor line in elevations.
|
||||
if (view !== 'top' && 0 >= vTop - drawH / s && 0 <= vTop) {
|
||||
out.push(
|
||||
`<line x1="${x0}" y1="${Y(0)}" x2="${x0 + drawW}" y2="${Y(0)}" stroke="${FRAME}" stroke-width="1.5"/>`,
|
||||
`<text x="${x0 + 4}" y="${Y(0) + 12}" font-size="8" font-family="${MONO}" fill="${TEXT}">FLOOR EL 0'-0"</text>`,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- glyphs
|
||||
const zOrder = (p: PlacedPart) =>
|
||||
VESSEL_TYPES.has(p.type) || isTentType(p.type) ? 0 : p.type === 'pipe' ? 1 : 2;
|
||||
const drawParts = [...parts].sort((a, b) => zOrder(a) - zOrder(b));
|
||||
|
||||
const pipeW = (diaIn: number) => Math.max((diaIn / 12) * s, 3);
|
||||
|
||||
/** Double-line stroke pair (outer ink + inner white) for a path `d`. */
|
||||
const dline = (d: string, w: number) =>
|
||||
`<path d="${d}" fill="none" stroke="${INK}" stroke-width="${n2(w)}"/>` +
|
||||
`<path d="${d}" fill="none" stroke="#ffffff" stroke-width="${n2(Math.max(w - 2.4, 1))}"/>`;
|
||||
|
||||
function pipeGlyph(part: PlacedPart): string {
|
||||
const p = mergedParams(part);
|
||||
const len = p.length ?? 1;
|
||||
const dir = rotateVec(part.rotation, [1, 0, 0]);
|
||||
const [du, dv] = proj(view, dir);
|
||||
const plen = Math.hypot(du, dv) * len;
|
||||
const [cu, cv] = proj(view, part.position);
|
||||
const cx = X(cu);
|
||||
const cy = Y(cv);
|
||||
const w = pipeW(p.diameter ?? 1);
|
||||
const color = partColor(part);
|
||||
if (plen * s < 4) {
|
||||
// Pipe runs perpendicular to the sheet — draw its end view.
|
||||
const r = Math.max(w / 2, 3.5);
|
||||
return (
|
||||
`<circle cx="${cx}" cy="${cy}" r="${n2(r + 1.5)}" fill="${color}" fill-opacity="0.18" stroke="${INK}" stroke-width="1.2"/>` +
|
||||
`<circle cx="${cx}" cy="${cy}" r="${n2(Math.max(r - 1.5, 1.2))}" fill="none" stroke="${INK}" stroke-width="0.8"/>`
|
||||
);
|
||||
}
|
||||
const deg = n2((Math.atan2(-dv, du) * 180) / Math.PI);
|
||||
let textDeg = deg;
|
||||
if (textDeg > 90 || textDeg < -90) textDeg = n2(textDeg + 180);
|
||||
const L = n2(plen * s);
|
||||
let g =
|
||||
`<g transform="translate(${cx},${cy}) rotate(${deg})">` +
|
||||
`<rect x="${n2(-L / 2)}" y="${n2(-w / 2)}" width="${L}" height="${n2(w)}" fill="${color}" fill-opacity="0.2" stroke="${INK}" stroke-width="1.2"/>` +
|
||||
`</g>`;
|
||||
// Length callout for pipes >= 1 ft that read at this scale.
|
||||
if (len >= 1 && L >= 34) {
|
||||
g +=
|
||||
`<g transform="translate(${cx},${cy}) rotate(${textDeg})">` +
|
||||
`<text x="0" y="${n2(-w / 2 - 5)}" text-anchor="middle" font-size="10" font-family="${MONO}" fill="${DIM}">${fmtFtIn(len)}</text>` +
|
||||
`</g>`;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function elbowGlyph(part: PlacedPart): string {
|
||||
const p = mergedParams(part);
|
||||
const w = Math.max(pipeW(p.diameter ?? 1), 4);
|
||||
const conns = safeConnectors(part);
|
||||
const [cu, cv] = proj(view, part.position);
|
||||
const cx = X(cu);
|
||||
const cy = Y(cv);
|
||||
if (conns.length < 2) return `<circle cx="${cx}" cy="${cy}" r="4" fill="none" stroke="${INK}" stroke-width="1.2"/>`;
|
||||
const [ua, va] = proj(view, conns[0].pos);
|
||||
const [ub, vb] = proj(view, conns[1].pos);
|
||||
const ax = X(ua), ay = Y(va), bx = X(ub), by = Y(vb);
|
||||
if (Math.hypot(bx - ax, by - ay) < 3) {
|
||||
const r = Math.max(w / 2, 3.5);
|
||||
return `<circle cx="${cx}" cy="${cy}" r="${n2(r + 1.5)}" fill="none" stroke="${INK}" stroke-width="1.2"/>`;
|
||||
}
|
||||
return dline(`M ${ax} ${ay} Q ${cx} ${cy} ${bx} ${by}`, w + 1.5);
|
||||
}
|
||||
|
||||
/** Junction glyph (tee/cross/wye/…): double lines from center to each port. */
|
||||
function junctionGlyph(part: PlacedPart): string {
|
||||
const p = mergedParams(part);
|
||||
const w = Math.max(pipeW(p.diameter ?? 1), 4);
|
||||
const [cu, cv] = proj(view, part.position);
|
||||
const cx = X(cu);
|
||||
const cy = Y(cv);
|
||||
const legs = safeConnectors(part)
|
||||
.map((c) => {
|
||||
const [u, v] = proj(view, c.pos);
|
||||
return [X(u), Y(v)] as [number, number];
|
||||
})
|
||||
.filter(([lx, ly]) => Math.hypot(lx - cx, ly - cy) >= 2);
|
||||
if (!legs.length)
|
||||
return `<circle cx="${cx}" cy="${cy}" r="${n2(Math.max(w / 2, 3.5))}" fill="none" stroke="${INK}" stroke-width="1.2"/>`;
|
||||
const outer = legs
|
||||
.map(([lx, ly]) => `<line x1="${cx}" y1="${cy}" x2="${lx}" y2="${ly}" stroke="${INK}" stroke-width="${n2(w + 1.5)}"/>`)
|
||||
.join('');
|
||||
const inner = legs
|
||||
.map(([lx, ly]) => `<line x1="${cx}" y1="${cy}" x2="${lx}" y2="${ly}" stroke="#ffffff" stroke-width="${n2(Math.max(w - 1, 1.5))}"/>`)
|
||||
.join('');
|
||||
return outer + inner;
|
||||
}
|
||||
|
||||
function pumpGlyph(part: PlacedPart): string {
|
||||
const [cu, cv] = proj(view, part.position);
|
||||
const cx = X(cu);
|
||||
const cy = Y(cv) - (view === 'top' ? 0 : 0.25 * s);
|
||||
const r = Math.max(0.3 * s, 9);
|
||||
const outC = safeConnectors(part).find((c) => c.connectorId === 'out');
|
||||
let ang = 0;
|
||||
if (outC) {
|
||||
const [du, dv] = proj(view, outC.dir);
|
||||
if (Math.hypot(du, dv) > 0.01) ang = Math.atan2(-dv, du);
|
||||
}
|
||||
const tri = [0.85, (2 * Math.PI) / 3 + 0.6, -(2 * Math.PI) / 3 - 0.6]
|
||||
.map((a, i) => {
|
||||
const rr = i === 0 ? r * 0.78 : r * 0.62;
|
||||
const aa = i === 0 ? ang : ang + (i === 1 ? 2.5 : -2.5);
|
||||
return `${n2(cx + Math.cos(aa) * rr)},${n2(cy + Math.sin(aa) * rr)}`;
|
||||
})
|
||||
.join(' ');
|
||||
return (
|
||||
`<circle cx="${cx}" cy="${n2(cy)}" r="${n2(r)}" fill="${partColor(part)}" fill-opacity="0.15" stroke="${INK}" stroke-width="1.5"/>` +
|
||||
`<polygon points="${tri}" fill="${INK}"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function valveGlyph(part: PlacedPart): string {
|
||||
const [cu, cv] = proj(view, part.position);
|
||||
const cx = X(cu);
|
||||
const cy = Y(cv);
|
||||
const conns = safeConnectors(part);
|
||||
let deg = 0;
|
||||
if (conns.length >= 2) {
|
||||
const [ua, va] = proj(view, conns[0].pos);
|
||||
const [ub, vb] = proj(view, conns[1].pos);
|
||||
if (Math.hypot(ub - ua, vb - va) > 0.01) deg = (Math.atan2(-(vb - va), ub - ua) * 180) / Math.PI;
|
||||
}
|
||||
const h = Math.max(0.32 * s, 9);
|
||||
const wg = Math.max(0.22 * s, 6);
|
||||
return (
|
||||
`<g transform="translate(${cx},${cy}) rotate(${n2(deg)})">` +
|
||||
`<path d="M ${n2(-h)} ${n2(-wg)} L 0 0 L ${n2(-h)} ${n2(wg)} Z M ${n2(h)} ${n2(-wg)} L 0 0 L ${n2(h)} ${n2(wg)} Z" fill="${partColor(part)}" fill-opacity="0.3" stroke="${INK}" stroke-width="1.2"/>` +
|
||||
`</g>`
|
||||
);
|
||||
}
|
||||
|
||||
function drainGlyph(part: PlacedPart): string {
|
||||
const [cu, cv] = proj(view, part.position);
|
||||
const cx = X(cu);
|
||||
const cy = Y(cv);
|
||||
const r = Math.max(0.22 * s, 7);
|
||||
const k = n2(r * 0.65);
|
||||
return (
|
||||
`<circle cx="${cx}" cy="${cy}" r="${n2(r)}" fill="none" stroke="${INK}" stroke-width="1.4"/>` +
|
||||
`<line x1="${n2(cx - k)}" y1="${n2(cy - k)}" x2="${n2(cx + k)}" y2="${n2(cy + k)}" stroke="${INK}" stroke-width="1.2"/>` +
|
||||
`<line x1="${n2(cx - k)}" y1="${n2(cy + k)}" x2="${n2(cx + k)}" y2="${n2(cy - k)}" stroke="${INK}" stroke-width="1.2"/>`
|
||||
);
|
||||
}
|
||||
|
||||
function dotGlyph(part: PlacedPart): string {
|
||||
const [cu, cv] = proj(view, part.position);
|
||||
const cx = X(cu);
|
||||
const cy = Y(cv);
|
||||
const r = Math.max(0.12 * s, 4);
|
||||
return (
|
||||
`<circle cx="${cx}" cy="${cy}" r="${n2(r)}" fill="${partColor(part)}" fill-opacity="0.3" stroke="${INK}" stroke-width="1.1"/>` +
|
||||
(part.type === 'emitter' ? `<circle cx="${cx}" cy="${cy}" r="1.4" fill="${INK}"/>` : '')
|
||||
);
|
||||
}
|
||||
|
||||
function boxGlyph(part: PlacedPart): string {
|
||||
const b = boxes.get(part.id)!;
|
||||
const rx = X(b.u0);
|
||||
const ry = Y(b.v1);
|
||||
const rw = n2(Math.max(X(b.u1) - rx, 3));
|
||||
const rh = n2(Math.max(Y(b.v0) - ry, 3));
|
||||
const unknown = !partDef(part.type);
|
||||
const tent = isTentType(part.type);
|
||||
const dashed = tent || unknown;
|
||||
const color = partColor(part);
|
||||
let g =
|
||||
`<rect x="${rx}" y="${ry}" width="${rw}" height="${rh}" ` +
|
||||
`fill="${tent ? 'none' : color}" fill-opacity="0.12" stroke="${INK}" stroke-width="1.4"` +
|
||||
`${dashed ? ' stroke-dasharray="6 4"' : ''}/>`;
|
||||
const p = mergedParams(part);
|
||||
if (part.type === 'tower') {
|
||||
if (view === 'top') {
|
||||
g += `<circle cx="${n2(rx + rw / 2)}" cy="${n2(ry + rh / 2)}" r="${n2(Math.min(rw, rh) * 0.32)}" fill="none" stroke="${INK}" stroke-width="1"/>`;
|
||||
} else {
|
||||
// Tick marks at plant-site spacing.
|
||||
const sites = Math.max(1, Math.round(p.sites ?? 6));
|
||||
for (let i = 1; i < sites; i++) {
|
||||
const ty = n2(ry + (rh * i) / sites);
|
||||
g += `<line x1="${rx}" y1="${ty}" x2="${n2(rx + rw * 0.45)}" y2="${ty}" stroke="${INK}" stroke-width="0.8"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (part.type === 'reservoir' && view !== 'top') {
|
||||
const wl = Y(part.position[1] + (p.height ?? 1) * ((p.level ?? 0) / 100));
|
||||
g += `<line x1="${rx}" y1="${n2(wl)}" x2="${n2(rx + rw)}" y2="${n2(wl)}" stroke="#0284c7" stroke-width="1" stroke-dasharray="4 3"/>`;
|
||||
}
|
||||
if (part.type === 'lattice') {
|
||||
g +=
|
||||
`<line x1="${rx}" y1="${ry}" x2="${n2(rx + rw)}" y2="${n2(ry + rh)}" stroke="${INK}" stroke-width="0.7"/>` +
|
||||
`<line x1="${rx}" y1="${n2(ry + rh)}" x2="${n2(rx + rw)}" y2="${ry}" stroke="${INK}" stroke-width="0.7"/>`;
|
||||
}
|
||||
if (unknown && rw > 44 && rh > 14) {
|
||||
g += `<text x="${n2(rx + rw / 2)}" y="${n2(ry + rh / 2 + 3)}" text-anchor="middle" font-size="9" fill="${TEXT}">${escapeXml(partLabel(part))}</text>`;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function glyphBody(part: PlacedPart): string {
|
||||
switch (part.type as string) {
|
||||
case 'pipe':
|
||||
return pipeGlyph(part);
|
||||
case 'elbow':
|
||||
return elbowGlyph(part);
|
||||
case 'tee':
|
||||
return junctionGlyph(part);
|
||||
case 'pump':
|
||||
return pumpGlyph(part);
|
||||
case 'valve':
|
||||
return valveGlyph(part);
|
||||
case 'drain':
|
||||
return drainGlyph(part);
|
||||
case 'emitter':
|
||||
case 'netPot':
|
||||
return dotGlyph(part);
|
||||
case 'reservoir':
|
||||
case 'tray':
|
||||
case 'wallPanel':
|
||||
case 'tower':
|
||||
case 'lattice':
|
||||
return boxGlyph(part);
|
||||
default: {
|
||||
// Catalogued-but-unrecognized compact fittings (wye, cross, reducer,
|
||||
// …) read best as junction symbols; everything else gets its AABB.
|
||||
const def = partDef(part.type);
|
||||
const b = boxes.get(part.id)!;
|
||||
const span = Math.max(b.u1 - b.u0, b.v1 - b.v0);
|
||||
if (def && safeConnectors(part).length >= 2 && span < 2.2) return junctionGlyph(part);
|
||||
return boxGlyph(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const part of drawParts) {
|
||||
out.push(
|
||||
`<g class="bs-part" data-part-id="${escapeXml(part.id)}" data-ref="${ref(part.id)}">${glyphBody(part)}</g>`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- key bubbles
|
||||
const placed: { x: number; y: number }[] = [];
|
||||
for (const part of drawParts) {
|
||||
const b = boxes.get(part.id)!;
|
||||
const r = ref(part.id);
|
||||
const bw2 = r.length * 6 + 8;
|
||||
let bx = X(b.u1) + 8 + bw2 / 2;
|
||||
let by = Y(b.v1) - 6;
|
||||
bx = Math.min(Math.max(bx, x0 + bw2 / 2 + 2), x0 + drawW - bw2 / 2 - 2);
|
||||
by = Math.min(Math.max(by, y0 + 10), y0 + drawH - 10);
|
||||
let guard = 0;
|
||||
while (placed.some((q) => Math.abs(q.x - bx) < 26 && Math.abs(q.y - by) < 17) && guard++ < 40) {
|
||||
by += 18;
|
||||
if (by > y0 + drawH - 10) {
|
||||
by = y0 + 10;
|
||||
bx += 28;
|
||||
}
|
||||
}
|
||||
placed.push({ x: bx, y: by });
|
||||
out.push(
|
||||
`<g class="bs-key" data-part-id="${escapeXml(part.id)}">` +
|
||||
`<rect x="${n2(bx - bw2 / 2)}" y="${n2(by - 8)}" width="${bw2}" height="16" rx="8" fill="#ffffff" stroke="${DIM}" stroke-width="1.2"/>` +
|
||||
`<text x="${n2(bx)}" y="${n2(by + 3)}" text-anchor="middle" font-size="8.5" font-weight="700" font-family="${MONO}" fill="${DIM}">${r}</text>` +
|
||||
`</g>`,
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- dimension lines
|
||||
if (parts.length) {
|
||||
const exW = u1c - u0c;
|
||||
const exH = v1c - v0c;
|
||||
const dimY = y0 + drawH + 24;
|
||||
const dimX = x0 - 24;
|
||||
// Horizontal overall dimension.
|
||||
out.push(
|
||||
`<line x1="${X(u0c)}" y1="${Y(v0c) + 4}" x2="${X(u0c)}" y2="${dimY + 5}" stroke="${DIM}" stroke-width="0.8"/>`,
|
||||
`<line x1="${X(u1c)}" y1="${Y(v0c) + 4}" x2="${X(u1c)}" y2="${dimY + 5}" stroke="${DIM}" stroke-width="0.8"/>`,
|
||||
`<line x1="${X(u0c)}" y1="${dimY}" x2="${X(u1c)}" y2="${dimY}" stroke="${DIM}" stroke-width="1" marker-start="url(#${arrId})" marker-end="url(#${arrId})"/>`,
|
||||
`<text x="${n2((X(u0c) + X(u1c)) / 2)}" y="${dimY - 5}" text-anchor="middle" font-size="11" font-family="${MONO}" fill="${DIM}">${fmtFtIn(exW)}</text>`,
|
||||
);
|
||||
// Vertical overall dimension.
|
||||
out.push(
|
||||
`<line x1="${X(u0c) - 4}" y1="${Y(v0c)}" x2="${dimX - 5}" y2="${Y(v0c)}" stroke="${DIM}" stroke-width="0.8"/>`,
|
||||
`<line x1="${X(u0c) - 4}" y1="${Y(v1c)}" x2="${dimX - 5}" y2="${Y(v1c)}" stroke="${DIM}" stroke-width="0.8"/>`,
|
||||
`<line x1="${dimX}" y1="${Y(v0c)}" x2="${dimX}" y2="${Y(v1c)}" stroke="${DIM}" stroke-width="1" marker-start="url(#${arrId})" marker-end="url(#${arrId})"/>`,
|
||||
`<text x="${dimX - 6}" y="${n2((Y(v0c) + Y(v1c)) / 2)}" text-anchor="middle" font-size="11" font-family="${MONO}" fill="${DIM}" transform="rotate(-90 ${dimX - 6} ${n2((Y(v0c) + Y(v1c)) / 2)})">${fmtFtIn(exH)}</text>`,
|
||||
);
|
||||
|
||||
// Elevation callouts (front/side): reservoir rims, tower tops, highest pipe.
|
||||
if (view !== 'top') {
|
||||
const callouts: { y: number; label: string }[] = [];
|
||||
let highPipe = -Infinity;
|
||||
for (const p of parts) {
|
||||
const a = worldAABB(p);
|
||||
if (p.type === 'reservoir') callouts.push({ y: a.max[1], label: `${ref(p.id)} RIM` });
|
||||
if (p.type === 'tower') callouts.push({ y: a.max[1], label: `${ref(p.id)} TOP` });
|
||||
if (p.type === 'pipe') highPipe = Math.max(highPipe, a.max[1]);
|
||||
}
|
||||
if (highPipe > 0.5) callouts.push({ y: highPipe, label: 'HIGH PIPE' });
|
||||
callouts.sort((a, b) => b.y - a.y);
|
||||
let lastTy = -Infinity;
|
||||
for (const c of callouts) {
|
||||
const ly = Y(c.y);
|
||||
let ty = ly + 3;
|
||||
if (ty - lastTy < 11) ty = lastTy + 11;
|
||||
lastTy = ty;
|
||||
out.push(
|
||||
`<line x1="${x0}" y1="${n2(ly)}" x2="${x0 + drawW + 6}" y2="${n2(ly)}" stroke="${DIM}" stroke-width="0.7" stroke-dasharray="7 4" opacity="0.65"/>`,
|
||||
`<text x="${x0 + drawW + 10}" y="${n2(ty)}" font-size="8" font-family="${MONO}" fill="${DIM}">${escapeXml(c.label)} ${fmtFtIn(c.y)}</text>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawing frame on top of the grid.
|
||||
out.push(`<rect x="${x0}" y="${y0}" width="${drawW}" height="${drawH}" fill="none" stroke="${FRAME}" stroke-width="1.2"/>`);
|
||||
|
||||
if (!parts.length) {
|
||||
out.push(
|
||||
`<text x="${x0 + drawW / 2}" y="${y0 + drawH / 2}" text-anchor="middle" font-size="14" fill="${TEXT}">NO PARTS PLACED</text>`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- legend
|
||||
const legend: LegendRow[] = [...parts]
|
||||
.sort((a, b) => (keys.get(a.id)?.num ?? 0) - (keys.get(b.id)?.num ?? 0))
|
||||
.map((p) => ({
|
||||
partId: p.id,
|
||||
ref: ref(p.id),
|
||||
label: partLabel(p),
|
||||
type: p.type,
|
||||
size: partSizeText(p),
|
||||
}));
|
||||
|
||||
const rowH = 19;
|
||||
const cols = legend.length > 12 ? 2 : 1;
|
||||
const rowsPerCol = Math.max(1, Math.ceil(legend.length / cols));
|
||||
const yLeg = y0 + drawH + dimB;
|
||||
const legendH = legend.length ? 26 + rowsPerCol * rowH : 18;
|
||||
const H = yLeg + legendH + margin;
|
||||
const colW = (W - margin * 2) / cols;
|
||||
|
||||
out.push(
|
||||
`<line x1="${margin}" y1="${yLeg}" x2="${W - margin}" y2="${yLeg}" stroke="${FRAME}" stroke-width="0.8"/>`,
|
||||
`<text x="${margin}" y="${yLeg + 16}" font-size="10" font-weight="700" letter-spacing="1.5" fill="${INK}">PARTS KEY</text>`,
|
||||
);
|
||||
legend.forEach((row, i) => {
|
||||
const col = Math.floor(i / rowsPerCol);
|
||||
const rx = margin + col * colW;
|
||||
const ry = yLeg + 26 + (i - col * rowsPerCol) * rowH + 6;
|
||||
const bw2 = row.ref.length * 6 + 8;
|
||||
out.push(
|
||||
`<g class="bs-legend-row" data-part-id="${escapeXml(row.partId)}">` +
|
||||
`<rect x="${rx}" y="${ry - 4}" width="${bw2}" height="14" rx="7" fill="#ffffff" stroke="${DIM}" stroke-width="1"/>` +
|
||||
`<text x="${n2(rx + bw2 / 2)}" y="${ry + 6}" text-anchor="middle" font-size="8" font-weight="700" font-family="${MONO}" fill="${DIM}">${row.ref}</text>` +
|
||||
`<text x="${rx + 42}" y="${ry + 7}" font-size="9.5" font-weight="600" fill="${INK}">${escapeXml(row.label.slice(0, 26))}</text>` +
|
||||
`<text x="${rx + 200}" y="${ry + 7}" font-size="8.5" fill="${TEXT}">${escapeXml(row.type)}</text>` +
|
||||
`<text x="${rx + 300}" y="${ry + 7}" font-size="8.5" font-family="${MONO}" fill="${TEXT}">${escapeXml(row.size)}</text>` +
|
||||
`</g>`,
|
||||
);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- header
|
||||
const inPerFt = s / 96; // assuming 96 px/in for the printed-scale note
|
||||
const scaleNote = `SCALE ${inPerFt >= 0.995 ? inPerFt.toFixed(1) : inPerFt.toFixed(2)} in = 1 ft · GRID 1 ft`;
|
||||
const header =
|
||||
`<text x="${margin}" y="${margin + 8}" font-size="16" font-weight="800" fill="${INK}">${escapeXml(title)}</text>` +
|
||||
`<text x="${margin}" y="${margin + 24}" font-size="9" letter-spacing="1" fill="${TEXT}">HYDRO BUILDER · BUILD SHEET · ${parts.length} PARTS</text>` +
|
||||
`<text x="${W - margin}" y="${margin + 8}" text-anchor="end" font-size="13" font-weight="700" fill="${INK}">${VIEW_NAME[view]}</text>` +
|
||||
`<text x="${W - margin}" y="${margin + 24}" text-anchor="end" font-size="9" font-family="${MONO}" fill="${TEXT}">${scaleNote}</text>` +
|
||||
`<line x1="${margin}" y1="${headerH}" x2="${W - margin}" y2="${headerH}" stroke="${FRAME}" stroke-width="1"/>`;
|
||||
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" ` +
|
||||
`font-family="ui-sans-serif, system-ui, sans-serif">` +
|
||||
`<defs><marker id="${arrId}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
|
||||
`<path d="M0,0 L10,5 L0,10 z" fill="${DIM}"/></marker></defs>` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" fill="#ffffff"/>` +
|
||||
`<rect x="6" y="6" width="${W - 12}" height="${H - 12}" fill="none" stroke="${FRAME}" stroke-width="1.5"/>` +
|
||||
header +
|
||||
out.join('') +
|
||||
`</svg>`;
|
||||
|
||||
return {
|
||||
svg,
|
||||
width: W,
|
||||
height: H,
|
||||
view,
|
||||
pxPerFt: s,
|
||||
extents: { w: parts.length ? u1c - u0c : 0, h: parts.length ? v1c - v0c : 0 },
|
||||
legend,
|
||||
};
|
||||
}
|
||||
301
src/buildsheets/common.ts
Normal file
301
src/buildsheets/common.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Shared helpers for the Build Sheets feature (blueprint drawings +
|
||||
* assembly instructions).
|
||||
*
|
||||
* Self-contained: depends only on src/types.ts, the part catalog, and the
|
||||
* connector utils. Unknown part types (added to the catalog by other code
|
||||
* at runtime, or entirely uncatalogued) are handled generically and never
|
||||
* throw.
|
||||
*/
|
||||
import type { PartDefinition, PlacedPart, Vec3, WorldConnector } from '../types';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
import { CONNECT_DIST, dist3, getWorldConnectors } from '../utils/connectors';
|
||||
|
||||
/** Catalog lookup that tolerates part types unknown at compile time. */
|
||||
const DEFS = PART_DEFS as Record<string, PartDefinition | undefined>;
|
||||
|
||||
export function partDef(type: string): PartDefinition | undefined {
|
||||
return DEFS[type];
|
||||
}
|
||||
|
||||
export function partLabel(p: PlacedPart): string {
|
||||
return p.label || partDef(p.type)?.label || p.type;
|
||||
}
|
||||
|
||||
export function partColor(p: PlacedPart): string {
|
||||
return p.color || partDef(p.type)?.color || '#94a3b8';
|
||||
}
|
||||
|
||||
/** Part params backed by catalog defaults (when the type is catalogued). */
|
||||
export function mergedParams(p: PlacedPart): Record<string, number> {
|
||||
return { ...(partDef(p.type)?.defaults ?? {}), ...p.params };
|
||||
}
|
||||
|
||||
/** World-space connectors; uncatalogued types yield [] instead of throwing. */
|
||||
export function safeConnectors(p: PlacedPart): WorldConnector[] {
|
||||
if (!partDef(p.type)) return [];
|
||||
try {
|
||||
return getWorldConnectors(p);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- geometry
|
||||
|
||||
const snapAxis = (v: number) => {
|
||||
if (Math.abs(v) < 1e-9) return 0;
|
||||
const r = Math.round(v);
|
||||
return Math.abs(v - r) < 1e-9 ? r : v;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rotate a vector by an XYZ-order euler — matches three.js Euler 'XYZ'
|
||||
* (R = Rx·Ry·Rz applied to a column vector: Rz first, then Ry, then Rx).
|
||||
* Components are snapped so 90°-multiple rotations stay exactly axis-aligned.
|
||||
*/
|
||||
export function rotateVec(rot: Vec3, v: Vec3): Vec3 {
|
||||
let [x, y, z] = v;
|
||||
const [rx, ry, rz] = rot;
|
||||
let c = Math.cos(rz);
|
||||
let s = Math.sin(rz);
|
||||
[x, y] = [x * c - y * s, x * s + y * c];
|
||||
c = Math.cos(ry);
|
||||
s = Math.sin(ry);
|
||||
[x, z] = [x * c + z * s, -x * s + z * c];
|
||||
c = Math.cos(rx);
|
||||
s = Math.sin(rx);
|
||||
[y, z] = [y * c - z * s, y * s + z * c];
|
||||
return [snapAxis(x), snapAxis(y), snapAxis(z)];
|
||||
}
|
||||
|
||||
export interface Box3 {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
|
||||
const box = (
|
||||
x0: number, y0: number, z0: number,
|
||||
x1: number, y1: number, z1: number,
|
||||
): Box3 => ({ min: [x0, y0, z0], max: [x1, y1, z1] });
|
||||
|
||||
/** Bend radius used by elbows (mirrors catalog.ts ELBOW_BEND_RADIUS). */
|
||||
export const BEND_RADIUS = 0.5;
|
||||
|
||||
/**
|
||||
* Local-space bounding box of a part (ft), derived from its params.
|
||||
* Known types get tuned footprints; unknown types get a generic box from
|
||||
* common params (width/depth/height/length/size) expanded to reach all of
|
||||
* the part's local connectors.
|
||||
*/
|
||||
export function localBox(part: PlacedPart): Box3 {
|
||||
const p = mergedParams(part);
|
||||
const dia = (p.diameter ?? 1) / 12;
|
||||
const r = Math.max(dia / 2, 0.04);
|
||||
switch (part.type as string) {
|
||||
case 'pipe':
|
||||
return box(-(p.length ?? 1) / 2, -r, -r, (p.length ?? 1) / 2, r, r);
|
||||
case 'elbow':
|
||||
return box(-r, -r, -r, BEND_RADIUS + r, BEND_RADIUS + r, r);
|
||||
case 'tee':
|
||||
return box(-0.6, -r, -r, 0.6, r, 0.6);
|
||||
case 'valve':
|
||||
return box(-0.35, -0.18, -0.18, 0.35, 0.18, 0.18);
|
||||
case 'pump':
|
||||
return box(-0.45, 0, -0.25, 0.45, 0.5, 0.25);
|
||||
case 'reservoir':
|
||||
return box(-(p.width ?? 2) / 2, 0, -(p.depth ?? 1.4) / 2, (p.width ?? 2) / 2, p.height ?? 1, (p.depth ?? 1.4) / 2);
|
||||
case 'tower':
|
||||
return box(-0.3, 0, -0.3, 0.3, p.height ?? 4, 0.3);
|
||||
case 'tray':
|
||||
return box(-(p.length ?? 3) / 2, 0, -(p.width ?? 1.2) / 2, (p.length ?? 3) / 2, 0.25, (p.width ?? 1.2) / 2);
|
||||
case 'wallPanel':
|
||||
return box(-(p.width ?? 3) / 2, 0, -0.15, (p.width ?? 3) / 2, p.height ?? 4, 0.15);
|
||||
case 'lattice':
|
||||
return box(-(p.width ?? 2.5) / 2, 0, -0.08, (p.width ?? 2.5) / 2, p.height ?? 3, 0.08);
|
||||
case 'netPot': {
|
||||
const s = (p.size ?? 1) * 0.22;
|
||||
return box(-s, 0, -s, s, s * 1.2, s);
|
||||
}
|
||||
case 'emitter':
|
||||
return box(-0.15, -0.15, -0.15, 0.2, 0.15, 0.15);
|
||||
case 'drain':
|
||||
return box(-0.35, 0, -0.3, 0.3, 0.3, 0.3);
|
||||
default: {
|
||||
const w = p.width ?? p.length ?? p.size ?? 1;
|
||||
const d = p.depth ?? p.width ?? p.size ?? 1;
|
||||
const h = p.height ?? p.size ?? 1;
|
||||
const b = box(-w / 2, 0, -d / 2, w / 2, h, d / 2);
|
||||
// Make sure the box reaches every local connector.
|
||||
try {
|
||||
for (const c of partDef(part.type)?.getConnectors(p) ?? []) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
b.min[i] = Math.min(b.min[i], c.pos[i]);
|
||||
b.max[i] = Math.max(b.max[i], c.pos[i]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* tolerate misbehaving catalog entries */
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** World-space AABB: rotate the 8 local-box corners, then translate. */
|
||||
export function worldAABB(part: PlacedPart): Box3 {
|
||||
const lb = localBox(part);
|
||||
const min: Vec3 = [Infinity, Infinity, Infinity];
|
||||
const max: Vec3 = [-Infinity, -Infinity, -Infinity];
|
||||
for (const cx of [lb.min[0], lb.max[0]])
|
||||
for (const cy of [lb.min[1], lb.max[1]])
|
||||
for (const cz of [lb.min[2], lb.max[2]]) {
|
||||
const w = rotateVec(part.rotation, [cx, cy, cz]);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const v = w[i] + part.position[i];
|
||||
if (v < min[i]) min[i] = v;
|
||||
if (v > max[i]) max[i] = v;
|
||||
}
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- formatting
|
||||
|
||||
const trimNum = (n: number) =>
|
||||
Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100);
|
||||
|
||||
/** Feet+inches text, e.g. 6.5 → 6'-6", 0.75 → 9". Rounds to 1/4 inch. */
|
||||
export function fmtFtIn(ftVal: number): string {
|
||||
const sign = ftVal < -1e-9 ? '-' : '';
|
||||
const quarters = Math.round(Math.abs(ftVal) * 48);
|
||||
const feet = Math.floor(quarters / 48);
|
||||
const inches = (quarters - feet * 48) / 4;
|
||||
if (feet === 0) return `${sign}${trimNum(inches)}"`;
|
||||
return `${sign}${feet}'-${trimNum(inches)}"`;
|
||||
}
|
||||
|
||||
/** Inches-only text, e.g. 2 (ft) → "24 in". Rounds to 1/4 inch. */
|
||||
export function fmtInches(ftVal: number): string {
|
||||
return `${trimNum(Math.round(ftVal * 48) / 4)} in`;
|
||||
}
|
||||
|
||||
export function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- keys
|
||||
|
||||
export interface PartKey {
|
||||
/** Short blueprint reference, e.g. "P3", "R1". */
|
||||
ref: string;
|
||||
/** Global 1-based legend index. */
|
||||
num: number;
|
||||
}
|
||||
|
||||
const REF_PREFIX: Record<string, string> = {
|
||||
pipe: 'P',
|
||||
elbow: 'E',
|
||||
tee: 'J',
|
||||
wye: 'WY',
|
||||
cross: 'X',
|
||||
reducer: 'RD',
|
||||
valve: 'V',
|
||||
pump: 'PM',
|
||||
reservoir: 'R',
|
||||
tower: 'T',
|
||||
tray: 'TY',
|
||||
wallPanel: 'W',
|
||||
lattice: 'L',
|
||||
netPot: 'N',
|
||||
emitter: 'EM',
|
||||
drain: 'D',
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministic key assignment: parts sorted by type, then position, then
|
||||
* id; numbered per type prefix (P1, P2, ... / R1 / T1 ...).
|
||||
*/
|
||||
export function assignKeys(parts: PlacedPart[]): Map<string, PartKey> {
|
||||
const sorted = [...parts].sort(
|
||||
(a, b) =>
|
||||
a.type.localeCompare(b.type) ||
|
||||
a.position[0] - b.position[0] ||
|
||||
a.position[2] - b.position[2] ||
|
||||
a.position[1] - b.position[1] ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const counters: Record<string, number> = {};
|
||||
const map = new Map<string, PartKey>();
|
||||
sorted.forEach((p, i) => {
|
||||
const prefix = REF_PREFIX[p.type] ?? p.type.slice(0, 2).toUpperCase();
|
||||
counters[prefix] = (counters[prefix] ?? 0) + 1;
|
||||
map.set(p.id, { ref: `${prefix}${counters[prefix]}`, num: i + 1 });
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Human size string for the legend / cut lists. */
|
||||
export function partSizeText(part: PlacedPart): string {
|
||||
const p = mergedParams(part);
|
||||
switch (part.type as string) {
|
||||
case 'pipe':
|
||||
return `${fmtFtIn(p.length ?? 0)} × ${p.diameter ?? 1}" dia`;
|
||||
case 'elbow':
|
||||
return `${p.angle ?? 90}° × ${p.diameter ?? 1}"`;
|
||||
case 'tee':
|
||||
case 'wye':
|
||||
case 'cross':
|
||||
return `${p.diameter ?? 1}"`;
|
||||
case 'valve':
|
||||
return `${p.diameter ?? 1}" · ${p.open ?? 100}% open`;
|
||||
case 'pump':
|
||||
return `${p.gph ?? 0} GPH / ${p.maxHeadFt ?? 0} ft head`;
|
||||
case 'reservoir':
|
||||
return `${fmtFtIn(p.width ?? 0)} × ${fmtFtIn(p.depth ?? 0)} × ${fmtFtIn(p.height ?? 0)}`;
|
||||
case 'tower':
|
||||
return `${fmtFtIn(p.height ?? 0)} tall · ${p.sites ?? 0} sites`;
|
||||
case 'tray':
|
||||
return `${fmtFtIn(p.length ?? 0)} × ${fmtFtIn(p.width ?? 0)}`;
|
||||
case 'wallPanel':
|
||||
case 'lattice':
|
||||
return `${fmtFtIn(p.width ?? 0)} × ${fmtFtIn(p.height ?? 0)}`;
|
||||
case 'drain':
|
||||
return `${p.capacityGph ?? 0} GPH cap`;
|
||||
default: {
|
||||
const dims: string[] = [];
|
||||
if (p.length != null) dims.push(fmtFtIn(p.length));
|
||||
if (p.width != null) dims.push(fmtFtIn(p.width));
|
||||
if (p.depth != null) dims.push(fmtFtIn(p.depth));
|
||||
if (p.height != null) dims.push(fmtFtIn(p.height));
|
||||
if (p.diameter != null) dims.push(`${p.diameter}" dia`);
|
||||
return dims.length ? dims.join(' × ') : '—';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- connections
|
||||
|
||||
export interface Connection {
|
||||
a: WorldConnector;
|
||||
b: WorldConnector;
|
||||
}
|
||||
|
||||
/**
|
||||
* All joined connector pairs: connectors of different parts within
|
||||
* CONNECT_DIST of each other. Each unordered pair appears exactly once.
|
||||
*/
|
||||
export function findConnections(parts: PlacedPart[]): Connection[] {
|
||||
const all = parts.flatMap(safeConnectors);
|
||||
const out: Connection[] = [];
|
||||
for (let i = 0; i < all.length; i++)
|
||||
for (let j = i + 1; j < all.length; j++) {
|
||||
if (all[i].partId === all[j].partId) continue;
|
||||
if (dist3(all[i].pos, all[j].pos) < CONNECT_DIST) out.push({ a: all[i], b: all[j] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
110
src/components/ColorPopover.tsx
Normal file
110
src/components/ColorPopover.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
|
||||
/** Curated palette offered for part recoloring. */
|
||||
export const COLOR_PRESETS = [
|
||||
'#b8bfc9', // steel gray
|
||||
'#64748b', // slate
|
||||
'#38bdf8', // sky
|
||||
'#3d7bfa', // blue
|
||||
'#34d399', // emerald
|
||||
'#3b7a57', // leaf green
|
||||
'#fbbf24', // amber
|
||||
'#f97316', // orange
|
||||
'#e0564f', // red
|
||||
'#a78bfa', // violet
|
||||
];
|
||||
|
||||
/**
|
||||
* Swatch row shared by the floating popover and the inspector: presets,
|
||||
* a native color picker, and a reset-to-catalog-color action.
|
||||
*/
|
||||
export function ColorSwatchRow({ ids }: { ids: string[] }) {
|
||||
const applyColor = useBuilder((s) => s.applyColor);
|
||||
const current = useBuilder((s) => {
|
||||
const first = ids.length ? s.parts[ids[0]] : undefined;
|
||||
return first ? (first.color ?? PART_DEFS[first.type].color) : '#888888';
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{COLOR_PRESETS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
title={c}
|
||||
onClick={() => applyColor(ids, c)}
|
||||
className={`h-6 w-full rounded-md ring-1 transition hover:scale-110 ${
|
||||
current.toLowerCase() === c ? 'ring-2 ring-white' : 'ring-black/40'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label
|
||||
className="relative flex h-6 flex-1 cursor-pointer items-center justify-center gap-1 overflow-hidden rounded-md border border-zinc-700 bg-zinc-800 text-[10px] font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
title="Pick a custom color"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm ring-1 ring-black/40"
|
||||
style={{ backgroundColor: current }}
|
||||
/>
|
||||
Custom
|
||||
<input
|
||||
type="color"
|
||||
value={current}
|
||||
onChange={(e) => applyColor(ids, e.target.value)}
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => applyColor(ids, undefined)}
|
||||
title="Reset to the catalog color"
|
||||
className="h-6 rounded-md border border-zinc-700 bg-zinc-800 px-2 text-[10px] font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ColorPopover — small floating palette opened by cmd/ctrl+clicking a part.
|
||||
* Applies to every selected part; closes on outside click or Esc (App).
|
||||
*/
|
||||
export function ColorPopover() {
|
||||
const popover = useBuilder((s) => s.colorPopover);
|
||||
const selectedIds = useBuilder((s) => s.selectedIds);
|
||||
const close = useBuilder((s) => s.closeColorPopover);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!popover) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) close();
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
return () => window.removeEventListener('pointerdown', onDown, true);
|
||||
}, [popover, close]);
|
||||
|
||||
if (!popover || !selectedIds.length) return null;
|
||||
|
||||
const x = Math.max(8, Math.min(popover.x, window.innerWidth - 196));
|
||||
const y = Math.max(8, Math.min(popover.y + 10, window.innerHeight - 150));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-50 w-[188px] rounded-xl border border-zinc-700 bg-zinc-900/95 p-2.5 shadow-2xl backdrop-blur"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<div className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Color · {selectedIds.length} part{selectedIds.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<ColorSwatchRow ids={selectedIds} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
src/components/ConnectorHandles.tsx
Normal file
285
src/components/ConnectorHandles.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Html, Line, useCursor } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import type { ThreeEvent } from '@react-three/fiber';
|
||||
import type { WorldConnector } from '../types';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { getWorldConnectors } from '../utils/connectors';
|
||||
import { beginPartDrag, dragCtx } from '../utils/dragState';
|
||||
|
||||
/**
|
||||
* ConnectorHandles — grabbable spheres at every connector of the selected
|
||||
* parts. Dragging a handle moves the WHOLE part (and the rest of the
|
||||
* selection, rigidly) so the grabbed connector follows the cursor; the
|
||||
* DragPlane in SceneCanvas does the actual plane math, live-highlights the
|
||||
* nearest open mating connector, and magnetically snaps on release.
|
||||
*/
|
||||
export function ConnectorHandles() {
|
||||
const selectedIds = useBuilder((s) => s.selectedIds);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const placingType = useBuilder((s) => s.placingType);
|
||||
const tool = useBuilder((s) => s.tool);
|
||||
|
||||
if (placingType || tool === 'measure' || !selectedIds.length) return null;
|
||||
|
||||
const conns: WorldConnector[] = [];
|
||||
for (const id of selectedIds) {
|
||||
const part = parts[id];
|
||||
if (part) conns.push(...getWorldConnectors(part));
|
||||
}
|
||||
const pipeIds = selectedIds.filter((id) => parts[id]?.type === 'pipe');
|
||||
|
||||
return (
|
||||
<>
|
||||
{pipeIds.map((id) => (
|
||||
<PipeStretchHandles key={`pipe-stretch-${id}`} partId={id} />
|
||||
))}
|
||||
{conns.map((c) => (
|
||||
<Handle key={c.key} conn={c} dimmed={parts[c.partId]?.type === 'pipe'} />
|
||||
))}
|
||||
<SnapTargetHighlight />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const dirQuat = (dir: [number, number, number]) =>
|
||||
new THREE.Quaternion().setFromUnitVectors(
|
||||
new THREE.Vector3(0, 0, 1),
|
||||
new THREE.Vector3(...dir).normalize(),
|
||||
);
|
||||
|
||||
function Handle({ conn, dimmed = false }: { conn: WorldConnector; dimmed?: boolean }) {
|
||||
const draggingId = useBuilder((s) => s.draggingId);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
useCursor(hovered, 'grab');
|
||||
|
||||
const quat = useMemo(() => dirQuat(conn.dir), [conn.dir[0], conn.dir[1], conn.dir[2]]);
|
||||
const isDragSource = draggingId !== null && dragCtx.connectorKey === conn.key;
|
||||
const active = hovered || isDragSource;
|
||||
|
||||
const onPointerDown = (e: ThreeEvent<PointerEvent>) => {
|
||||
if (e.button !== 0) return;
|
||||
const s = useBuilder.getState();
|
||||
if (s.placingType || s.tool === 'measure') return;
|
||||
e.stopPropagation();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
const handlePointerUp = (upEvt: PointerEvent) => {
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
const dx = upEvt.clientX - startX;
|
||||
const dy = upEvt.clientY - startY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < 4) {
|
||||
s.spawnPipeAtConnector(conn.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
|
||||
beginPartDrag({
|
||||
partId: conn.partId,
|
||||
grabPoint: conn.pos,
|
||||
mode: 'handle',
|
||||
connectorKey: conn.key,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={conn.pos}>
|
||||
{/* Generous invisible hit sphere (raycasts despite invisible material). */}
|
||||
<mesh
|
||||
userData={{ connectorHandle: true }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerOver={(e) => {
|
||||
// No stopPropagation (it would starve the DragPlane of move events
|
||||
// mid-drag). Handles take hover priority over part bodies when they
|
||||
// are roughly as close as the nearest hit (mirrors the
|
||||
// PartMesh pointerdown guard).
|
||||
if (useBuilder.getState().draggingId) return;
|
||||
const firstHandle = e.intersections.find(
|
||||
(i) => i.object.userData?.connectorHandle,
|
||||
);
|
||||
if (
|
||||
firstHandle?.object !== e.object ||
|
||||
firstHandle.distance > (e.intersections[0]?.distance ?? Infinity) + 0.5
|
||||
)
|
||||
return;
|
||||
setHovered(true);
|
||||
}}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<sphereGeometry args={[0.22, 10, 10]} />
|
||||
<meshBasicMaterial visible={false} />
|
||||
</mesh>
|
||||
{/* Visible grab knob, always on top so it's never buried in geometry. */}
|
||||
<mesh scale={active ? 1.4 : 1} renderOrder={998}>
|
||||
<sphereGeometry args={[dimmed ? 0.05 : 0.075, 14, 14]} />
|
||||
<meshBasicMaterial
|
||||
color={active ? '#bae6fd' : '#38bdf8'}
|
||||
toneMapped={false}
|
||||
depthTest={false}
|
||||
transparent
|
||||
opacity={dimmed ? (active ? 0.85 : 0.45) : 0.95}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Direction ring hinting how the connector faces. */}
|
||||
<mesh quaternion={quat} scale={active ? 1.25 : 1} renderOrder={998}>
|
||||
<torusGeometry args={[dimmed ? 0.11 : 0.15, dimmed ? 0.014 : 0.018, 8, 24]} />
|
||||
<meshBasicMaterial
|
||||
color="#38bdf8"
|
||||
toneMapped={false}
|
||||
depthTest={false}
|
||||
transparent
|
||||
opacity={dimmed ? (active ? 0.8 : 0.35) : active ? 0.95 : 0.55}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function PipeStretchHandles({ partId }: { partId: string }) {
|
||||
const part = useBuilder((s) => s.parts[partId]);
|
||||
const draggingId = useBuilder((s) => s.draggingId);
|
||||
if (!part || part.type !== 'pipe') return null;
|
||||
|
||||
const conns = getWorldConnectors(part);
|
||||
const a = conns.find((c) => c.connectorId === 'a');
|
||||
const b = conns.find((c) => c.connectorId === 'b');
|
||||
if (!a || !b) return null;
|
||||
|
||||
const len = part.params.length ?? 0;
|
||||
const mid: [number, number, number] = [
|
||||
(a.pos[0] + b.pos[0]) / 2,
|
||||
(a.pos[1] + b.pos[1]) / 2 + 0.28,
|
||||
(a.pos[2] + b.pos[2]) / 2,
|
||||
];
|
||||
const editing = draggingId === partId && !!dragCtx.pipeStretch;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Line
|
||||
points={[a.pos, b.pos]}
|
||||
color={editing ? '#7dd3fc' : '#38bdf8'}
|
||||
lineWidth={2}
|
||||
transparent
|
||||
opacity={editing ? 0.95 : 0.55}
|
||||
/>
|
||||
<PipeEndHandle conn={a} />
|
||||
<PipeEndHandle conn={b} />
|
||||
<Html position={mid} center distanceFactor={12}>
|
||||
<div
|
||||
className={`rounded-full border px-2 py-1 text-[10px] font-semibold whitespace-nowrap shadow-lg backdrop-blur ${
|
||||
editing
|
||||
? 'border-sky-400 bg-sky-300/95 text-sky-950'
|
||||
: 'border-zinc-700 bg-zinc-950/90 text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{len.toFixed(2)} ft
|
||||
<span className="ml-1 text-zinc-400">{editing ? 'stretching' : 'drag ends to stretch'}</span>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function PipeEndHandle({ conn }: { conn: WorldConnector }) {
|
||||
const draggingId = useBuilder((s) => s.draggingId);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
useCursor(hovered, 'grab');
|
||||
const quat = useMemo(() => dirQuat(conn.dir), [conn.dir[0], conn.dir[1], conn.dir[2]]);
|
||||
const active = hovered || (draggingId !== null && dragCtx.connectorKey === conn.key);
|
||||
|
||||
const onPointerDown = (e: ThreeEvent<PointerEvent>) => {
|
||||
if (e.button !== 0) return;
|
||||
const s = useBuilder.getState();
|
||||
if (s.placingType || s.tool === 'measure') return;
|
||||
e.stopPropagation();
|
||||
beginPartDrag({
|
||||
partId: conn.partId,
|
||||
grabPoint: conn.pos,
|
||||
mode: 'handle',
|
||||
connectorKey: conn.key,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={conn.pos}>
|
||||
<mesh
|
||||
userData={{ connectorHandle: true }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerOver={(e) => {
|
||||
if (useBuilder.getState().draggingId) return;
|
||||
const firstHandle = e.intersections.find((i) => i.object.userData?.connectorHandle);
|
||||
if (
|
||||
firstHandle?.object !== e.object ||
|
||||
firstHandle.distance > (e.intersections[0]?.distance ?? Infinity) + 0.5
|
||||
)
|
||||
return;
|
||||
setHovered(true);
|
||||
}}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<sphereGeometry args={[0.28, 12, 12]} />
|
||||
<meshBasicMaterial visible={false} />
|
||||
</mesh>
|
||||
<mesh quaternion={quat} scale={active ? 1.12 : 1} renderOrder={998}>
|
||||
<cylinderGeometry args={[0.05, 0.1, 0.18, 12]} />
|
||||
<meshBasicMaterial
|
||||
color={active ? '#e0f2fe' : '#7dd3fc'}
|
||||
toneMapped={false}
|
||||
depthTest={false}
|
||||
transparent
|
||||
opacity={0.98}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh quaternion={quat} position={[0, 0, 0.11]} scale={active ? 1.18 : 1} renderOrder={998}>
|
||||
<coneGeometry args={[0.08, 0.16, 12]} />
|
||||
<meshBasicMaterial
|
||||
color={active ? '#38bdf8' : '#0ea5e9'}
|
||||
toneMapped={false}
|
||||
depthTest={false}
|
||||
transparent
|
||||
opacity={0.95}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pulsing glow ring on the nearest open connector during a handle drag. */
|
||||
function SnapTargetHighlight() {
|
||||
const key = useBuilder((s) => s.snapTargetKey);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const ref = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (ref.current) {
|
||||
const k = 1 + 0.18 * Math.sin(clock.elapsedTime * 7);
|
||||
ref.current.scale.setScalar(k);
|
||||
}
|
||||
});
|
||||
|
||||
if (!key) return null;
|
||||
const partId = key.split(':')[0];
|
||||
const part = parts[partId];
|
||||
if (!part) return null;
|
||||
const conn = getWorldConnectors(part).find((c) => c.key === key);
|
||||
if (!conn) return null;
|
||||
|
||||
return (
|
||||
<group position={conn.pos}>
|
||||
<group ref={ref}>
|
||||
<mesh quaternion={dirQuat(conn.dir)} renderOrder={999}>
|
||||
<torusGeometry args={[0.24, 0.035, 10, 28]} />
|
||||
<meshBasicMaterial color="#34d399" toneMapped={false} depthTest={false} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
<mesh renderOrder={999}>
|
||||
<sphereGeometry args={[0.1, 12, 12]} />
|
||||
<meshBasicMaterial color="#6ee7b7" toneMapped={false} depthTest={false} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
251
src/components/FlowArrows.tsx
Normal file
251
src/components/FlowArrows.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import type { FlowSegment, PlacedPart, SimResult } from '../types';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { useSimulation } from '../simulation/flowSimulator';
|
||||
import {
|
||||
ensureWaterVisual,
|
||||
freezeWaterClock,
|
||||
getWaterTime,
|
||||
getWaterVisual,
|
||||
tickWaterClock,
|
||||
waterVisuals,
|
||||
} from '../simulation/waterState';
|
||||
|
||||
/**
|
||||
* FlowArrows — mount root for all time-based water visual systems:
|
||||
*
|
||||
* - WaterDriver (always mounted): advances a global fill wavefront each
|
||||
* frame and writes per-part fill state into the shared water map
|
||||
* (simulation/waterState), which the meshes in PartBody read in their
|
||||
* own useFrame callbacks. No React state is touched per frame.
|
||||
* - Direction arrows (gated by the showFlow toggle): small cones marching
|
||||
* along flowing segments, fading in/out at segment ends and appearing
|
||||
* only once the pipe has actually filled with water.
|
||||
*/
|
||||
export function FlowArrows() {
|
||||
const showFlow = useBuilder((s) => s.showFlow);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const sim = useSimulation();
|
||||
return (
|
||||
<group>
|
||||
<WaterDriver sim={sim} parts={parts} />
|
||||
{showFlow &&
|
||||
sim.segments.map((seg, i) => <ArrowStream key={`${seg.partId}-${i}`} seg={seg} />)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Water fill driver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Module-level so remounts / panel toggles don't reset the animation.
|
||||
let waveFront = 0;
|
||||
|
||||
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||||
const clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x);
|
||||
|
||||
const GAL_PER_FT3 = 7.48;
|
||||
|
||||
/** Approximate internal water volume (gal) — sets how fast a part fills. */
|
||||
function partVolumeGal(part: PlacedPart, pathLen: number): number {
|
||||
const p = part.params;
|
||||
const d = Math.max(0.25, p.diameter ?? 1);
|
||||
switch (part.type) {
|
||||
case 'pipe':
|
||||
case 'elbow':
|
||||
case 'tee':
|
||||
case 'valve':
|
||||
// Bore volume: π·r² × path length (d inches → ft).
|
||||
return Math.PI * Math.pow(d / 24, 2) * pathLen * GAL_PER_FT3;
|
||||
case 'pump':
|
||||
return 0.06;
|
||||
case 'tower':
|
||||
return 0.1 * (p.height ?? 4);
|
||||
case 'tray':
|
||||
return 0.08 * (p.length ?? 3) * (p.width ?? 1.2);
|
||||
case 'wallPanel':
|
||||
return 0.25;
|
||||
case 'emitter':
|
||||
return 0.02;
|
||||
case 'drain':
|
||||
return 0.05;
|
||||
case 'reservoir':
|
||||
return 2;
|
||||
default:
|
||||
return 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
interface WaveSpan {
|
||||
dist: number;
|
||||
len: number;
|
||||
/** Wavefront speed through this part (ft/s) ≈ len · GPH / volume. */
|
||||
speed: number;
|
||||
}
|
||||
|
||||
function WaterDriver({ sim, parts }: { sim: SimResult; parts: Record<string, PlacedPart> }) {
|
||||
const simRef = useRef(sim);
|
||||
simRef.current = sim;
|
||||
|
||||
// Per-part fill data, recomputed only when the sim solution changes.
|
||||
const wave = useMemo(() => {
|
||||
const partIds = Object.keys(sim.fillLength);
|
||||
const spans: WaveSpan[] = [];
|
||||
for (const pid of partIds) {
|
||||
const dist = sim.fillDistance[pid];
|
||||
const flow = sim.flows[pid] ?? 0;
|
||||
const part = parts[pid];
|
||||
if (dist === undefined || flow <= 0 || !part) continue;
|
||||
const len = Math.max(0.2, sim.fillLength[pid]);
|
||||
const vol = Math.max(0.004, partVolumeGal(part, len));
|
||||
spans.push({ dist, len, speed: clamp((len * (flow / 3600)) / vol, 0.5, 5) });
|
||||
}
|
||||
return { partIds, spans };
|
||||
}, [sim, parts]);
|
||||
const waveRef = useRef(wave);
|
||||
waveRef.current = wave;
|
||||
|
||||
useFrame((_, rawDt) => {
|
||||
// Master play/pause: freeze ALL water animation in place.
|
||||
if (!useBuilder.getState().simRunning) {
|
||||
freezeWaterClock();
|
||||
return;
|
||||
}
|
||||
const s = simRef.current;
|
||||
const dt = Math.min(rawDt, 0.1);
|
||||
tickWaterClock(dt);
|
||||
|
||||
// Advance the wavefront while pumping; retreat (drain back) when idle.
|
||||
// The front moves at the speed of the slowest part it is currently
|
||||
// filling (∝ GPH / part volume), so fat tanks fill slower than thin pipes.
|
||||
const flowing = s.totalGph > 0.5;
|
||||
if (flowing) {
|
||||
let speed = Infinity;
|
||||
for (const span of waveRef.current.spans) {
|
||||
if (waveFront >= span.dist && waveFront < span.dist + span.len) {
|
||||
speed = Math.min(speed, span.speed);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(speed)) speed = 2; // bridging a junction gap
|
||||
waveFront = Math.min(waveFront + speed * dt, s.totalPathLength + 1);
|
||||
} else {
|
||||
waveFront = Math.max(waveFront - 5 * dt, 0);
|
||||
}
|
||||
|
||||
for (const pid of waveRef.current.partIds) ensureWaterVisual(pid);
|
||||
|
||||
for (const [pid, v] of waterVisuals) {
|
||||
if (!(pid in s.fillLength)) {
|
||||
// Part was deleted — let its water vanish, then drop the entry.
|
||||
v.fill = Math.max(0, v.fill - dt * 1.5);
|
||||
if (v.fill <= 0) waterVisuals.delete(pid);
|
||||
continue;
|
||||
}
|
||||
const flow = s.flows[pid] ?? 0;
|
||||
const backed = s.backedUp.has(pid);
|
||||
const dist = s.fillDistance[pid];
|
||||
const len = Math.max(0.2, s.fillLength[pid]);
|
||||
|
||||
if (flow > 0 && dist !== undefined) {
|
||||
// A part D ft from the pump fills once the wavefront passes D.
|
||||
v.fill = clamp01((waveFront - dist) / len);
|
||||
} else if (backed) {
|
||||
v.fill = Math.min(1, v.fill + dt * 0.6); // stagnant water backing up
|
||||
} else {
|
||||
v.fill = Math.max(0, v.fill - dt * 0.9); // no supply — drain out
|
||||
}
|
||||
|
||||
v.flow = flow;
|
||||
v.backedUp = backed;
|
||||
v.overflow = false;
|
||||
v.spill = false;
|
||||
v.entry = s.entryConnector[pid];
|
||||
v.state = backed
|
||||
? 'backedUp'
|
||||
: v.fill <= 0.002
|
||||
? 'empty'
|
||||
: v.fill >= 0.998
|
||||
? 'full'
|
||||
: 'filling';
|
||||
}
|
||||
|
||||
for (const d of s.drains) {
|
||||
if (!d.overflowing) continue;
|
||||
const v = waterVisuals.get(d.partId);
|
||||
if (v) v.overflow = true;
|
||||
}
|
||||
for (const pot of s.netPots) {
|
||||
if (!pot.overflowing) continue;
|
||||
const v = ensureWaterVisual(pot.partId);
|
||||
v.fill = 1;
|
||||
v.flow = pot.inflowGph;
|
||||
v.backedUp = true;
|
||||
v.overflow = false;
|
||||
v.spill = true;
|
||||
v.state = 'backedUp';
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direction arrows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UP = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
function ArrowStream({ seg }: { seg: FlowSegment }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
const { from, dir, len, quat, count, speed } = useMemo(() => {
|
||||
const from = new THREE.Vector3(...seg.from);
|
||||
const to = new THREE.Vector3(...seg.to);
|
||||
const dir = to.clone().sub(from);
|
||||
const len = dir.length();
|
||||
dir.normalize();
|
||||
// Arrow speed tracks water VELOCITY (∝ GPH / d²), not raw GPH — the same
|
||||
// flow squeezed through a narrow pipe visibly rushes.
|
||||
const dia = Math.max(0.5, seg.diameter ?? 1);
|
||||
return {
|
||||
from,
|
||||
dir,
|
||||
len,
|
||||
quat: new THREE.Quaternion().setFromUnitVectors(UP, dir),
|
||||
count: Math.max(1, Math.round(len / 0.55)),
|
||||
speed: 0.35 + Math.min(2.5, seg.gph / (dia * dia) / 220),
|
||||
};
|
||||
}, [seg]);
|
||||
|
||||
useFrame(() => {
|
||||
const g = groupRef.current;
|
||||
if (!g) return;
|
||||
const t = getWaterTime() * speed;
|
||||
// Arrows only appear once the water has actually reached this part.
|
||||
const fill = getWaterVisual(seg.partId)?.fill ?? 1;
|
||||
g.children.forEach((child, i) => {
|
||||
const f = (((i + t) % count) + count) % count; // 0..count
|
||||
const u = f / count;
|
||||
child.position.copy(from).addScaledVector(dir, u * len);
|
||||
const mat = (child as THREE.Mesh).material as THREE.MeshBasicMaterial;
|
||||
// Fade in/out near the segment ends.
|
||||
mat.opacity = 0.85 * fill * Math.min(1, Math.min(u, 1 - u) * 4 + 0.15);
|
||||
});
|
||||
});
|
||||
|
||||
if (len < 0.2) return null;
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<mesh key={i} quaternion={quat}>
|
||||
<coneGeometry args={[0.05, 0.14, 8]} />
|
||||
<meshBasicMaterial color="#38bdf8" toneMapped={false} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
1264
src/components/PartBody.tsx
Normal file
1264
src/components/PartBody.tsx
Normal file
File diff suppressed because it is too large
Load Diff
222
src/components/PartLibrary.tsx
Normal file
222
src/components/PartLibrary.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react';
|
||||
import { CATEGORIES, PART_DEFS } from '../parts/catalog';
|
||||
import { HOTBAR_PARTS } from '../parts/hotbar';
|
||||
import type { PartType } from '../types';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { nextQuickBuildOffset, quickBuildParts, QUICK_BUILDS } from '../utils/demoProject';
|
||||
import { PartIcon } from './icons/PartIcons';
|
||||
|
||||
/**
|
||||
* PartLibrary — left sidebar. Click a part (or drag it onto the canvas)
|
||||
* to place it. Shift-click in the scene stamps multiple copies.
|
||||
*/
|
||||
export function PartLibrary() {
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const placingType = useBuilder((s) => s.placingType);
|
||||
const setPlacingType = useBuilder((s) => s.setPlacingType);
|
||||
const addPart = useBuilder((s) => s.addPart);
|
||||
const updatePart = useBuilder((s) => s.updatePart);
|
||||
const setSelected = useBuilder((s) => s.setSelected);
|
||||
const toggleSelected = useBuilder((s) => s.toggleSelected);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const matchesPart = (type: PartType) => PART_DEFS[type].label.toLowerCase().includes(q);
|
||||
|
||||
const filteredHotbar = q ? HOTBAR_PARTS.filter(matchesPart) : HOTBAR_PARTS;
|
||||
const filteredQuickBuilds = q
|
||||
? QUICK_BUILDS.filter((b) => b.label.toLowerCase().includes(q))
|
||||
: QUICK_BUILDS;
|
||||
const filteredCategories = CATEGORIES.map((cat) => ({
|
||||
...cat,
|
||||
types: q ? cat.types.filter(matchesPart) : cat.types,
|
||||
})).filter((cat) => cat.types.length > 0);
|
||||
|
||||
const addQuickBuild = (id: (typeof QUICK_BUILDS)[number]['id']) => {
|
||||
setPlacingType(null);
|
||||
const assembly = quickBuildParts(id, nextQuickBuildOffset(Object.values(parts)));
|
||||
const ids = assembly.map((part) => {
|
||||
const placedId = addPart(part.type, part.position, { snap: false });
|
||||
updatePart(placedId, {
|
||||
rotation: part.rotation,
|
||||
params: part.params,
|
||||
label: part.label,
|
||||
color: part.color,
|
||||
});
|
||||
return placedId;
|
||||
});
|
||||
|
||||
setSelected(ids[0] ?? null);
|
||||
ids.slice(1).forEach(toggleSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||||
<div className="border-b border-zinc-800 px-4 py-3">
|
||||
<h2 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
|
||||
Parts Library
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] leading-snug text-zinc-600">
|
||||
Click then place in scene, or drag onto the canvas. Shift-click to stamp copies.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search parts…"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1.5 text-xs text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-zinc-600 focus:ring-1 focus:ring-zinc-700"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded px-1 text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
{filteredHotbar.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Tool Belt
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{filteredHotbar.map((type, idx) => (
|
||||
<LibraryItem
|
||||
key={`hotbar-${type}`}
|
||||
type={type}
|
||||
active={placingType === type}
|
||||
badge={!q ? `${idx + 1}` : undefined}
|
||||
compact
|
||||
onClick={() => setPlacingType(placingType === type ? null : type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filteredQuickBuilds.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Quick Builds
|
||||
</h3>
|
||||
<div className="grid gap-1.5">
|
||||
{filteredQuickBuilds.map((build) => (
|
||||
<QuickBuildButton
|
||||
key={build.id}
|
||||
label={build.label}
|
||||
description={build.description}
|
||||
onClick={() => addQuickBuild(build.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filteredCategories.map((cat) => (
|
||||
<div key={cat.name} className="mb-3">
|
||||
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
{cat.name}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{cat.types.map((t) => (
|
||||
<LibraryItem
|
||||
key={t}
|
||||
type={t}
|
||||
active={placingType === t}
|
||||
onClick={() => setPlacingType(placingType === t ? null : t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickBuildButton({
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={description}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2 text-left text-zinc-200 transition hover:border-emerald-500/60 hover:bg-emerald-500/10 hover:text-emerald-100"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[12px] leading-tight font-semibold">{label}</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-snug text-zinc-500">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-sm text-emerald-400">+</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryItem({
|
||||
type,
|
||||
active,
|
||||
badge,
|
||||
compact,
|
||||
onClick,
|
||||
}: {
|
||||
type: PartType;
|
||||
active: boolean;
|
||||
badge?: string;
|
||||
compact?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const def = PART_DEFS[type];
|
||||
return (
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-hydro-part', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
onClick={onClick}
|
||||
title={
|
||||
active
|
||||
? `${def.description} — click again to cancel${badge ? ` (${badge})` : ''}`
|
||||
: `${def.description}${badge ? ` (${badge})` : ''}`
|
||||
}
|
||||
className={`group relative flex flex-col items-center gap-1 rounded-lg border px-1.5 text-center transition
|
||||
${
|
||||
active
|
||||
? 'border-sky-400 bg-sky-500/15 text-sky-300 shadow-[0_0_10px_-2px_rgba(56,189,248,0.6)] ring-1 ring-sky-400/40'
|
||||
: 'border-zinc-800 bg-zinc-900/60 text-zinc-300 hover:border-zinc-600 hover:bg-zinc-800/80'
|
||||
} ${compact ? 'py-1.5' : 'py-2'}`}
|
||||
>
|
||||
{badge && (
|
||||
<span className="absolute top-1 left-1 rounded bg-zinc-950/90 px-1 py-0.5 text-[9px] font-bold leading-none text-zinc-400 ring-1 ring-zinc-800">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<PartIcon
|
||||
type={type}
|
||||
className={`${compact ? 'h-7 w-7' : 'h-9 w-9'} transition-transform duration-150 group-hover:scale-110 ${
|
||||
active ? 'scale-110 drop-shadow-[0_0_4px_rgba(56,189,248,0.5)]' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className={`w-full font-medium break-words ${compact ? 'text-[10px]' : 'text-[11px] leading-tight'}`}>
|
||||
{def.label}
|
||||
</span>
|
||||
{active && (
|
||||
<span className="text-[9px] font-semibold tracking-wider text-sky-400 uppercase">
|
||||
Placing…
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
208
src/components/PartMesh.tsx
Normal file
208
src/components/PartMesh.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { useCursor } from '@react-three/drei';
|
||||
import type { ThreeEvent } from '@react-three/fiber';
|
||||
import type { PlacedPart, Vec3 } from '../types';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { beginPartDrag } from '../utils/dragState';
|
||||
import { ConnectorDots, PartBody, PartCtx } from './PartBody';
|
||||
import { FLOW_PARTS } from '../parts/catalog';
|
||||
|
||||
interface PartMeshProps {
|
||||
part: PlacedPart;
|
||||
/** GPH flowing through this part (0 = idle). */
|
||||
flow?: number;
|
||||
selected?: boolean;
|
||||
/** Render as a translucent placement preview. */
|
||||
ghost?: boolean;
|
||||
/** Connector keys joined to another part (for the green/amber dots). */
|
||||
connectedKeys?: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure a part's solid geometry bounds in the part's local space.
|
||||
* Only castShadow meshes count, so transient water visuals (emitter streams,
|
||||
* tray fill, reservoir water) don't inflate the box.
|
||||
*/
|
||||
function measureLocalBox(root: THREE.Object3D): THREE.Box3 {
|
||||
const box = new THREE.Box3();
|
||||
root.updateWorldMatrix(true, true);
|
||||
const inv = new THREE.Matrix4().copy(root.matrixWorld).invert();
|
||||
const rel = new THREE.Matrix4();
|
||||
const b = new THREE.Box3();
|
||||
const collect = (solidOnly: boolean) => {
|
||||
root.traverse((o) => {
|
||||
const mesh = o as THREE.Mesh;
|
||||
if (!mesh.isMesh || (solidOnly && !mesh.castShadow)) return;
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
|
||||
if (!mesh.geometry.boundingBox) return;
|
||||
b.copy(mesh.geometry.boundingBox);
|
||||
rel.multiplyMatrices(inv, mesh.matrixWorld);
|
||||
b.applyMatrix4(rel);
|
||||
box.union(b);
|
||||
});
|
||||
};
|
||||
collect(true);
|
||||
if (box.isEmpty()) collect(false);
|
||||
return box;
|
||||
}
|
||||
|
||||
interface Bounds {
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
}
|
||||
|
||||
/** Generous invisible hit size so thin pipes/valves are easy to click. */
|
||||
const hitSize = (v: number) => Math.max(v + 0.22, 0.5);
|
||||
|
||||
/**
|
||||
* PartMesh — interaction wrapper around a part's geometry (PartBody):
|
||||
* selection (incl. shift multi-select & cmd-click color popover), drag start,
|
||||
* hover highlight, an inflated invisible hit proxy, and connector indicators.
|
||||
*/
|
||||
export const PartMesh = memo(function PartMesh({
|
||||
part,
|
||||
flow = 0,
|
||||
selected = false,
|
||||
ghost = false,
|
||||
connectedKeys,
|
||||
}: PartMeshProps) {
|
||||
const innerRef = useRef<THREE.Group>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
useCursor(hovered && !ghost);
|
||||
|
||||
// Re-measure local bounds when geometry-affecting inputs change.
|
||||
const paramsKey = JSON.stringify(part.params);
|
||||
useEffect(() => {
|
||||
if (ghost) return;
|
||||
const handle = requestAnimationFrame(() => {
|
||||
const inner = innerRef.current;
|
||||
if (!inner) return;
|
||||
const box = measureLocalBox(inner);
|
||||
if (box.isEmpty()) {
|
||||
setBounds({ center: [0, 0.25, 0], size: [0.5, 0.5, 0.5] });
|
||||
return;
|
||||
}
|
||||
const c = box.getCenter(new THREE.Vector3());
|
||||
const sz = box.getSize(new THREE.Vector3());
|
||||
setBounds({ center: [c.x, c.y, c.z], size: [sz.x, sz.y, sz.z] });
|
||||
});
|
||||
return () => cancelAnimationFrame(handle);
|
||||
}, [part.type, paramsKey, ghost]);
|
||||
|
||||
// Soft edge outline shown on hover.
|
||||
const hoverEdges = useMemo(() => {
|
||||
if (!bounds || ghost) return null;
|
||||
const g = new THREE.BoxGeometry(
|
||||
bounds.size[0] + 0.1,
|
||||
bounds.size[1] + 0.1,
|
||||
bounds.size[2] + 0.1,
|
||||
);
|
||||
const edges = new THREE.EdgesGeometry(g);
|
||||
g.dispose();
|
||||
return edges;
|
||||
}, [bounds, ghost]);
|
||||
useEffect(() => () => hoverEdges?.dispose(), [hoverEdges]);
|
||||
|
||||
const onPointerDown = (e: ThreeEvent<PointerEvent>) => {
|
||||
if (ghost) return;
|
||||
const s = useBuilder.getState();
|
||||
if (s.placingType || s.tool === 'measure') return; // ground layer handles those
|
||||
// A connector handle along the same ray takes priority over the body.
|
||||
const handleHit = e.intersections.find((i) => i.object.userData?.connectorHandle);
|
||||
if (handleHit && handleHit.distance <= e.distance + 0.5) return;
|
||||
e.stopPropagation();
|
||||
const ne = e.nativeEvent;
|
||||
if (ne.metaKey || ne.ctrlKey) {
|
||||
// Cmd/Ctrl+click — color popover for the selection (select this part if needed).
|
||||
if (!s.selectedIds.includes(part.id)) s.setSelected(part.id);
|
||||
s.openColorPopover(ne.clientX, ne.clientY);
|
||||
return;
|
||||
}
|
||||
if (ne.shiftKey) {
|
||||
s.toggleSelected(part.id);
|
||||
return;
|
||||
}
|
||||
if (!s.selectedIds.includes(part.id)) s.setSelected(part.id);
|
||||
if (e.button !== 0) return;
|
||||
if (ne.altKey) {
|
||||
const sourceIds = s.selectedIds.includes(part.id) ? [...s.selectedIds] : [part.id];
|
||||
const copiedIds = s.duplicateParts(sourceIds, { offset: [0, 0, 0] });
|
||||
const sourceIndex = sourceIds.indexOf(part.id);
|
||||
const dragId = copiedIds[sourceIndex] ?? copiedIds[0];
|
||||
if (!dragId) return;
|
||||
beginPartDrag({
|
||||
partId: dragId,
|
||||
grabPoint: [e.point.x, e.point.y, e.point.z],
|
||||
mode: 'body',
|
||||
});
|
||||
return;
|
||||
}
|
||||
beginPartDrag({
|
||||
partId: part.id,
|
||||
grabPoint: [e.point.x, e.point.y, e.point.z],
|
||||
mode: 'body',
|
||||
});
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: ThreeEvent<MouseEvent>) => {
|
||||
if (ghost) return;
|
||||
e.stopPropagation();
|
||||
const s = useBuilder.getState();
|
||||
if (s.placingType || s.tool === 'measure') return;
|
||||
if (FLOW_PARTS.has(part.type)) s.selectConnectedRun(part.id);
|
||||
else s.setSelected(part.id);
|
||||
if (part.type === 'pump') {
|
||||
// PropertiesPanel listens and flashes/scrolls the pump quick-settings card.
|
||||
window.dispatchEvent(new CustomEvent('hydro:flash-pump', { detail: part.id }));
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerOver = (e: ThreeEvent<PointerEvent>) => {
|
||||
if (ghost) return;
|
||||
const s = useBuilder.getState();
|
||||
if (s.placingType || s.tool === 'measure' || s.draggingId) return;
|
||||
// No stopPropagation here (it would break r3f's move delivery during
|
||||
// drags); only hover when this part is the nearest thing under the cursor.
|
||||
if (e.intersections[0]?.eventObject !== e.eventObject) return;
|
||||
setHovered(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<PartCtx.Provider value={{ ghost, selected }}>
|
||||
<group
|
||||
position={part.position}
|
||||
rotation={part.rotation}
|
||||
onPointerDown={onPointerDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onPointerOver={onPointerOver}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<group ref={innerRef}>
|
||||
<group name="part-body-mesh" userData={{ partId: part.id }}>
|
||||
<PartBody part={part} flow={flow} connectedKeys={connectedKeys} />
|
||||
</group>
|
||||
{!ghost && <ConnectorDots part={part} connectedKeys={connectedKeys} />}
|
||||
</group>
|
||||
|
||||
{/* Inflated invisible hit proxy — makes thin parts easy to grab. */}
|
||||
{!ghost && bounds && (
|
||||
<mesh position={bounds.center}>
|
||||
<boxGeometry
|
||||
args={[hitSize(bounds.size[0]), hitSize(bounds.size[1]), hitSize(bounds.size[2])]}
|
||||
/>
|
||||
<meshBasicMaterial visible={false} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Hover outline (own indicator — PartBody is untouched). */}
|
||||
{!ghost && hovered && !selected && bounds && hoverEdges && (
|
||||
<lineSegments geometry={hoverEdges} position={bounds.center}>
|
||||
<lineBasicMaterial color="#38bdf8" transparent opacity={0.55} depthWrite={false} />
|
||||
</lineSegments>
|
||||
)}
|
||||
</group>
|
||||
</PartCtx.Provider>
|
||||
);
|
||||
});
|
||||
888
src/components/PropertiesPanel.tsx
Normal file
888
src/components/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,888 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { PART_DEFS, STANDARD_PIPE_SIZES, snapPipeSize } from '../parts/catalog';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSimulator';
|
||||
import { computeRoomMetrics } from '../environment/roomMetrics';
|
||||
import type { PlacedPart, Vec3 } from '../types';
|
||||
import { ColorSwatchRow } from './ColorPopover';
|
||||
|
||||
const rad2deg = (r: number) => Math.round((r * 180) / Math.PI);
|
||||
const deg2rad = (d: number) => (d * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* PropertiesPanel — right sidebar inspector.
|
||||
* One part selected: full inspector (pump quick-settings first for pumps).
|
||||
* Multiple parts: compact group panel (counts, color, shared actions).
|
||||
*/
|
||||
export function PropertiesPanel() {
|
||||
const selectedIds = useBuilder((s) => s.selectedIds);
|
||||
|
||||
return (
|
||||
<aside className="flex w-64 shrink-0 flex-col border-l border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||||
<div className="border-b border-zinc-800 px-4 py-3">
|
||||
<h2 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
|
||||
Inspector
|
||||
{selectedIds.length > 1 && (
|
||||
<span className="ml-2 rounded-full bg-sky-500/15 px-2 py-0.5 text-[10px] font-bold text-sky-300">
|
||||
{selectedIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedIds.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : selectedIds.length === 1 ? (
|
||||
<PartInspector partId={selectedIds[0]} />
|
||||
) : (
|
||||
<MultiInspector ids={selectedIds} />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
const growthStage = useBuilder((s) => s.growthStage);
|
||||
const setGrowthStage = useBuilder((s) => s.setGrowthStage);
|
||||
return (
|
||||
<div className="px-4 py-6 text-xs leading-relaxed text-zinc-600 space-y-4">
|
||||
{/* Global Growth Stage selector */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3">
|
||||
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">Crop Growth Stage</h3>
|
||||
<select
|
||||
value={growthStage}
|
||||
onChange={(e) => setGrowthStage(e.target.value as any)}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-xs text-zinc-200 outline-none focus:border-sky-600 cursor-pointer"
|
||||
>
|
||||
<option value="seedling">Seedling</option>
|
||||
<option value="vegetative">Vegetative</option>
|
||||
<option value="flowering">Flowering</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Select a part to edit its properties.
|
||||
<ul className="mt-4 space-y-1.5 text-[11px] text-zinc-700">
|
||||
<li><Kbd>Click</Kbd> select · drag to move</li>
|
||||
<li><Kbd>⇧Click</Kbd> add/remove from selection</li>
|
||||
<li><Kbd>⌘Click</Kbd> change color</li>
|
||||
<li>Drag <span className="text-sky-600">connector dots</span> to snap pipes</li>
|
||||
<li><Kbd>⇧Drag</Kbd> move vertically (3D view)</li>
|
||||
<li><Kbd>⌥Drag</Kbd> clone while dragging</li>
|
||||
<li><Kbd>Space</Kbd> play / pause water</li>
|
||||
<li><Kbd>R</Kbd> rotate 90° <Kbd>Q</Kbd>/<Kbd>E</Kbd> lower / raise</li>
|
||||
<li><Kbd>D</Kbd> duplicate <Kbd>⌫</Kbd> delete</li>
|
||||
<li><Kbd>⌘Z</Kbd> undo <Kbd>⇧⌘Z</Kbd> redo</li>
|
||||
<li><Kbd>Esc</Kbd> deselect / cancel</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[10px] text-zinc-300">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-selection panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MultiInspector({ ids }: { ids: string[] }) {
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const removeParts = useBuilder((s) => s.removeParts);
|
||||
const duplicateParts = useBuilder((s) => s.duplicateParts);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const id of ids) {
|
||||
const part = parts[id];
|
||||
if (!part) continue;
|
||||
const label = PART_DEFS[part.type].label;
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
|
||||
<Section title="Selection">
|
||||
<ul className="space-y-1">
|
||||
{[...counts.entries()].map(([label, n]) => (
|
||||
<li key={label} className="flex justify-between text-xs text-zinc-300">
|
||||
<span>{label}</span>
|
||||
<span className="font-mono text-zinc-500">× {n}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-zinc-600">
|
||||
Drag any selected part (or the gizmo) to move the whole group.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Color">
|
||||
<ColorSwatchRow ids={ids} />
|
||||
</Section>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => duplicateParts(ids)}
|
||||
title="Duplicate the whole selection (D)"
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeParts(ids)}
|
||||
title="Delete every selected part (⌫)"
|
||||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1.5 text-xs font-medium text-rose-300 hover:bg-rose-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pump quick-settings card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Pump param keys surfaced in the quick card (hidden from the generic list). */
|
||||
const PUMP_CARD_KEYS = new Set(['gph', 'maxHeadFt']);
|
||||
const AIR_COMPRESSOR_CARD_KEYS = new Set(['gph', 'frequency']);
|
||||
const RESERVOIR_CARD_KEYS = new Set(['ph', 'ec']);
|
||||
|
||||
function PumpCard({ part }: { part: PlacedPart }) {
|
||||
const setParam = useBuilder((s) => s.setParam);
|
||||
const sim = useSimulation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [flash, setFlash] = useState(false);
|
||||
|
||||
// Double-clicking a pump in the scene scrolls to + flashes this card.
|
||||
useEffect(() => {
|
||||
const onFlash = (e: Event) => {
|
||||
if ((e as CustomEvent<string>).detail !== part.id) return;
|
||||
cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
setFlash(true);
|
||||
setTimeout(() => setFlash(false), 1000);
|
||||
};
|
||||
window.addEventListener('hydro:flash-pump', onFlash);
|
||||
return () => window.removeEventListener('hydro:flash-pump', onFlash);
|
||||
}, [part.id]);
|
||||
|
||||
const def = PART_DEFS.pump;
|
||||
const on = (part.params.on ?? 1) > 0;
|
||||
const result = sim.pumps.find((p) => p.pumpId === part.id);
|
||||
const gph = part.params.gph ?? def.defaults.gph;
|
||||
const maxHead = part.params.maxHeadFt ?? def.defaults.maxHeadFt;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`rounded-lg border p-3 transition-all duration-300 ${
|
||||
flash
|
||||
? 'border-sky-400 bg-sky-950/60 ring-2 ring-sky-400/60'
|
||||
: 'border-sky-900/70 bg-sky-950/30'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Pump</span>
|
||||
{/* Big power toggle */}
|
||||
<button
|
||||
onClick={() => setParam(part.id, 'on', on ? 0 : 1)}
|
||||
title={on ? 'Turn pump off' : 'Turn pump on'}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
on ? 'bg-emerald-500' : 'bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[8px] font-black text-zinc-700 shadow transition-all ${
|
||||
on ? 'left-6' : 'left-0.5'
|
||||
}`}
|
||||
>
|
||||
{on ? 'I' : 'O'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live delivered flow */}
|
||||
<div className="mb-2 rounded-md bg-zinc-900/70 px-2.5 py-1.5">
|
||||
<div className="text-lg font-bold tabular-nums text-sky-300">
|
||||
{result && on ? result.gph.toFixed(0) : '0'}
|
||||
<span className="ml-1 text-[10px] font-medium text-zinc-500">GPH delivered</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500 tabular-nums">
|
||||
{result
|
||||
? `head ${result.headFt.toFixed(1)} / ${result.maxHeadFt} ft`
|
||||
: 'not connected to a reservoir'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickSlider
|
||||
label="Rated flow"
|
||||
unit="GPH"
|
||||
value={gph}
|
||||
meta={def.params.gph}
|
||||
onChange={(v) => setParam(part.id, 'gph', v)}
|
||||
/>
|
||||
<QuickSlider
|
||||
label="Max head"
|
||||
unit="ft"
|
||||
value={maxHead}
|
||||
meta={def.params.maxHeadFt}
|
||||
onChange={(v) => setParam(part.id, 'maxHeadFt', v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AirCompressorCard({ part }: { part: PlacedPart }) {
|
||||
const setParam = useBuilder((s) => s.setParam);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [flash, setFlash] = useState(false);
|
||||
|
||||
// Double-clicking an air compressor in the scene scrolls to + flashes this card.
|
||||
useEffect(() => {
|
||||
const onFlash = (e: Event) => {
|
||||
if ((e as CustomEvent<string>).detail !== part.id) return;
|
||||
cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
setFlash(true);
|
||||
setTimeout(() => setFlash(false), 1000);
|
||||
};
|
||||
window.addEventListener('hydro:flash-airCompressor', onFlash);
|
||||
return () => window.removeEventListener('hydro:flash-airCompressor', onFlash);
|
||||
}, [part.id]);
|
||||
|
||||
const def = PART_DEFS.airCompressor;
|
||||
const on = (part.params.on ?? 1) > 0;
|
||||
const gph = part.params.gph ?? def.defaults.gph;
|
||||
const frequency = part.params.frequency ?? def.defaults.frequency;
|
||||
|
||||
// Find nearest reservoir
|
||||
const reservoir = useMemo(() => {
|
||||
let nearest: PlacedPart | null = null;
|
||||
let minDist = Infinity;
|
||||
const pos = part.position;
|
||||
for (const p of Object.values(parts)) {
|
||||
if (p.type === 'reservoir') {
|
||||
const d = Math.hypot(p.position[0] - pos[0], p.position[1] - pos[1], p.position[2] - pos[2]);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearest = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}, [parts, part.position]);
|
||||
|
||||
// Compute stats if we have a reservoir
|
||||
const stats = useMemo(() => {
|
||||
if (!reservoir) return null;
|
||||
|
||||
// Find all air compressors targeting this reservoir
|
||||
let totalAerationGph = 0;
|
||||
for (const p of Object.values(parts)) {
|
||||
if (p.type === 'airCompressor' && (p.params.on ?? 1) > 0) {
|
||||
// Find nearest reservoir for this compressor
|
||||
let compNearest: PlacedPart | null = null;
|
||||
let cMinDist = Infinity;
|
||||
for (const res of Object.values(parts)) {
|
||||
if (res.type === 'reservoir') {
|
||||
const d = Math.hypot(res.position[0] - p.position[0], res.position[1] - p.position[1], res.position[2] - p.position[2]);
|
||||
if (d < cMinDist) {
|
||||
cMinDist = d;
|
||||
compNearest = res;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (compNearest && compNearest.id === reservoir.id) {
|
||||
totalAerationGph += p.params.gph ?? 120;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const w = reservoir.params.width ?? 2;
|
||||
const d = reservoir.params.depth ?? 1.4;
|
||||
const h = reservoir.params.height ?? 1;
|
||||
const level = reservoir.params.level ?? 70;
|
||||
const volGal = w * d * h * (level / 100) * 7.48;
|
||||
|
||||
// TDO Calculation
|
||||
let tdo = 5.5;
|
||||
if (totalAerationGph > 0 && volGal > 0) {
|
||||
const ratio = totalAerationGph / volGal;
|
||||
tdo = 5.5 + 3.7 * (1 - Math.exp(-ratio * 2.0));
|
||||
}
|
||||
|
||||
return {
|
||||
volGal,
|
||||
totalAerationGph,
|
||||
tdo,
|
||||
};
|
||||
}, [parts, reservoir]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`rounded-lg border p-3 transition-all duration-300 ${
|
||||
flash
|
||||
? 'border-sky-400 bg-sky-950/60 ring-2 ring-sky-400/60'
|
||||
: 'border-sky-900/70 bg-sky-950/30'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Air Compressor</span>
|
||||
<button
|
||||
onClick={() => setParam(part.id, 'on', on ? 0 : 1)}
|
||||
title={on ? 'Turn compressor off' : 'Turn compressor on'}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
on ? 'bg-emerald-500' : 'bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[8px] font-black text-zinc-700 shadow transition-all ${
|
||||
on ? 'left-6' : 'left-0.5'
|
||||
}`}
|
||||
>
|
||||
{on ? 'I' : 'O'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Target and Live Dissolved Oxygen */}
|
||||
<div className="mb-3 rounded-md bg-zinc-900/70 px-2.5 py-1.5 space-y-1">
|
||||
<div className="text-[10px] font-semibold text-zinc-400">Target Reservoir:</div>
|
||||
{reservoir ? (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-zinc-200">
|
||||
<span className="font-medium">{reservoir.label || `Reservoir #${reservoir.id}`}</span>
|
||||
<span className="text-[10px] font-mono text-zinc-500">
|
||||
{Math.hypot(reservoir.position[0] - part.position[0], reservoir.position[2] - part.position[2]).toFixed(1)} ft away
|
||||
</span>
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-800/60 space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-zinc-500">Dissolved O₂:</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${
|
||||
stats.tdo >= 8.0
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||
: stats.tdo >= 7.0
|
||||
? 'bg-sky-500/10 text-sky-400 border border-sky-500/20'
|
||||
: 'bg-amber-500/10 text-amber-400 border border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
{stats.tdo.toFixed(1)} mg/L
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[9px] text-zinc-500">
|
||||
<span>Tank Volume:</span>
|
||||
<span className="font-mono text-zinc-300">{stats.volGal.toFixed(1)} Gal</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[9px] text-zinc-500">
|
||||
<span>Total Aeration:</span>
|
||||
<span className="font-mono text-zinc-300">{stats.totalAerationGph} GPH</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-amber-400/80 font-medium">No reservoir in scene!</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<QuickSlider
|
||||
label="Air Flow"
|
||||
unit="GPH"
|
||||
value={gph}
|
||||
meta={def.params.gph}
|
||||
onChange={(v) => setParam(part.id, 'gph', v)}
|
||||
/>
|
||||
<QuickSlider
|
||||
label="Bubble Frequency"
|
||||
unit="Hz"
|
||||
value={frequency}
|
||||
meta={def.params.frequency}
|
||||
onChange={(v) => setParam(part.id, 'frequency', v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowTentCard({ part }: { part: PlacedPart }) {
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const metrics = useMemo(() => {
|
||||
const allMetrics = computeRoomMetrics(parts);
|
||||
return allMetrics.find((m) => m.roomId === part.id);
|
||||
}, [parts, part.id]);
|
||||
|
||||
if (!metrics) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-sky-900/50 bg-sky-950/20 p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Room Simulation</span>
|
||||
<span className="font-mono text-xs text-sky-300">{metrics.volumeFt3.toFixed(0)} ft³</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* VPD */}
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">VPD</div>
|
||||
<div className="mt-0.5 text-xs font-bold text-emerald-400">
|
||||
{metrics.roomVPD.toFixed(2)}
|
||||
<span className="text-[8px] font-normal text-zinc-500 ml-0.5">kPa</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DLI */}
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">DLI</div>
|
||||
<div className="mt-0.5 text-xs font-bold text-amber-400">
|
||||
{metrics.roomDLI.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CO2 */}
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">CO₂</div>
|
||||
<div className="mt-0.5 text-xs font-bold text-sky-400">
|
||||
{metrics.roomCo2}
|
||||
<span className="text-[8px] font-normal text-zinc-500 ml-0.5">PPM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-zinc-400 space-y-1 pt-1.5 border-t border-zinc-800/60 font-mono">
|
||||
<div className="flex justify-between">
|
||||
<span>Est. Temp:</span>
|
||||
<span className="text-zinc-200">{metrics.roomTempF.toFixed(1)}°F</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Est. RH:</span>
|
||||
<span className="text-zinc-200">{metrics.roomRH}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Room PPFD:</span>
|
||||
<span className="text-zinc-200">{metrics.roomPPFD.toFixed(0)} μmol/m²/s</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>AC Sizing:</span>
|
||||
<span className="text-zinc-300 font-semibold">{Math.round(metrics.acSizingBtuHr)} BTU/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Dehumidifier:</span>
|
||||
<span className="text-zinc-300 font-semibold">{metrics.dehumidifierPintsDay.toFixed(1)} Pints/day</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Sensible Heat:</span>
|
||||
<span className="text-zinc-500">{Math.round(metrics.sensibleHeatGainsBtuHr)} BTU/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Latent Cooling:</span>
|
||||
<span className="text-emerald-500/80">{Math.round(metrics.latentCoolingBtuHr)} BTU/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReservoirCard({ part }: { part: PlacedPart }) {
|
||||
const setParam = useBuilder((s) => s.setParam);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const growthStage = useBuilder((s) => s.growthStage);
|
||||
const sim = useSimulation();
|
||||
const stats = useMemo(() => computeReservoirWaterStats(part, parts), [part, parts]);
|
||||
const runtime = sim.reservoirRuntime[part.id];
|
||||
|
||||
const { ecLow, ecHigh } = useMemo(() => {
|
||||
if (growthStage === 'seedling') return { ecLow: 0.4, ecHigh: 0.8 };
|
||||
if (growthStage === 'flowering') return { ecLow: 1.5, ecHigh: 2.2 };
|
||||
return { ecLow: 1.0, ecHigh: 1.6 }; // vegetative
|
||||
}, [growthStage]);
|
||||
|
||||
const isEcAlert = stats.ec < ecLow || stats.ec > ecHigh;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-sky-900/50 bg-sky-950/20 p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Reservoir Health</span>
|
||||
<span className={`font-mono text-xs ${runtime?.empty ? 'text-rose-300' : 'text-sky-300'}`}>
|
||||
{(runtime?.gallonsCurrent ?? stats.volGal).toFixed(1)} Gal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">Runtime</div>
|
||||
<div className={`mt-0.5 text-xs font-bold ${runtime?.empty ? 'text-rose-400' : runtime?.minutesRemaining !== null ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{runtime
|
||||
? runtime.minutesRemaining === null
|
||||
? 'Stable'
|
||||
: runtime.empty
|
||||
? 'Dry'
|
||||
: `${runtime.minutesRemaining.toFixed(1)} min`
|
||||
: 'Stable'}
|
||||
</div>
|
||||
<div className="text-[8px] text-zinc-600">
|
||||
{runtime?.minutesRemaining === null
|
||||
? 'No net draw'
|
||||
: runtime?.empty
|
||||
? 'Source exhausted'
|
||||
: `${runtime.netOutflowGph.toFixed(1)} GPH net draw`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">Volume</div>
|
||||
<div className="mt-0.5 text-xs font-bold text-sky-300">
|
||||
{runtime
|
||||
? `${runtime.gallonsCurrent.toFixed(1)} / ${runtime.gallonsCapacity.toFixed(1)}`
|
||||
: `${stats.volGal.toFixed(1)} / ${stats.volGal.toFixed(1)}`}
|
||||
</div>
|
||||
<div className="text-[8px] text-zinc-600">Gallons</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Water Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* Temp */}
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">Temp</div>
|
||||
<div
|
||||
className={`mt-0.5 text-xs font-bold ${
|
||||
stats.tempF > 73.0 ? 'text-amber-400' : 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{stats.tempF.toFixed(1)}°F
|
||||
</div>
|
||||
<div className="text-[8px] text-zinc-600">
|
||||
{stats.tempF > 73.0 ? 'Too Warm' : 'Optimal'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* pH */}
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">pH</div>
|
||||
<div
|
||||
className={`mt-0.5 text-xs font-bold ${
|
||||
stats.ph < 5.5 || stats.ph > 6.5 ? 'text-amber-400' : 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{stats.ph.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-[8px] text-zinc-600">
|
||||
{stats.ph < 5.5 || stats.ph > 6.5 ? 'Lockout' : 'Optimal'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EC */}
|
||||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||||
<div className="text-[9px] text-zinc-500 font-bold uppercase">EC</div>
|
||||
<div
|
||||
className={`mt-0.5 text-xs font-bold ${
|
||||
isEcAlert ? 'text-amber-400' : 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{stats.ec.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-[8px] text-zinc-600">
|
||||
{isEcAlert ? 'Alert' : 'Optimal'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickSlider
|
||||
label="Adjust pH"
|
||||
unit=""
|
||||
value={part.params.ph ?? 6.0}
|
||||
meta={PART_DEFS.reservoir.params.ph}
|
||||
onChange={(v) => setParam(part.id, 'ph', v)}
|
||||
/>
|
||||
<QuickSlider
|
||||
label="Adjust EC"
|
||||
unit="mS/cm"
|
||||
value={part.params.ec ?? 1.2}
|
||||
meta={PART_DEFS.reservoir.params.ec}
|
||||
onChange={(v) => setParam(part.id, 'ec', v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickSlider({
|
||||
label,
|
||||
unit,
|
||||
value,
|
||||
meta,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
meta: { min: number; max: number; step: number };
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<div className="mb-0.5 flex justify-between text-[11px]">
|
||||
<span className="text-zinc-400">{label}</span>
|
||||
<span className="font-mono text-sky-400">
|
||||
{value} {unit}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={meta.min}
|
||||
max={meta.max}
|
||||
step={meta.step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
className="w-full accent-sky-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-part inspector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PartInspector({ partId }: { partId: string }) {
|
||||
const part = useBuilder((s) => s.parts[partId]);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const updatePart = useBuilder((s) => s.updatePart);
|
||||
const setParam = useBuilder((s) => s.setParam);
|
||||
const removeParts = useBuilder((s) => s.removeParts);
|
||||
const duplicateParts = useBuilder((s) => s.duplicateParts);
|
||||
const rotateParts = useBuilder((s) => s.rotateParts);
|
||||
const pushHistory = useBuilder((s) => s.pushHistory);
|
||||
const sim = useSimulation();
|
||||
|
||||
if (!part) return null;
|
||||
const def = PART_DEFS[part.type];
|
||||
const netPotStatus = part.type === 'netPot' ? sim.netPots.find((p) => p.partId === partId) : undefined;
|
||||
const sourcePart = netPotStatus ? parts[netPotStatus.sourcePartId] : undefined;
|
||||
|
||||
const setPos = (axis: number, v: number) => {
|
||||
pushHistory(`pos:${partId}:${axis}`);
|
||||
const position = [...part.position] as Vec3;
|
||||
position[axis] = axis === 1 ? Math.max(0, v) : v;
|
||||
updatePart(partId, { position });
|
||||
};
|
||||
|
||||
const setRot = (axis: number, degVal: number) => {
|
||||
pushHistory(`rot:${partId}:${axis}`);
|
||||
const rotation = [...part.rotation] as Vec3;
|
||||
rotation[axis] = deg2rad(degVal);
|
||||
updatePart(partId, { rotation });
|
||||
};
|
||||
|
||||
const paramEntries = Object.entries(def.params).filter(
|
||||
([key]) =>
|
||||
(part.type !== 'pump' || !PUMP_CARD_KEYS.has(key)) &&
|
||||
(part.type !== 'airCompressor' || !AIR_COMPRESSOR_CARD_KEYS.has(key)) &&
|
||||
(part.type !== 'reservoir' || !RESERVOIR_CARD_KEYS.has(key)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: part.color ?? def.color }}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-zinc-200">{def.label}</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-zinc-600">#{part.id}</span>
|
||||
</div>
|
||||
<input
|
||||
value={part.label ?? ''}
|
||||
placeholder="Name this part…"
|
||||
onChange={(e) => updatePart(partId, { label: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-xs text-zinc-200 placeholder-zinc-600 outline-none focus:border-sky-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{part.type === 'pump' && <PumpCard part={part} />}
|
||||
{part.type === 'airCompressor' && <AirCompressorCard part={part} />}
|
||||
{part.type === 'reservoir' && <ReservoirCard part={part} />}
|
||||
{part.type === 'growTent' && <GrowTentCard part={part} />}
|
||||
{part.type === 'netPot' && netPotStatus && (
|
||||
<Section title="Hydraulics">
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2.5 ${
|
||||
netPotStatus.overflowing
|
||||
? 'border-rose-900/70 bg-rose-950/40'
|
||||
: 'border-amber-900/60 bg-amber-950/30'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`text-xs font-semibold ${
|
||||
netPotStatus.overflowing ? 'text-rose-300' : 'text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{netPotStatus.overflowing ? 'Pot overflow active' : 'Pot near overflow'}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
|
||||
Water under this pot is coming from {sourcePart ? PART_DEFS[sourcePart.type].label : 'the flooded line'} at{' '}
|
||||
{netPotStatus.inflowGph.toFixed(0)} GPH. Water height is {netPotStatus.waterHeightFt.toFixed(2)} ft and the rim is {netPotStatus.rimHeightFt.toFixed(2)} ft.
|
||||
</p>
|
||||
<p className="mt-1.5 rounded-md bg-zinc-900/80 px-2 py-1 text-[11px] leading-snug text-zinc-300">
|
||||
<span className="font-semibold text-sky-400">Fix: </span>
|
||||
Add drainage or move the pot off the backed-up run.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Position (ft)">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(['X', 'Y', 'Z'] as const).map((axis, i) => (
|
||||
<NumberField
|
||||
key={axis}
|
||||
label={axis}
|
||||
value={part.position[i]}
|
||||
step={0.25}
|
||||
onChange={(v) => setPos(i, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Rotation (°)">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(['X', 'Y', 'Z'] as const).map((axis, i) => (
|
||||
<NumberField
|
||||
key={axis}
|
||||
label={axis}
|
||||
value={rad2deg(part.rotation[i])}
|
||||
step={15}
|
||||
onChange={(v) => setRot(i, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-1.5">
|
||||
<MiniBtn onClick={() => rotateParts([partId], 1, Math.PI / 2)}>⟳ 90° Y</MiniBtn>
|
||||
<MiniBtn onClick={() => rotateParts([partId], 2, Math.PI / 2)}>⟳ 90° Z</MiniBtn>
|
||||
<MiniBtn onClick={() => updatePart(partId, { rotation: [0, 0, 0] })}>Reset</MiniBtn>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{paramEntries.length > 0 && (
|
||||
<Section title="Parameters">
|
||||
<div className="space-y-2.5">
|
||||
{paramEntries.map(([key, meta]) => (
|
||||
<div key={key}>
|
||||
<div className="mb-0.5 flex justify-between text-[11px]">
|
||||
<span className="text-zinc-400">{meta.label}</span>
|
||||
<span className="font-mono text-sky-400">
|
||||
{part.params[key] ?? def.defaults[key]}
|
||||
{meta.unit ? ` ${meta.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{key === 'diameter' || key === 'diameterA' || key === 'diameterB' ? (
|
||||
<select
|
||||
value={String(snapPipeSize(part.params[key] ?? def.defaults[key]))}
|
||||
onChange={(e) => setParam(partId, key, parseFloat(e.target.value))}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-[11px] text-zinc-200 outline-none focus:border-sky-600"
|
||||
>
|
||||
{STANDARD_PIPE_SIZES.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size} in
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="range"
|
||||
min={meta.min}
|
||||
max={meta.max}
|
||||
step={meta.step}
|
||||
value={part.params[key] ?? def.defaults[key]}
|
||||
onChange={(e) => setParam(partId, key, parseFloat(e.target.value))}
|
||||
className="w-full accent-sky-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Color">
|
||||
<ColorSwatchRow ids={[partId]} />
|
||||
</Section>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => duplicateParts([partId])}
|
||||
title="Duplicate this part (D)"
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeParts([partId])}
|
||||
title="Delete this part (⌫)"
|
||||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1.5 text-xs font-medium text-rose-300 hover:bg-rose-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
step,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
step: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-1.5">
|
||||
<span className="text-[10px] font-bold text-zinc-600">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(value.toFixed(2))}
|
||||
step={step}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (!Number.isNaN(v)) onChange(v);
|
||||
}}
|
||||
className="w-full bg-transparent py-1.5 text-right font-mono text-[11px] text-zinc-200 outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBtn({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex-1 rounded border border-zinc-800 bg-zinc-900 px-1.5 py-1 text-[10px] text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
996
src/components/SceneCanvas.tsx
Normal file
996
src/components/SceneCanvas.tsx
Normal file
@@ -0,0 +1,996 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
||||
import type { ThreeEvent } from '@react-three/fiber';
|
||||
import {
|
||||
Grid,
|
||||
Html,
|
||||
Line,
|
||||
OrbitControls,
|
||||
OrthographicCamera,
|
||||
PerspectiveCamera,
|
||||
GizmoHelper,
|
||||
GizmoViewport,
|
||||
} from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import type { PartType, PlacedPart, Vec3, ViewMode } from '../types';
|
||||
import { snapValue, useBuilder } from '../store/builderStore';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
import { useSimulation } from '../simulation/flowSimulator';
|
||||
import { PartMesh } from './PartMesh';
|
||||
import { FlowArrows } from './FlowArrows';
|
||||
import { ConnectorHandles } from './ConnectorHandles';
|
||||
import { SelectionGizmo } from './SelectionGizmo';
|
||||
import { dragCtx } from '../utils/dragState';
|
||||
import {
|
||||
findNearestOpenConnector,
|
||||
findNearestPipeTap,
|
||||
getWorldConnectors,
|
||||
} from '../utils/connectors';
|
||||
import { AirCompressorVisuals } from './waterVisuals';
|
||||
|
||||
const HALF_PI = Math.PI / 2;
|
||||
|
||||
/** Default placement height per part type (pipes float at port height). */
|
||||
function placementY(type: PartType): number {
|
||||
switch (type) {
|
||||
case 'pipe':
|
||||
case 'elbow':
|
||||
case 'tee':
|
||||
case 'valve':
|
||||
case 'emitter':
|
||||
return 0.25;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed so the HTML5 drag-and-drop handler in App can place parts. */
|
||||
export const sceneRefs: { camera: THREE.Camera | null; el: HTMLElement | null } = {
|
||||
camera: null,
|
||||
el: null,
|
||||
};
|
||||
|
||||
/** Raycast client coords onto the ground plane (used by sidebar drag-drop). */
|
||||
export function clientToGround(clientX: number, clientY: number): Vec3 | null {
|
||||
const { camera, el } = sceneRefs;
|
||||
if (!camera || !el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ndc = new THREE.Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.setFromCamera(ndc, camera);
|
||||
const hit = new THREE.Vector3();
|
||||
if (ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), hit)) {
|
||||
return [hit.x, hit.y, hit.z];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function CaptureRefs() {
|
||||
const camera = useThree((s) => s.camera);
|
||||
const el = useThree((s) => s.gl.domElement);
|
||||
const scene = useThree((s) => s.scene);
|
||||
const setScene = useBuilder((s) => s.setScene);
|
||||
|
||||
useEffect(() => {
|
||||
sceneRefs.camera = camera;
|
||||
sceneRefs.el = el;
|
||||
}, [camera, el]);
|
||||
|
||||
useEffect(() => {
|
||||
setScene(scene);
|
||||
return () => setScene(null);
|
||||
}, [scene, setScene]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VIEWS: Record<ViewMode, { pos: Vec3; up: Vec3; target: Vec3 }> = {
|
||||
orbit: { pos: [9, 7, 11], up: [0, 1, 0], target: [0, 1, 0] },
|
||||
top: { pos: [0, 60, 0], up: [0, 0, -1], target: [0, 0, 0] },
|
||||
front: { pos: [0, 2, 60], up: [0, 1, 0], target: [0, 2, 0] },
|
||||
side: { pos: [60, 2, 0], up: [0, 1, 0], target: [0, 2, 0] },
|
||||
};
|
||||
|
||||
function sceneStyle(renderMode: 'builder' | 'xray' | 'water') {
|
||||
switch (renderMode) {
|
||||
case 'xray':
|
||||
return {
|
||||
background: '#071019',
|
||||
fogFar: 180,
|
||||
ambient: 0.72,
|
||||
hemi: 0.82,
|
||||
dir: 1.15,
|
||||
gridCell: '#15384a',
|
||||
gridSection: '#1f536d',
|
||||
};
|
||||
case 'water':
|
||||
return {
|
||||
background: '#04131c',
|
||||
fogFar: 210,
|
||||
ambient: 0.3,
|
||||
hemi: 0.42,
|
||||
dir: 0.9,
|
||||
gridCell: '#0c2a3b',
|
||||
gridSection: '#134764',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
background: '#0a0e13',
|
||||
fogFar: 140,
|
||||
ambient: 0.45,
|
||||
hemi: 0.5,
|
||||
dir: 1.4,
|
||||
gridCell: '#1b2531',
|
||||
gridSection: '#2c3d52',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function CameraRig() {
|
||||
const viewMode = useBuilder((s) => s.viewMode);
|
||||
const camera = useThree((s) => s.camera);
|
||||
const controls = useThree((s) => s.controls) as unknown as {
|
||||
target: THREE.Vector3;
|
||||
update: () => void;
|
||||
} | null;
|
||||
|
||||
useEffect(() => {
|
||||
const v = VIEWS[viewMode];
|
||||
camera.position.set(...v.pos);
|
||||
camera.up.set(...v.up);
|
||||
camera.lookAt(...v.target);
|
||||
if (controls) {
|
||||
controls.target.set(...v.target);
|
||||
controls.update();
|
||||
}
|
||||
}, [viewMode, camera, controls]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ground interaction: placement ghost, measuring, deselection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function InteractionLayer() {
|
||||
const placingType = useBuilder((s) => s.placingType);
|
||||
const tool = useBuilder((s) => s.tool);
|
||||
const snapToGrid = useBuilder((s) => s.snapToGrid);
|
||||
const gridSize = useBuilder((s) => s.gridSize);
|
||||
const measurePoints = useBuilder((s) => s.measurePoints);
|
||||
const pipeRunPoints = useBuilder((s) => s.pipeRunPoints);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const [ghostPos, setGhostPos] = useState<Vec3 | null>(null);
|
||||
const [pipeRunHover, setPipeRunHover] = useState<Vec3 | null>(null);
|
||||
|
||||
const roomParts = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
|
||||
}, [parts, activeRoomId]);
|
||||
|
||||
const snap = (v: number) => (snapToGrid ? snapValue(v, gridSize) : v);
|
||||
|
||||
const toPos = (e: ThreeEvent<PointerEvent | MouseEvent>): Vec3 => [
|
||||
snap(e.point.x),
|
||||
placingType ? placementY(placingType) : 0,
|
||||
snap(e.point.z),
|
||||
];
|
||||
|
||||
const onMove = (e: ThreeEvent<PointerEvent>) => {
|
||||
if (placingType) setGhostPos(toPos(e));
|
||||
if (tool === 'pipeRun') {
|
||||
const pos = toPos(e);
|
||||
const target = findNearestOpenConnector(pos, roomParts, new Set(), SNAP_PIPE_RUN_DIST);
|
||||
if (target) {
|
||||
useBuilder.getState().setSnapTargetKey(target.key);
|
||||
setPipeRunHover(target.pos);
|
||||
} else {
|
||||
const tap = findNearestPipeTap(pos, roomParts, SNAP_PIPE_RUN_DIST);
|
||||
useBuilder.getState().setSnapTargetKey(null);
|
||||
setPipeRunHover(tap ? tap.branchPos : pos);
|
||||
}
|
||||
} else if (useBuilder.getState().snapTargetKey) {
|
||||
useBuilder.getState().setSnapTargetKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: ThreeEvent<MouseEvent>) => {
|
||||
if (e.delta > 6) return; // it was a camera drag, not a click
|
||||
const s = useBuilder.getState();
|
||||
if (placingType) {
|
||||
s.addPart(placingType, toPos(e));
|
||||
// Hold Shift to stamp multiple copies.
|
||||
if (!e.nativeEvent.shiftKey) s.setPlacingType(null);
|
||||
return;
|
||||
}
|
||||
if (tool === 'measure') {
|
||||
s.addMeasurePoint([e.point.x, Math.max(0, e.point.y), e.point.z]);
|
||||
return;
|
||||
}
|
||||
if (tool === 'pipeRun') {
|
||||
const pos = toPos(e);
|
||||
const target = findNearestOpenConnector(pos, roomParts, new Set(), SNAP_PIPE_RUN_DIST);
|
||||
if (target) {
|
||||
s.setSnapTargetKey(target.key);
|
||||
s.extendPipeRun(
|
||||
{ pos: target.pos, connectorKey: target.key },
|
||||
e.nativeEvent.shiftKey,
|
||||
);
|
||||
} else {
|
||||
const tap = findNearestPipeTap(pos, roomParts, SNAP_PIPE_RUN_DIST);
|
||||
s.setSnapTargetKey(null);
|
||||
s.extendPipeRun(
|
||||
tap
|
||||
? {
|
||||
pos: tap.pos,
|
||||
tapPipeId: tap.partId,
|
||||
tapRotationY: tap.rotationY,
|
||||
tapDiameter: tap.diameter,
|
||||
}
|
||||
: { pos },
|
||||
e.nativeEvent.shiftKey,
|
||||
);
|
||||
}
|
||||
if (!e.nativeEvent.shiftKey) s.setSnapTargetKey(null);
|
||||
return;
|
||||
}
|
||||
// Only deselect when the ground itself was the nearest thing clicked —
|
||||
// clicks that land on a part/handle first must not clear the selection.
|
||||
if (e.intersections[0]?.eventObject !== e.eventObject) return;
|
||||
// Shift-clicking empty ground keeps the multi-selection intact.
|
||||
if (!e.nativeEvent.shiftKey) s.setSelected(null);
|
||||
};
|
||||
|
||||
const ghostPart: PlacedPart | null = useMemo(() => {
|
||||
if (!placingType || !ghostPos) return null;
|
||||
return {
|
||||
id: '__ghost__',
|
||||
type: placingType,
|
||||
position: ghostPos,
|
||||
rotation: [0, 0, 0],
|
||||
params: { ...PART_DEFS[placingType].defaults },
|
||||
};
|
||||
}, [placingType, ghostPos]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh
|
||||
rotation={[-HALF_PI, 0, 0]}
|
||||
position={[0, -0.02, 0]}
|
||||
onPointerMove={onMove}
|
||||
onClick={onClick}
|
||||
>
|
||||
<planeGeometry args={[600, 600]} />
|
||||
<meshBasicMaterial visible={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
{/* Soft shadow catcher */}
|
||||
<mesh rotation={[-HALF_PI, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
|
||||
<planeGeometry args={[300, 300]} />
|
||||
<shadowMaterial opacity={0.35} />
|
||||
</mesh>
|
||||
{ghostPart && <PartMesh part={ghostPart} ghost />}
|
||||
{measurePoints.length > 0 && <MeasureViz points={measurePoints} />}
|
||||
{tool === 'pipeRun' && pipeRunPoints.length > 0 && pipeRunHover && (
|
||||
<PipeRunViz points={pipeRunPoints} hover={pipeRunHover} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PipeRunViz({
|
||||
points,
|
||||
hover,
|
||||
}: {
|
||||
points: { pos: Vec3; connectorKey?: string }[];
|
||||
hover: Vec3;
|
||||
}) {
|
||||
const start = points[points.length - 1].pos;
|
||||
const end = hover;
|
||||
const lineStart: Vec3 = [start[0], 0.28, start[2]];
|
||||
const lineEnd: Vec3 = [end[0], 0.28, end[2]];
|
||||
const mid: Vec3 = [(lineStart[0] + lineEnd[0]) / 2, 0.55, (lineStart[2] + lineEnd[2]) / 2];
|
||||
const dist = Math.hypot(end[0] - start[0], end[2] - start[2]);
|
||||
const clamped = clampPipeLength(dist);
|
||||
return (
|
||||
<group>
|
||||
{points.length > 1 && (
|
||||
<Line
|
||||
points={points.map((p) => [p.pos[0], 0.26, p.pos[2]] as Vec3)}
|
||||
color="#0ea5e9"
|
||||
lineWidth={2}
|
||||
dashed
|
||||
dashScale={3}
|
||||
/>
|
||||
)}
|
||||
<Line points={[lineStart, lineEnd]} color="#38bdf8" lineWidth={2} dashed dashScale={4} />
|
||||
<Html position={mid} center distanceFactor={12}>
|
||||
<div className="rounded bg-sky-400 px-1.5 py-0.5 font-mono text-[11px] font-bold text-sky-950 whitespace-nowrap">
|
||||
{clamped.toFixed(2)} ft · Shift-click continues
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function MeasureViz({ points }: { points: Vec3[] }) {
|
||||
const [a, b] = points;
|
||||
const mid: Vec3 | null = b
|
||||
? [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 0.3, (a[2] + b[2]) / 2]
|
||||
: null;
|
||||
const dist = b ? Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) : 0;
|
||||
return (
|
||||
<group>
|
||||
{points.map((p, i) => (
|
||||
<mesh key={i} position={p}>
|
||||
<sphereGeometry args={[0.09, 12, 12]} />
|
||||
<meshBasicMaterial color="#fbbf24" toneMapped={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{b && (
|
||||
<>
|
||||
<Line points={[a, b]} color="#fbbf24" lineWidth={2} dashed dashScale={3} />
|
||||
<Html position={mid!} center distanceFactor={12}>
|
||||
<div className="rounded bg-amber-400 px-1.5 py-0.5 font-mono text-[11px] font-bold text-black whitespace-nowrap">
|
||||
{dist.toFixed(2)} ft
|
||||
</div>
|
||||
</Html>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag plane — moves the dragged part (and the rest of the selection,
|
||||
// rigidly), oriented per view. In orbit view, holding Shift switches to a
|
||||
// camera-facing plane so parts can be moved vertically. Connector-handle
|
||||
// drags additionally live-highlight + magnetically snap to mating connectors.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Within this distance (ft) a handle drag magnetically sticks to the target. */
|
||||
const MAGNET_DIST = 0.45;
|
||||
const SNAP_PIPE_RUN_DIST = 0.9;
|
||||
const MIN_PIPE_LENGTH = PART_DEFS.pipe.params.length.min;
|
||||
const MAX_PIPE_LENGTH = PART_DEFS.pipe.params.length.max;
|
||||
|
||||
function clampPipeLength(length: number) {
|
||||
return Math.min(MAX_PIPE_LENGTH, Math.max(MIN_PIPE_LENGTH, length));
|
||||
}
|
||||
|
||||
function stretchPipeToPoint(point: Vec3): { end: Vec3; position: Vec3; length: number } | null {
|
||||
const stretch = dragCtx.pipeStretch;
|
||||
if (!stretch) return null;
|
||||
const toPoint: Vec3 = [
|
||||
point[0] - stretch.anchorPos[0],
|
||||
point[1] - stretch.anchorPos[1],
|
||||
point[2] - stretch.anchorPos[2],
|
||||
];
|
||||
const projected =
|
||||
toPoint[0] * stretch.axis[0] + toPoint[1] * stretch.axis[1] + toPoint[2] * stretch.axis[2];
|
||||
const length = clampPipeLength(projected);
|
||||
const end: Vec3 = [
|
||||
stretch.anchorPos[0] + stretch.axis[0] * length,
|
||||
stretch.anchorPos[1] + stretch.axis[1] * length,
|
||||
stretch.anchorPos[2] + stretch.axis[2] * length,
|
||||
];
|
||||
return {
|
||||
end,
|
||||
position: [
|
||||
(stretch.anchorPos[0] + end[0]) / 2,
|
||||
(stretch.anchorPos[1] + end[1]) / 2,
|
||||
(stretch.anchorPos[2] + end[2]) / 2,
|
||||
],
|
||||
length,
|
||||
};
|
||||
}
|
||||
|
||||
function DragPlane() {
|
||||
const draggingId = useBuilder((s) => s.draggingId);
|
||||
const viewMode = useBuilder((s) => s.viewMode);
|
||||
const part = useBuilder((s) => (s.draggingId ? s.parts[s.draggingId] : undefined));
|
||||
const camera = useThree((s) => s.camera);
|
||||
const [shiftDown, setShiftDown] = useState(false);
|
||||
|
||||
// Track Shift (vertical-move modifier in orbit view) even before a drag starts.
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => e.key === 'Shift' && setShiftDown(true);
|
||||
const up = (e: KeyboardEvent) => e.key === 'Shift' && setShiftDown(false);
|
||||
window.addEventListener('keydown', down);
|
||||
window.addEventListener('keyup', up);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', down);
|
||||
window.removeEventListener('keyup', up);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Safety: end the drag even if pointerup happens off-canvas.
|
||||
useEffect(() => {
|
||||
if (!draggingId) return;
|
||||
const end = () => finishDrag(draggingId);
|
||||
window.addEventListener('pointerup', end);
|
||||
return () => window.removeEventListener('pointerup', end);
|
||||
}, [draggingId]);
|
||||
|
||||
if (!draggingId || !part) return null;
|
||||
|
||||
const freeMode = viewMode === 'orbit' && shiftDown;
|
||||
const planeKey = `${viewMode}:${freeMode ? 'free' : 'flat'}`;
|
||||
const group = dragCtx.groupIds.length ? dragCtx.groupIds : [draggingId];
|
||||
|
||||
const onMove = (e: ThreeEvent<PointerEvent>) => {
|
||||
const s = useBuilder.getState();
|
||||
const p = s.parts[draggingId];
|
||||
if (!p) return;
|
||||
if (!dragCtx.pushed) {
|
||||
s.pushHistory(`drag:${draggingId}`);
|
||||
dragCtx.pushed = true;
|
||||
}
|
||||
// The plane orientation changed mid-drag (Shift toggled): re-anchor so the
|
||||
// part doesn't jump under the new plane.
|
||||
if (dragCtx.planeKey && dragCtx.planeKey !== planeKey) {
|
||||
dragCtx.grabOffset = [
|
||||
e.point.x - p.position[0],
|
||||
e.point.y - p.position[1],
|
||||
e.point.z - p.position[2],
|
||||
];
|
||||
dragCtx.planeAnchor = [e.point.x, e.point.y, e.point.z];
|
||||
}
|
||||
dragCtx.planeKey = planeKey;
|
||||
|
||||
const snap = (v: number) => (s.snapToGrid ? snapValue(v, s.gridSize) : v);
|
||||
if (dragCtx.pipeStretch && dragCtx.connectorKey) {
|
||||
let handlePoint: Vec3;
|
||||
if (freeMode) {
|
||||
handlePoint = [snap(e.point.x), snap(e.point.y), snap(e.point.z)];
|
||||
} else if (viewMode === 'front') {
|
||||
handlePoint = [snap(e.point.x), snap(e.point.y), dragCtx.planeAnchor[2]];
|
||||
} else if (viewMode === 'side') {
|
||||
handlePoint = [dragCtx.planeAnchor[0], snap(e.point.y), snap(e.point.z)];
|
||||
} else {
|
||||
handlePoint = [snap(e.point.x), dragCtx.planeAnchor[1], snap(e.point.z)];
|
||||
}
|
||||
let stretch = stretchPipeToPoint(handlePoint);
|
||||
if (!stretch) return;
|
||||
const snapTarget = findNearestOpenConnector(
|
||||
stretch.end,
|
||||
Object.values(s.parts).filter((p) => p.roomId === s.activeRoomId),
|
||||
new Set([draggingId]),
|
||||
);
|
||||
s.setSnapTargetKey(snapTarget ? snapTarget.key : null);
|
||||
if (snapTarget && snapTarget.d < MAGNET_DIST) {
|
||||
stretch = stretchPipeToPoint(snapTarget.pos) ?? stretch;
|
||||
}
|
||||
s.setPipeStretch(draggingId, stretch.position, stretch.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const [ox, oy, oz] = dragCtx.grabOffset;
|
||||
const startP = dragCtx.startPositions[draggingId] ?? p.position;
|
||||
|
||||
let target: Vec3;
|
||||
if (freeMode) {
|
||||
target = [snap(e.point.x - ox), snap(e.point.y - oy), snap(e.point.z - oz)];
|
||||
} else if (viewMode === 'front') {
|
||||
target = [snap(e.point.x - ox), snap(e.point.y - oy), startP[2]];
|
||||
} else if (viewMode === 'side') {
|
||||
target = [startP[0], snap(e.point.y - oy), snap(e.point.z - oz)];
|
||||
} else {
|
||||
target = [snap(e.point.x - ox), startP[1], snap(e.point.z - oz)];
|
||||
}
|
||||
let delta: Vec3 = [target[0] - startP[0], target[1] - startP[1], target[2] - startP[2]];
|
||||
|
||||
// Handle drags: highlight the nearest open mating connector and stick to
|
||||
// it magnetically when close.
|
||||
if (dragCtx.mode === 'handle' && dragCtx.connectorKey) {
|
||||
const connectorId = dragCtx.connectorKey.split(':')[1];
|
||||
const moved: PlacedPart = {
|
||||
...p,
|
||||
position: [startP[0] + delta[0], startP[1] + delta[1], startP[2] + delta[2]],
|
||||
};
|
||||
const conn = getWorldConnectors(moved).find((c) => c.connectorId === connectorId);
|
||||
if (conn) {
|
||||
const snapTarget = findNearestOpenConnector(
|
||||
conn.pos,
|
||||
Object.values(s.parts).filter((part) => part.roomId === s.activeRoomId),
|
||||
new Set(group),
|
||||
);
|
||||
s.setSnapTargetKey(snapTarget ? snapTarget.key : null);
|
||||
if (snapTarget && snapTarget.d < MAGNET_DIST) {
|
||||
delta = [
|
||||
delta[0] + snapTarget.pos[0] - conn.pos[0],
|
||||
delta[1] + snapTarget.pos[1] - conn.pos[1],
|
||||
delta[2] + snapTarget.pos[2] - conn.pos[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the whole group above the floor.
|
||||
let minY = Infinity;
|
||||
for (const id of group) {
|
||||
const sp = dragCtx.startPositions[id];
|
||||
if (sp) minY = Math.min(minY, sp[1]);
|
||||
}
|
||||
if (Number.isFinite(minY)) delta[1] = Math.max(delta[1], -minY);
|
||||
|
||||
const updates: Record<string, Vec3> = {};
|
||||
for (const id of group) {
|
||||
const sp = dragCtx.startPositions[id];
|
||||
if (!sp) continue;
|
||||
updates[id] = [sp[0] + delta[0], sp[1] + delta[1], sp[2] + delta[2]];
|
||||
}
|
||||
s.setPositions(updates);
|
||||
};
|
||||
|
||||
const anchor = dragCtx.planeAnchor;
|
||||
const planeProps = {
|
||||
onPointerMove: onMove,
|
||||
onPointerUp: () => finishDrag(draggingId),
|
||||
};
|
||||
const planeContents = (
|
||||
<>
|
||||
<planeGeometry args={[600, 600]} />
|
||||
<meshBasicMaterial visible={false} side={THREE.DoubleSide} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (freeMode) {
|
||||
// Camera-facing plane through the grab point: free vertical + lateral moves.
|
||||
return (
|
||||
<mesh position={anchor} quaternion={camera.quaternion} {...planeProps}>
|
||||
{planeContents}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
const vertical = viewMode === 'front' || viewMode === 'side';
|
||||
return (
|
||||
<mesh
|
||||
position={
|
||||
vertical
|
||||
? viewMode === 'front'
|
||||
? [0, 0, anchor[2]]
|
||||
: [anchor[0], 0, 0]
|
||||
: [0, anchor[1], 0]
|
||||
}
|
||||
rotation={
|
||||
vertical ? (viewMode === 'side' ? [0, HALF_PI, 0] : [0, 0, 0]) : [-HALF_PI, 0, 0]
|
||||
}
|
||||
{...planeProps}
|
||||
>
|
||||
{planeContents}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function finishDrag(id: string) {
|
||||
const s = useBuilder.getState();
|
||||
if (s.draggingId !== id) return;
|
||||
const group = dragCtx.groupIds.length ? dragCtx.groupIds : [id];
|
||||
|
||||
// A plain click (no movement) on a member of a multi-selection collapses
|
||||
// the selection to just that part — dragging keeps the group together.
|
||||
const start = dragCtx.startPositions[id];
|
||||
const p = s.parts[id];
|
||||
const moved =
|
||||
!start || !p
|
||||
? false
|
||||
: Math.hypot(
|
||||
p.position[0] - start[0],
|
||||
p.position[1] - start[1],
|
||||
p.position[2] - start[2],
|
||||
) > 1e-6;
|
||||
if (dragCtx.mode === 'body' && !moved && group.length > 1) {
|
||||
s.setSelected(id);
|
||||
s.setSnapTargetKey(null);
|
||||
s.setDraggingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragCtx.mode === 'handle' && dragCtx.connectorKey && s.snapTargetKey) {
|
||||
// Snap the grabbed connector exactly onto the highlighted target.
|
||||
const p = s.parts[id];
|
||||
const targetPart = s.parts[s.snapTargetKey.split(':')[0]];
|
||||
if (p && targetPart) {
|
||||
const connectorId = dragCtx.connectorKey.split(':')[1];
|
||||
const conn = getWorldConnectors(p).find((c) => c.connectorId === connectorId);
|
||||
const target = getWorldConnectors(targetPart).find((c) => c.key === s.snapTargetKey);
|
||||
if (conn && target) {
|
||||
if (dragCtx.pipeStretch) {
|
||||
const stretch = stretchPipeToPoint(target.pos);
|
||||
if (stretch) s.setPipeStretch(id, stretch.position, stretch.length);
|
||||
} else {
|
||||
s.translateParts(group, [
|
||||
target.pos[0] - conn.pos[0],
|
||||
target.pos[1] - conn.pos[1],
|
||||
target.pos[2] - conn.pos[2],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (dragCtx.mode === 'body') {
|
||||
// Magnetic connector snapping on drop (vs. parts outside the group).
|
||||
s.snapGroupToConnectors(group, id);
|
||||
}
|
||||
s.setSnapTargetKey(null);
|
||||
s.setDraggingId(null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parts + simulation glue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PartMeshes() {
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const selectedIds = useBuilder((s) => s.selectedIds);
|
||||
const sim = useSimulation();
|
||||
|
||||
const roomParts = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
|
||||
}, [parts, activeRoomId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{roomParts.map((part) => (
|
||||
<PartMesh
|
||||
key={part.id}
|
||||
part={part}
|
||||
flow={sim.flows[part.id] ?? 0}
|
||||
selected={selectedIds.includes(part.id)}
|
||||
connectedKeys={sim.connectedKeys}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alignment guides — thin dashed lines between the dragged part and aligned
|
||||
// siblings (X or Z within 0.05 ft tolerance). Only the 20 nearest parts are
|
||||
// checked each frame to keep things fast.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALIGN_TOL = 0.05;
|
||||
const ALIGN_MAX_NEIGHBOURS = 20;
|
||||
const ALIGN_Y = 0.03; // just above the grid
|
||||
|
||||
function AlignmentGuides() {
|
||||
const draggingId = useBuilder((s) => s.draggingId);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
|
||||
const guides = useMemo(() => {
|
||||
if (!draggingId) return [];
|
||||
const dragged = parts[draggingId];
|
||||
if (!dragged) return [];
|
||||
|
||||
const [dx, , dz] = dragged.position;
|
||||
const others = Object.values(parts).filter((p) => p.id !== draggingId && p.roomId === activeRoomId);
|
||||
|
||||
// Sort by distance and take the nearest N.
|
||||
const nearest = others
|
||||
.map((p) => ({ p, d: Math.hypot(p.position[0] - dx, p.position[2] - dz) }))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, ALIGN_MAX_NEIGHBOURS)
|
||||
.map((e) => e.p);
|
||||
|
||||
const lines: { points: [Vec3, Vec3]; key: string }[] = [];
|
||||
|
||||
for (const other of nearest) {
|
||||
const [ox, , oz] = other.position;
|
||||
// X alignment
|
||||
if (Math.abs(ox - dx) < ALIGN_TOL) {
|
||||
const minZ = Math.min(dz, oz);
|
||||
const maxZ = Math.max(dz, oz);
|
||||
lines.push({
|
||||
points: [
|
||||
[ox, ALIGN_Y, minZ],
|
||||
[ox, ALIGN_Y, maxZ],
|
||||
],
|
||||
key: `x:${other.id}`,
|
||||
});
|
||||
}
|
||||
// Z alignment
|
||||
if (Math.abs(oz - dz) < ALIGN_TOL) {
|
||||
const minX = Math.min(dx, ox);
|
||||
const maxX = Math.max(dx, ox);
|
||||
lines.push({
|
||||
points: [
|
||||
[minX, ALIGN_Y, oz],
|
||||
[maxX, ALIGN_Y, oz],
|
||||
],
|
||||
key: `z:${other.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}, [draggingId, parts]);
|
||||
|
||||
if (guides.length === 0) return null;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{guides.map((g) => (
|
||||
<Line
|
||||
key={g.key}
|
||||
points={g.points}
|
||||
color="#22d3ee"
|
||||
lineWidth={1.5}
|
||||
dashed
|
||||
dashScale={6}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera focus — smoothly lerps the orbit controls target toward a store-
|
||||
// provided focusTarget, then clears it once arrived (~0.5s).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CameraFocus() {
|
||||
const focusTarget = useBuilder((s) => s.focusTarget);
|
||||
const controls = useThree((s) => s.controls) as unknown as {
|
||||
target: THREE.Vector3;
|
||||
update: () => void;
|
||||
} | null;
|
||||
const camera = useThree((s) => s.camera);
|
||||
const progressRef = useRef(0);
|
||||
const startTargetRef = useRef(new THREE.Vector3());
|
||||
const startCamRef = useRef(new THREE.Vector3());
|
||||
const goalRef = useRef<Vec3 | null>(null);
|
||||
|
||||
// When a new focusTarget arrives, snapshot the current target and start lerping.
|
||||
useEffect(() => {
|
||||
if (!focusTarget || !controls) return;
|
||||
startTargetRef.current.copy(controls.target);
|
||||
startCamRef.current.copy(camera.position);
|
||||
goalRef.current = focusTarget;
|
||||
progressRef.current = 0;
|
||||
}, [focusTarget, controls, camera]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const goal = goalRef.current;
|
||||
if (!goal || !controls) return;
|
||||
|
||||
progressRef.current = Math.min(1, progressRef.current + delta / 0.5);
|
||||
const t = progressRef.current;
|
||||
// Smooth-step easing
|
||||
const ease = t * t * (3 - 2 * t);
|
||||
|
||||
controls.target.lerpVectors(
|
||||
startTargetRef.current,
|
||||
new THREE.Vector3(...goal),
|
||||
ease,
|
||||
);
|
||||
|
||||
// Also shift the camera by the same delta so framing is preserved.
|
||||
const offset = camera.position.clone().sub(startCamRef.current);
|
||||
const targetDelta = new THREE.Vector3(...goal).sub(startTargetRef.current).multiplyScalar(ease);
|
||||
camera.position.copy(startCamRef.current).add(offset).add(targetDelta);
|
||||
|
||||
controls.update();
|
||||
|
||||
if (t >= 1) {
|
||||
goalRef.current = null;
|
||||
useBuilder.getState().setFocusTarget(null);
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera keyboard panning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function KeyboardNavigator() {
|
||||
const camera = useThree((s) => s.camera);
|
||||
const controls = useThree((s) => s.controls) as unknown as {
|
||||
target: THREE.Vector3;
|
||||
update: () => void;
|
||||
} | null;
|
||||
const activeKeys = useRef<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
activeKeys.current[e.key] = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
activeKeys.current[e.key] = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { passive: false });
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const d = Math.min(0.1, delta); // clamp to prevent jumps on frames skip
|
||||
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
|
||||
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
|
||||
|
||||
const moveVec = new THREE.Vector3();
|
||||
if (activeKeys.current['ArrowUp']) moveVec.add(up);
|
||||
if (activeKeys.current['ArrowDown']) moveVec.add(up.clone().negate());
|
||||
if (activeKeys.current['ArrowLeft']) moveVec.add(right.clone().negate());
|
||||
if (activeKeys.current['ArrowRight']) moveVec.add(right);
|
||||
|
||||
if (moveVec.lengthSq() > 0) {
|
||||
moveVec.normalize();
|
||||
const speed = 15; // ft/sec panning speed
|
||||
moveVec.multiplyScalar(speed * d);
|
||||
camera.position.add(moveVec);
|
||||
if (controls) {
|
||||
controls.target.add(moveVec);
|
||||
}
|
||||
}
|
||||
|
||||
// Always clamp coordinates to keep building grid within ±120 ft
|
||||
camera.position.x = Math.max(-120, Math.min(120, camera.position.x));
|
||||
camera.position.y = Math.max(-120, Math.min(120, camera.position.y));
|
||||
camera.position.z = Math.max(-120, Math.min(120, camera.position.z));
|
||||
|
||||
if (controls) {
|
||||
controls.target.x = Math.max(-120, Math.min(120, controls.target.x));
|
||||
controls.target.y = Math.max(-120, Math.min(120, controls.target.y));
|
||||
controls.target.z = Math.max(-120, Math.min(120, controls.target.z));
|
||||
controls.update();
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root canvas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
function RemoteCursors({ remoteCursors }: { remoteCursors: Record<string, any> }) {
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(remoteCursors).map(([userId, data]) => {
|
||||
if (data.activeRoomId !== activeRoomId) return null;
|
||||
return (
|
||||
<mesh key={userId} position={data.position}>
|
||||
<sphereGeometry args={[0.15, 16, 16]} />
|
||||
<meshBasicMaterial color={data.color} toneMapped={false} />
|
||||
<Html distanceFactor={15} center position={[0, 0.4, 0]}>
|
||||
<div
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap shadow-lg select-none pointer-events-none"
|
||||
style={{ backgroundColor: data.color }}
|
||||
>
|
||||
{data.userName.split('@')[0]}
|
||||
</div>
|
||||
</Html>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalCursorTracker() {
|
||||
const { raycaster } = useThree();
|
||||
|
||||
useFrame(() => {
|
||||
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.25);
|
||||
const target = new THREE.Vector3();
|
||||
if (raycaster.ray.intersectPlane(plane, target)) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('collab:cursor', {
|
||||
detail: { x: target.x, y: target.y, z: target.z },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SceneCanvas({ remoteCursors = {} }: { remoteCursors?: Record<string, any> }) {
|
||||
const viewMode = useBuilder((s) => s.viewMode);
|
||||
const renderMode = useBuilder((s) => s.renderMode);
|
||||
const draggingId = useBuilder((s) => s.draggingId);
|
||||
const gizmoDragging = useBuilder((s) => s.gizmoDragging);
|
||||
const orbitRef = useRef(null);
|
||||
const style = sceneStyle(renderMode);
|
||||
|
||||
return (
|
||||
<Canvas shadows dpr={[1, 2]} className="touch-none">
|
||||
<color attach="background" args={[style.background]} />
|
||||
<fog attach="fog" args={[style.background, 50, style.fogFar]} />
|
||||
|
||||
{viewMode === 'orbit' ? (
|
||||
<PerspectiveCamera makeDefault fov={50} position={VIEWS.orbit.pos} near={0.1} far={400} />
|
||||
) : (
|
||||
<OrthographicCamera
|
||||
makeDefault
|
||||
zoom={48}
|
||||
position={VIEWS[viewMode].pos}
|
||||
near={0.1}
|
||||
far={400}
|
||||
/>
|
||||
)}
|
||||
<OrbitControls
|
||||
ref={orbitRef}
|
||||
makeDefault
|
||||
enabled={!draggingId && !gizmoDragging}
|
||||
enableRotate={viewMode === 'orbit'}
|
||||
enableDamping
|
||||
dampingFactor={0.12}
|
||||
maxPolarAngle={Math.PI / 2 - 0.02}
|
||||
minDistance={2}
|
||||
maxDistance={120}
|
||||
/>
|
||||
<CameraRig />
|
||||
<CaptureRefs />
|
||||
<KeyboardNavigator />
|
||||
|
||||
<ambientLight intensity={style.ambient} />
|
||||
<hemisphereLight args={['#bcd6ff', '#1a2230', style.hemi]} />
|
||||
<directionalLight
|
||||
position={[12, 18, 8]}
|
||||
intensity={style.dir}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-camera-left={-25}
|
||||
shadow-camera-right={25}
|
||||
shadow-camera-top={25}
|
||||
shadow-camera-bottom={-25}
|
||||
/>
|
||||
|
||||
<Grid
|
||||
position={[0, 0, 0]}
|
||||
args={[200, 200]}
|
||||
cellSize={0.5}
|
||||
cellColor={style.gridCell}
|
||||
sectionSize={2.5}
|
||||
sectionColor={style.gridSection}
|
||||
fadeDistance={90}
|
||||
fadeStrength={1.5}
|
||||
infiniteGrid
|
||||
/>
|
||||
|
||||
<InteractionLayer />
|
||||
<DragPlane />
|
||||
<PartMeshes />
|
||||
<ConnectorHandles />
|
||||
<SelectionGizmo />
|
||||
<FlowArrows />
|
||||
<AirCompressorVisuals />
|
||||
<AlignmentGuides />
|
||||
<CameraFocus />
|
||||
<RemoteCursors remoteCursors={remoteCursors} />
|
||||
<LocalCursorTracker />
|
||||
|
||||
<GizmoHelper alignment="top-right" margin={[80, 80]}>
|
||||
<GizmoViewport axisColors={['#ef4444', '#22c55e', '#3b82f6']} labelColor="white" />
|
||||
</GizmoHelper>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
99
src/components/SelectionGizmo.tsx
Normal file
99
src/components/SelectionGizmo.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { PivotControls } from '@react-three/drei';
|
||||
import type { Vec3 } from '../types';
|
||||
import { snapValue, useBuilder } from '../store/builderStore';
|
||||
|
||||
/**
|
||||
* SelectionGizmo — a translate-only pivot gizmo at the selection centroid.
|
||||
* Dragging an axis arrow / plane slider moves every selected part rigidly in
|
||||
* all three dimensions (grid-snapped, floor-clamped), as one undo step.
|
||||
*/
|
||||
export function SelectionGizmo() {
|
||||
const selectedIds = useBuilder((s) => s.selectedIds);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const draggingId = useBuilder((s) => s.draggingId);
|
||||
const placingType = useBuilder((s) => s.placingType);
|
||||
const tool = useBuilder((s) => s.tool);
|
||||
|
||||
const matrix = useMemo(() => new THREE.Matrix4(), []);
|
||||
const draggingRef = useRef(false);
|
||||
const startRef = useRef<{ centroid: Vec3; positions: Record<string, Vec3> } | null>(null);
|
||||
|
||||
const selected = selectedIds.map((id) => parts[id]).filter(Boolean);
|
||||
const centroid: Vec3 = [0, 0, 0];
|
||||
for (const p of selected) {
|
||||
centroid[0] += p.position[0] / selected.length;
|
||||
centroid[1] += p.position[1] / selected.length;
|
||||
centroid[2] += p.position[2] / selected.length;
|
||||
}
|
||||
|
||||
// Keep the gizmo parked at the selection centroid whenever it isn't the
|
||||
// thing doing the moving (selection changes, undo, inspector edits, ...).
|
||||
useLayoutEffect(() => {
|
||||
if (!draggingRef.current) matrix.setPosition(centroid[0], centroid[1], centroid[2]);
|
||||
});
|
||||
|
||||
if (!selected.length || draggingId || placingType || tool === 'measure') return null;
|
||||
|
||||
const onDragStart = () => {
|
||||
draggingRef.current = true;
|
||||
const s = useBuilder.getState();
|
||||
s.pushHistory();
|
||||
s.setGizmoDragging(true);
|
||||
const positions: Record<string, Vec3> = {};
|
||||
for (const id of s.selectedIds) {
|
||||
const p = s.parts[id];
|
||||
if (p) positions[id] = [...p.position] as Vec3;
|
||||
}
|
||||
startRef.current = { centroid: [...centroid] as Vec3, positions };
|
||||
};
|
||||
|
||||
const onDrag = (l: THREE.Matrix4) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const s = useBuilder.getState();
|
||||
matrix.copy(l); // gizmo follows the pointer
|
||||
const v = new THREE.Vector3().setFromMatrixPosition(l);
|
||||
const snap = (n: number) => (s.snapToGrid ? snapValue(n, s.gridSize) : n);
|
||||
const delta: Vec3 = [
|
||||
snap(v.x - start.centroid[0]),
|
||||
snap(v.y - start.centroid[1]),
|
||||
snap(v.z - start.centroid[2]),
|
||||
];
|
||||
// Don't let any part of the group sink below the floor.
|
||||
let minY = Infinity;
|
||||
for (const p of Object.values(start.positions)) minY = Math.min(minY, p[1]);
|
||||
if (Number.isFinite(minY)) delta[1] = Math.max(delta[1], -minY);
|
||||
|
||||
const updates: Record<string, Vec3> = {};
|
||||
for (const [id, sp] of Object.entries(start.positions)) {
|
||||
updates[id] = [sp[0] + delta[0], sp[1] + delta[1], sp[2] + delta[2]];
|
||||
}
|
||||
s.setPositions(updates);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
draggingRef.current = false;
|
||||
startRef.current = null;
|
||||
useBuilder.getState().setGizmoDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PivotControls
|
||||
matrix={matrix}
|
||||
autoTransform={false}
|
||||
fixed
|
||||
scale={72}
|
||||
lineWidth={3}
|
||||
depthTest={false}
|
||||
disableRotations
|
||||
disableScaling
|
||||
axisColors={['#f87171', '#4ade80', '#60a5fa']}
|
||||
hoveredColor="#fbbf24"
|
||||
onDragStart={onDragStart}
|
||||
onDrag={onDrag}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
303
src/components/StatusPanel.tsx
Normal file
303
src/components/StatusPanel.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSimulator';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { computeRoomMetrics } from '../environment/roomMetrics';
|
||||
import type { WarningLevel } from '../types';
|
||||
|
||||
const LEVEL_STYLE: Record<WarningLevel, string> = {
|
||||
error: 'border-rose-900/60 bg-rose-950/60 text-rose-300',
|
||||
warning: 'border-amber-900/60 bg-amber-950/50 text-amber-300',
|
||||
info: 'border-sky-900/60 bg-sky-950/50 text-sky-300',
|
||||
};
|
||||
|
||||
const LEVEL_ICON: Record<WarningLevel, string> = {
|
||||
error: '⛔',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️',
|
||||
};
|
||||
|
||||
/**
|
||||
* StatusPanel — bottom bar showing live simulation results:
|
||||
* per-pump delivered flow, system totals, and diagnostics.
|
||||
*/
|
||||
export function StatusPanel() {
|
||||
const sim = useSimulation();
|
||||
const partCount = useBuilder((s) => Object.keys(s.parts).length);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const setSelected = useBuilder((s) => s.setSelected);
|
||||
const simRunning = useBuilder((s) => s.simRunning);
|
||||
const toggleSimRunning = useBuilder((s) => s.toggleSimRunning);
|
||||
const heatmapMode = useBuilder((s) => s.heatmapMode);
|
||||
const toggleHeatmapMode = useBuilder((s) => s.toggleHeatmapMode);
|
||||
const roomMetrics = useMemo(() => computeRoomMetrics(parts), [parts]);
|
||||
|
||||
const reservoirs = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.type === 'reservoir');
|
||||
}, [parts]);
|
||||
|
||||
const reservoirStats = useMemo(() => {
|
||||
return reservoirs.map((res) => computeReservoirWaterStats(res, parts));
|
||||
}, [reservoirs, parts]);
|
||||
|
||||
const lph = sim.totalGph * 3.785;
|
||||
const flowing = sim.pumps.some((p) => p.running);
|
||||
const peakVelocity = Math.max(0, ...Object.values(sim.velocities));
|
||||
const peakPressure = Math.max(0, ...Object.values(sim.pressures));
|
||||
|
||||
return (
|
||||
<footer className="flex h-36 shrink-0 border-t border-zinc-800 bg-zinc-950/90 backdrop-blur">
|
||||
{/* System stats */}
|
||||
<div className="flex w-56 shrink-0 flex-col justify-center gap-1 border-r border-zinc-800 px-4">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Simulation
|
||||
</h3>
|
||||
<div className="text-2xl font-bold text-sky-400 tabular-nums">
|
||||
{sim.totalGph.toFixed(0)}
|
||||
<span className="ml-1 text-xs font-medium text-zinc-500">GPH</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 tabular-nums">
|
||||
≈ {lph.toFixed(0)} L/h · {partCount} parts · {sim.connectionCount} connections
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600 tabular-nums">
|
||||
peak {peakVelocity.toFixed(1)} ft/s · {peakPressure.toFixed(1)} psi
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={toggleSimRunning}
|
||||
title={simRunning ? 'Pause simulation (Space)' : 'Resume simulation (Space)'}
|
||||
className={`inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold transition hover:brightness-125 ${
|
||||
!simRunning
|
||||
? 'bg-amber-950 text-amber-400 ring-1 ring-amber-800'
|
||||
: flowing
|
||||
? 'bg-emerald-950 text-emerald-400 ring-1 ring-emerald-800'
|
||||
: 'bg-zinc-900 text-zinc-500 ring-1 ring-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
{simRunning && flowing && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" />
|
||||
)}
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
{!simRunning ? '⏸ Paused' : flowing ? 'Water flowing' : 'System idle'}
|
||||
</button>
|
||||
|
||||
<label className="inline-flex cursor-pointer items-center gap-1 text-[10px] font-semibold text-zinc-400 hover:text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={heatmapMode}
|
||||
onChange={toggleHeatmapMode}
|
||||
className="accent-sky-500 cursor-pointer h-3 w-3 rounded border-zinc-700 bg-zinc-850"
|
||||
/>
|
||||
<span>Heatmap</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pumps */}
|
||||
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">Pumps</h3>
|
||||
{sim.pumps.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No pumps in the system.</p>
|
||||
)}
|
||||
{sim.pumps.map((p) => (
|
||||
<button
|
||||
key={p.pumpId}
|
||||
onClick={() => setSelected(p.pumpId)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs font-semibold text-zinc-200">
|
||||
{p.gph > 0 ? `${p.gph.toFixed(0)} GPH` : 'No flow'}
|
||||
<span className="ml-1 text-[10px] font-normal text-zinc-500">
|
||||
/ {p.ratedGph} rated
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-zinc-500">
|
||||
head {p.headFt.toFixed(1)}/{p.maxHeadFt} ft
|
||||
</span>
|
||||
</div>
|
||||
{/* Efficiency bar */}
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
className={`h-full rounded-full ${p.gph / p.ratedGph > 0.5 ? 'bg-emerald-500' : p.gph > 0 ? 'bg-amber-500' : 'bg-rose-600'}`}
|
||||
style={{ width: `${Math.max(2, (p.gph / p.ratedGph) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Diagnostics */}
|
||||
<div className="flex w-80 shrink-0 flex-col gap-1 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Environment
|
||||
</h3>
|
||||
{roomMetrics.length === 0 ? (
|
||||
<p className="text-xs text-zinc-600">Add a grow tent/room to track air exchange.</p>
|
||||
) : (
|
||||
roomMetrics.map((room) => (
|
||||
<button
|
||||
key={room.roomId}
|
||||
onClick={() => setSelected(room.roomId)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-zinc-200">{room.label}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||
room.sealed
|
||||
? 'bg-emerald-950 text-emerald-300 ring-1 ring-emerald-800'
|
||||
: 'bg-sky-950 text-sky-300 ring-1 ring-sky-800'
|
||||
}`}
|
||||
>
|
||||
{room.sealed ? 'Sealed' : 'Open air'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400 tabular-nums">
|
||||
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust CFM
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500 tabular-nums">
|
||||
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-600 tabular-nums">
|
||||
circ {room.circulationCfm.toFixed(0)} CFM · light {room.lightWatts.toFixed(0)} W · CO2 {room.co2TankCount} tank{room.co2TankCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div className="mt-1 flex gap-1.5 text-[9px] font-mono">
|
||||
<span className="rounded bg-zinc-900 px-1 py-0.5 text-emerald-400 border border-zinc-800">
|
||||
VPD {room.roomVPD.toFixed(2)} kPa
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1 py-0.5 text-amber-400 border border-zinc-800">
|
||||
DLI {room.roomDLI.toFixed(1)}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1 py-0.5 text-sky-400 border border-zinc-800">
|
||||
CO₂ {room.roomCo2} ppm
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room Readout */}
|
||||
<div className="flex w-56 shrink-0 flex-col justify-center gap-1.5 border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Room Readout
|
||||
</h3>
|
||||
{roomMetrics.length === 0 ? (
|
||||
<p className="text-xs text-zinc-600">No room placed</p>
|
||||
) : (() => {
|
||||
const m = roomMetrics[0];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 text-[10px] font-mono tabular-nums">
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-rose-400">
|
||||
🌡️ {m.roomTempF.toFixed(0)}°F
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
|
||||
💧 {m.roomRH.toFixed(0)}%
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-emerald-400">
|
||||
VPD {m.roomVPD.toFixed(2)}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
|
||||
CO₂ {m.roomCo2}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-amber-400">
|
||||
☀️ {m.roomPPFD.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Aeration & Water */}
|
||||
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Aeration & Water
|
||||
</h3>
|
||||
{reservoirStats.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No reservoirs in the system.</p>
|
||||
)}
|
||||
{reservoirStats.map((stats) => (
|
||||
<button
|
||||
key={stats.id}
|
||||
onClick={() => setSelected(stats.id)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
|
||||
>
|
||||
{(() => {
|
||||
const runtime = sim.reservoirRuntime[stats.id];
|
||||
return runtime ? (
|
||||
<div className="mb-1 flex items-center justify-between text-[9px] font-mono">
|
||||
<span className={`${runtime.empty ? 'text-rose-400' : 'text-sky-400'}`}>
|
||||
{runtime.gallonsCurrent.toFixed(1)} / {runtime.gallonsCapacity.toFixed(1)} gal
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{runtime.minutesRemaining === null
|
||||
? 'steady'
|
||||
: `${runtime.minutesRemaining.toFixed(1)} min left`}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs font-semibold text-zinc-200">
|
||||
{stats.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-zinc-500">
|
||||
{stats.volGal.toFixed(1)} Gal
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-[10px] text-zinc-400">
|
||||
<span>Aeration: {stats.activeAerationGph} GPH</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${
|
||||
stats.tdo >= 8.0
|
||||
? 'bg-emerald-950 text-emerald-400 border border-emerald-800/30'
|
||||
: stats.tdo >= 7.0
|
||||
? 'bg-sky-950 text-sky-300 border border-sky-800/30'
|
||||
: 'bg-amber-950 text-amber-400 border border-amber-800/30 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
{stats.tdo.toFixed(1)} mg/L
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[9px] text-zinc-500 font-mono">
|
||||
<span className={stats.tempF > 73.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
|
||||
Temp: {stats.tempF.toFixed(1)}°F
|
||||
</span>
|
||||
<span className={stats.ph < 5.5 || stats.ph > 6.5 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
|
||||
pH: {stats.ph.toFixed(1)}
|
||||
</span>
|
||||
<span className={stats.ec < 0.8 || stats.ec > 2.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
|
||||
EC: {stats.ec.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Diagnostics
|
||||
</h3>
|
||||
{sim.warnings.length === 0 ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
✓ System healthy — no issues detected.
|
||||
</p>
|
||||
) : (
|
||||
sim.warnings.map((w, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => w.partId && setSelected(w.partId)}
|
||||
className={`rounded-md border px-2.5 py-1 text-left text-[11px] leading-snug ${LEVEL_STYLE[w.level]} ${
|
||||
w.partId ? 'cursor-pointer hover:brightness-125' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
{LEVEL_ICON[w.level]} {w.message}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
93
src/components/Toast.tsx
Normal file
93
src/components/Toast.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toast store */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
/** Set to true when the toast is being dismissed (triggers fade-out). */
|
||||
exiting?: boolean;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type: ToastType) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastStore>((set, get) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (message, type) => {
|
||||
const id = typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID().slice(0, 8)
|
||||
: Math.random().toString(36).substring(2, 10);
|
||||
set({ toasts: [...get().toasts, { id, message, type }] });
|
||||
|
||||
// Auto-remove after 3 s (with a 300 ms exit animation lead-in)
|
||||
setTimeout(() => get().dismissToast(id), 3000);
|
||||
},
|
||||
|
||||
dismissToast: (id) => {
|
||||
// Mark as exiting so CSS can animate out
|
||||
set({ toasts: get().toasts.map((t) => (t.id === id ? { ...t, exiting: true } : t)) });
|
||||
// Remove from DOM after the fade-out animation
|
||||
setTimeout(() => set({ toasts: get().toasts.filter((t) => t.id !== id) }), 300);
|
||||
},
|
||||
}));
|
||||
|
||||
/** Convenience shortcut so callers don't need to import the store. */
|
||||
export const addToast = useToastStore.getState().addToast;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Border color map */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const BORDER: Record<ToastType, string> = {
|
||||
success: 'border-l-emerald-500',
|
||||
error: 'border-l-rose-500',
|
||||
info: 'border-l-sky-500',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ToastContainer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const dismiss = useToastStore((s) => s.dismissToast);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[200] flex flex-col-reverse gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto flex items-start gap-2 rounded-lg border-l-4 bg-zinc-900 px-4 py-3 shadow-2xl ${BORDER[toast.type]} ${
|
||||
toast.exiting ? 'animate-toast-out' : 'animate-toast-in'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-zinc-200">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
className="ml-2 shrink-0 text-zinc-500 hover:text-zinc-200 transition cursor-pointer text-xs leading-none mt-0.5"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
555
src/components/Toolbar.tsx
Normal file
555
src/components/Toolbar.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import type { RenderMode, ViewMode, PlacedPart } from '../types';
|
||||
import {
|
||||
downloadProject,
|
||||
importProjectFile,
|
||||
} from '../utils/serializer';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PaywallModal } from '../payments/PaywallModal';
|
||||
import { addToast } from './Toast';
|
||||
import { useSaveStatus } from '../store/saveStatusStore';
|
||||
|
||||
function SaveIndicator() {
|
||||
const status = useSaveStatus((s) => s.status);
|
||||
if (status === 'saved') {
|
||||
return (
|
||||
<span
|
||||
title="All changes saved to browser"
|
||||
className="h-2 w-2 rounded-full bg-emerald-500 transition-colors duration-300 shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (status === 'saving') {
|
||||
return (
|
||||
<span
|
||||
title="Saving changes..."
|
||||
className="h-2 w-2 rounded-full bg-amber-500 animate-pulse shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
title="Unsaved changes"
|
||||
className="h-2 w-2 rounded-full bg-amber-500 shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar — project actions (new/save/load/export/import), undo/redo,
|
||||
* tools (measure, snap), view presets, and the flow-animation toggle.
|
||||
*/
|
||||
export function Toolbar({
|
||||
onOpenBom,
|
||||
onCheckBuild,
|
||||
}: {
|
||||
onOpenBom: () => void;
|
||||
onCheckBuild: () => void;
|
||||
}) {
|
||||
const { token, user, logout, refreshProfile } = useAuth();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [projectsList, setProjectsList] = useState<{ name: string; savedAt: string; parts: PlacedPart[] }[]>([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
|
||||
const projectName = useBuilder((s) => s.projectName);
|
||||
const setProjectName = useBuilder((s) => s.setProjectName);
|
||||
const growthStage = useBuilder((s) => s.growthStage);
|
||||
const setGrowthStage = useBuilder((s) => s.setGrowthStage);
|
||||
const viewMode = useBuilder((s) => s.viewMode);
|
||||
const setViewMode = useBuilder((s) => s.setViewMode);
|
||||
const renderMode = useBuilder((s) => s.renderMode);
|
||||
const setRenderMode = useBuilder((s) => s.setRenderMode);
|
||||
const tool = useBuilder((s) => s.tool);
|
||||
const setTool = useBuilder((s) => s.setTool);
|
||||
const showFlow = useBuilder((s) => s.showFlow);
|
||||
const toggleFlow = useBuilder((s) => s.toggleFlow);
|
||||
const simRunning = useBuilder((s) => s.simRunning);
|
||||
const toggleSimRunning = useBuilder((s) => s.toggleSimRunning);
|
||||
const snapToGrid = useBuilder((s) => s.snapToGrid);
|
||||
const toggleSnap = useBuilder((s) => s.toggleSnap);
|
||||
const undo = useBuilder((s) => s.undo);
|
||||
const redo = useBuilder((s) => s.redo);
|
||||
const canUndo = useBuilder((s) => s.past.length > 0);
|
||||
const canRedo = useBuilder((s) => s.future.length > 0);
|
||||
const autoCapOpenEnds = useBuilder((s) => s.autoCapOpenEnds);
|
||||
const rooms = useBuilder((s) => s.rooms);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const addRoom = useBuilder((s) => s.addRoom);
|
||||
const removeRoom = useBuilder((s) => s.removeRoom);
|
||||
const setActiveRoomId = useBuilder((s) => s.setActiveRoomId);
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [loadOpen, setLoadOpen] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
if (!token) return;
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjectsList(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch projects', err);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (loadOpen) {
|
||||
void fetchProjects();
|
||||
}
|
||||
}, [loadOpen]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!token) return;
|
||||
const s = useBuilder.getState();
|
||||
const partsArray = Object.values(s.parts);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ name: s.projectName, parts: partsArray })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 402) {
|
||||
setShowPaywall(true);
|
||||
} else {
|
||||
addToast(data.error || 'Failed to save project', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
addToast('Project saved successfully!', 'success');
|
||||
} catch (err) {
|
||||
addToast('Save failed: ' + err, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = (name: string) => {
|
||||
const proj = projectsList.find((p) => p.name === name);
|
||||
if (proj) {
|
||||
useBuilder.getState().loadParts(proj.parts, proj.name);
|
||||
}
|
||||
setLoadOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, name: string) => {
|
||||
e.stopPropagation();
|
||||
if (!token || !confirm(`Delete project "${name}" permanently?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchProjects();
|
||||
await refreshProfile();
|
||||
addToast('Project deleted successfully', 'success');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
addToast(data.error || 'Failed to delete project', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
addToast('Delete failed: ' + err, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const s = useBuilder.getState();
|
||||
downloadProject(s.projectName, Object.values(s.parts));
|
||||
};
|
||||
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const proj = await importProjectFile(file);
|
||||
useBuilder.getState().loadParts(proj.parts, proj.name);
|
||||
addToast('Project imported successfully', 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Import failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
if (confirm('Start a new empty project? Unsaved changes are kept in undo history.')) {
|
||||
useBuilder.getState().clearAll();
|
||||
setProjectName('Untitled system');
|
||||
}
|
||||
};
|
||||
|
||||
const views: { id: ViewMode; label: string; title: string }[] = [
|
||||
{ id: 'orbit', label: '3D', title: 'Free 3D orbit view' },
|
||||
{ id: 'top', label: 'Top', title: 'Top-down plan view' },
|
||||
{ id: 'front', label: 'Front', title: 'Front elevation view' },
|
||||
{ id: 'side', label: 'Side', title: 'Side elevation view' },
|
||||
];
|
||||
const renderModes: { id: RenderMode; label: string; title: string }[] = [
|
||||
{ id: 'builder', label: 'Builder', title: 'Standard building mode' },
|
||||
{ id: 'xray', label: 'X-Ray', title: 'Transparent shell with internal water visibility' },
|
||||
{ id: 'water', label: 'Water', title: 'Water-first view with solids faded back' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-zinc-800 bg-zinc-950 px-3">
|
||||
<div className="flex items-center gap-2 pr-2">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-sky-500 to-blue-700 text-sm font-black text-white">
|
||||
H
|
||||
</span>
|
||||
<input
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className="w-44 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-sm font-semibold text-zinc-100 outline-none hover:border-zinc-800 focus:border-sky-700 focus:bg-zinc-900"
|
||||
/>
|
||||
<SaveIndicator />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TBtn onClick={handleNew} title="New empty project">New</TBtn>
|
||||
<TBtn onClick={handleSave} title="Save to cloud storage">Save</TBtn>
|
||||
<div className="relative">
|
||||
<TBtn onClick={() => setLoadOpen((o) => !o)} title="Load a saved project">
|
||||
Load ▾
|
||||
</TBtn>
|
||||
{loadOpen && (
|
||||
<div className="absolute top-9 left-0 z-50 max-h-72 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 p-1 shadow-2xl">
|
||||
{loadingProjects && (
|
||||
<div className="px-3 py-2 text-xs text-zinc-500">Loading...</div>
|
||||
)}
|
||||
{!loadingProjects && projectsList.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-zinc-500">No saved projects yet.</div>
|
||||
)}
|
||||
{!loadingProjects && projectsList.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-zinc-800 group"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleLoad(p.name)}
|
||||
className="flex-1 text-left text-xs text-zinc-200 outline-none"
|
||||
>
|
||||
{p.name}
|
||||
<span className="block text-[10px] text-zinc-500">
|
||||
{new Date(p.savedAt).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, p.name)}
|
||||
className="hidden group-hover:block text-[10px] text-red-400 hover:text-red-300 px-2 py-0.5 rounded bg-zinc-950/40"
|
||||
title="Delete project"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TBtn onClick={handleExport} title="Download design as JSON">Export</TBtn>
|
||||
<TBtn onClick={() => fileRef.current?.click()} title="Import a design JSON">Import</TBtn>
|
||||
<TBtn onClick={() => setShowShortcuts(true)} title="Show Keyboard Shortcuts">⌨️ Shortcuts</TBtn>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleImport(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TBtn onClick={onOpenBom} title="Open bill of materials">BOM</TBtn>
|
||||
<TBtn onClick={onCheckBuild} title="Run design validation">Check Build</TBtn>
|
||||
<TBtn onClick={() => autoCapOpenEnds()} title="Add caps to every loose plumbing end">
|
||||
Auto Cap
|
||||
</TBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TBtn onClick={undo} disabled={!canUndo} title="Undo (⌘Z)">↶</TBtn>
|
||||
<TBtn onClick={redo} disabled={!canRedo} title="Redo (⇧⌘Z)">↷</TBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TBtn
|
||||
active={tool === 'measure'}
|
||||
onClick={() => setTool(tool === 'measure' ? 'select' : 'measure')}
|
||||
title="Measure: click two points in the scene"
|
||||
>
|
||||
📏 Measure
|
||||
</TBtn>
|
||||
<TBtn
|
||||
active={tool === 'pipeRun'}
|
||||
onClick={() => setTool(tool === 'pipeRun' ? 'select' : 'pipeRun')}
|
||||
title="Pipe Run: click two ground points"
|
||||
>
|
||||
Pipe Run
|
||||
</TBtn>
|
||||
<TBtn active={snapToGrid} onClick={toggleSnap} title="Snap to grid (0.25 ft)">
|
||||
⌗ Snap
|
||||
</TBtn>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Master play / pause for the water simulation */}
|
||||
<button
|
||||
onClick={toggleSimRunning}
|
||||
title={simRunning ? 'Pause water simulation (Space)' : 'Run water simulation (Space)'}
|
||||
className={`grid h-8 w-8 shrink-0 place-items-center rounded-full text-white shadow-lg transition-all hover:scale-110 active:scale-95 ${
|
||||
simRunning
|
||||
? 'bg-emerald-500 shadow-emerald-500/40 hover:bg-emerald-400'
|
||||
: 'bg-sky-600 shadow-sky-600/40 hover:bg-sky-500'
|
||||
}`}
|
||||
>
|
||||
{simRunning ? (
|
||||
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor">
|
||||
<rect x="3" y="2.5" width="3.4" height="11" rx="1" />
|
||||
<rect x="9.6" y="2.5" width="3.4" height="11" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" className="ml-0.5 h-3.5 w-3.5" fill="currentColor">
|
||||
<path d="M4 2.8a1 1 0 0 1 1.52-.86l8.2 5.2a1 1 0 0 1 0 1.7l-8.2 5.2A1 1 0 0 1 4 13.2V2.8Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 bg-zinc-900 border border-zinc-800 rounded-lg px-2.5 py-1">
|
||||
<span className="text-[10px] uppercase font-bold text-zinc-400">Stage</span>
|
||||
<select
|
||||
value={growthStage}
|
||||
onChange={(e) => setGrowthStage(e.target.value as any)}
|
||||
className="bg-transparent text-xs text-zinc-200 outline-none border-none cursor-pointer pr-1"
|
||||
>
|
||||
<option value="seedling" className="bg-zinc-900 text-zinc-200">Seedling</option>
|
||||
<option value="vegetative" className="bg-zinc-900 text-zinc-200">Vegetative</option>
|
||||
<option value="flowering" className="bg-zinc-900 text-zinc-200">Flowering</option>
|
||||
</select>
|
||||
</div>
|
||||
<TBtn active={showFlow} onClick={toggleFlow} title="Toggle water flow animation">
|
||||
💧 Flow
|
||||
</TBtn>
|
||||
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
|
||||
{renderModes.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setRenderMode(mode.id)}
|
||||
title={mode.title}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition ${
|
||||
renderMode === mode.id
|
||||
? 'bg-cyan-600 text-white'
|
||||
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
|
||||
{views.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setViewMode(v.id)}
|
||||
title={v.title}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition ${
|
||||
viewMode === v.id
|
||||
? 'bg-sky-600 text-white'
|
||||
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[10px] font-semibold text-zinc-300 max-w-24 truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
<span className={`text-[9px] font-bold uppercase ${user.total_slots === 0 ? 'text-amber-400' : user.project_count >= user.total_slots ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{user.project_count} / {user.total_slots} Slots
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{user.total_slots === 0 ? (
|
||||
<button
|
||||
onClick={() => setShowPaywall(true)}
|
||||
className="rounded bg-gradient-to-r from-sky-500/20 to-blue-500/20 hover:from-sky-500/30 hover:to-blue-500/30 text-sky-300 border border-sky-700 px-2 py-1 text-[10px] font-bold transition cursor-pointer flex items-center gap-1 shadow-lg"
|
||||
>
|
||||
💎 Upgrade
|
||||
</button>
|
||||
) : (
|
||||
user.project_count >= user.total_slots && (
|
||||
<button
|
||||
onClick={() => setShowPaywall(true)}
|
||||
className="rounded bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-600/30 px-2 py-1 text-[10px] font-semibold transition cursor-pointer"
|
||||
>
|
||||
+ Add Slot
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-[10px] font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition cursor-pointer"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
|
||||
|
||||
{showShortcuts && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-2xl text-left">
|
||||
<h3 className="text-sm font-bold text-zinc-100 mb-4 flex items-center gap-1.5 border-b border-zinc-800 pb-2">
|
||||
⌨️ Keyboard Shortcuts
|
||||
</h3>
|
||||
<div className="space-y-2.5 text-xs text-zinc-400">
|
||||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||||
<span>Undo / Redo</span>
|
||||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">⌘Z / ⌘Y</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||||
<span>Rotate Selected</span>
|
||||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">R</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||||
<span>Duplicate Selected</span>
|
||||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">D</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||||
<span>Delete Selected</span>
|
||||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">⌫ / Del</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||||
<span>Run / Pause Sim</span>
|
||||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Spacebar</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||||
<span>Pan Camera View</span>
|
||||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Arrow Keys</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||||
<span>Clear Selection & Tool</span>
|
||||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Esc</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowShortcuts(false)}
|
||||
className="mt-6 w-full rounded bg-zinc-800 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-700 transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Tabbed room bar */}
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-zinc-850 bg-zinc-900/60 px-3">
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
{rooms.map((room) => {
|
||||
const isActive = room.id === activeRoomId;
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className={`group flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold transition cursor-pointer select-none ${
|
||||
isActive
|
||||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||||
: 'text-zinc-400 hover:text-zinc-200 border border-transparent'
|
||||
}`}
|
||||
onClick={() => setActiveRoomId(room.id)}
|
||||
>
|
||||
<span>{room.name}</span>
|
||||
{rooms.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`Delete room "${room.name}" and all its parts permanently?`)) {
|
||||
removeRoom(room.id);
|
||||
}
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 hover:text-red-400 text-[10px] ml-1 transition"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = prompt('Enter room name:', `Room ${rooms.length + 1}`);
|
||||
if (name) {
|
||||
addRoom(name.trim());
|
||||
}
|
||||
}}
|
||||
className="rounded border border-zinc-700 bg-zinc-850 hover:bg-zinc-800 text-zinc-300 px-2.5 py-1 text-[11px] font-semibold transition flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
+ Add Room
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="mx-1 h-6 w-px bg-zinc-800" />;
|
||||
}
|
||||
|
||||
function TBtn({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
active,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition disabled:cursor-default disabled:opacity-30
|
||||
${
|
||||
active
|
||||
? 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700'
|
||||
: 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
428
src/components/icons/PartIcons.tsx
Normal file
428
src/components/icons/PartIcons.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { PART_DEFS } from '../../parts/catalog';
|
||||
|
||||
/**
|
||||
* Miniature pictographic icons for the parts library.
|
||||
*
|
||||
* Visual language: 40x40 viewBox, ~2px rounded light strokes (#cbd5e1),
|
||||
* with each part's catalog accent color used as fill/highlight.
|
||||
* Unknown part types fall back to a generic colored tile.
|
||||
*/
|
||||
|
||||
export interface PartIconProps {
|
||||
/** Accent color from the part catalog (`def.color`). */
|
||||
color: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STROKE = '#cbd5e1';
|
||||
const LEAF = '#4ade80';
|
||||
|
||||
function Svg({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
stroke={STROKE}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Straight pipe segment with couplings at both ends. */
|
||||
function PipeIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="8" y="15" width="24" height="10" rx="2" fill={color} fillOpacity={0.35} />
|
||||
<rect x="4" y="12.5" width="5" height="15" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
<rect x="31" y="12.5" width="5" height="15" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
<path d="M12 18.5 H28" strokeOpacity={0.45} strokeWidth={1.5} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 90-degree elbow bend with flanged ends. */
|
||||
function ElbowIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path
|
||||
d="M5 10 H22 A8 8 0 0 1 30 18 V35 H20 V20 H5 Z"
|
||||
fill={color}
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
<rect x="3" y="8" width="4" height="14" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
<rect x="18" y="33" width="14" height="4" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** T-joint: horizontal run with a center branch. */
|
||||
function TeeIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M5 13 H35 V23 H25 V35 H15 V23 H5 Z" fill={color} fillOpacity={0.35} />
|
||||
<rect x="3" y="11" width="4" height="14" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
<rect x="33" y="11" width="4" height="14" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
<rect x="13" y="33" width="14" height="4" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline valve: bowtie body, stem, and handle wheel. */
|
||||
function ValveIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M6 15 L20 21.5 L6 28 Z" fill={color} fillOpacity={0.35} />
|
||||
<path d="M34 15 L20 21.5 L34 28 Z" fill={color} fillOpacity={0.35} />
|
||||
<path d="M20 21.5 V11" />
|
||||
<ellipse cx="20" cy="9" rx="8" ry="3" stroke={color} fill={color} fillOpacity={0.3} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Centrifugal pump: round volute, impeller spokes, outlet duct, feet. */
|
||||
function PumpIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="23" y="11" width="13" height="7" rx="1.5" fill={color} fillOpacity={0.45} />
|
||||
<circle cx="18" cy="23" r="10" fill={color} fillOpacity={0.25} />
|
||||
<path d="M18 23 L18 16" strokeWidth={1.8} />
|
||||
<path d="M18 23 L12 26.5" strokeWidth={1.8} />
|
||||
<path d="M18 23 L24 26.5" strokeWidth={1.8} />
|
||||
<circle cx="18" cy="23" r="1.8" fill={STROKE} stroke="none" />
|
||||
<path d="M10 36 H26" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reservoir tank with a wavy water line. */
|
||||
function ReservoirIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="7" y="9" width="26" height="25" rx="3" />
|
||||
<path
|
||||
d="M8.5 21 Q12 18.5 15.5 21 T22.5 21 T29.5 21 L31.5 21 V29.5 Q31.5 32.5 28.5 32.5 H11.5 Q8.5 32.5 8.5 29.5 Z"
|
||||
fill={color}
|
||||
fillOpacity={0.6}
|
||||
stroke="none"
|
||||
/>
|
||||
<path d="M8.5 21 Q12 18.5 15.5 21 T22.5 21 T29.5 21 L31.5 21" stroke={color} strokeWidth={1.8} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vertical grow tower with angled plant cups and foliage. */
|
||||
function TowerIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="16" y="5" width="8" height="28" rx="2" fill={color} fillOpacity={0.25} />
|
||||
<path d="M16 12 L10 9.5 V15 L16 16 Z" fill={color} fillOpacity={0.4} />
|
||||
<path d="M24 19 L30 16.5 V22 L24 23 Z" fill={color} fillOpacity={0.4} />
|
||||
<path d="M16 26 L10 23.5 V29 L16 30 Z" fill={color} fillOpacity={0.4} />
|
||||
<path d="M10 9.5 Q7 7 7.5 4.5 Q11 5 11.5 8.5" fill={LEAF} stroke={LEAF} strokeWidth={1.2} />
|
||||
<path d="M30 16.5 Q33 14 32.5 11.5 Q29 12 28.5 15.5" fill={LEAF} stroke={LEAF} strokeWidth={1.2} />
|
||||
<path d="M10 23.5 Q7 21 7.5 18.5 Q11 19 11.5 22.5" fill={LEAF} stroke={LEAF} strokeWidth={1.2} />
|
||||
<rect x="12" y="33" width="16" height="3.5" rx="1.5" fill={color} fillOpacity={0.4} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shallow flood tray in light perspective with water ripples. */
|
||||
function TrayIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M6 21 L14 14 H34 L26 21 Z" fill={color} fillOpacity={0.25} />
|
||||
<path d="M6 21 H26 V28 H6 Z" fill={color} fillOpacity={0.45} />
|
||||
<path d="M26 21 L34 14 V21 L26 28 Z" fill={color} fillOpacity={0.35} />
|
||||
<path d="M12 18.5 Q14 17 16 18.5" strokeWidth={1.4} strokeOpacity={0.8} />
|
||||
<path d="M20 17.5 Q22 16 24 17.5" strokeWidth={1.4} strokeOpacity={0.8} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vertical grow wall: panel with a grid of planting pockets. */
|
||||
function WallPanelIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="8" y="6" width="24" height="28" rx="2" fill={color} fillOpacity={0.2} />
|
||||
{[12.5, 20.5, 28.5].map((y) =>
|
||||
[13, 23].map((x) => (
|
||||
<path
|
||||
key={`${x}-${y}`}
|
||||
d={`M${x} ${y} q2 2.5 4 0`}
|
||||
stroke={color}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
<path d="M14 11 q1 -2.5 3 -3" stroke={LEAF} strokeWidth={1.4} />
|
||||
<path d="M24 19 q1 -2.5 3 -3" stroke={LEAF} strokeWidth={1.4} />
|
||||
<path d="M14 27 q1 -2.5 3 -3" stroke={LEAF} strokeWidth={1.4} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Structural lattice: frame with criss-cross diagonals. */
|
||||
function LatticeIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="7" y="7" width="26" height="26" rx="2" />
|
||||
<g stroke={color} strokeWidth={1.6}>
|
||||
<path d="M7 21 L21 7" />
|
||||
<path d="M7 33 L33 7" />
|
||||
<path d="M19 33 L33 19" />
|
||||
<path d="M19 7 L33 21" />
|
||||
<path d="M7 7 L33 33" />
|
||||
<path d="M7 19 L21 33" />
|
||||
</g>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Net pot: meshed cup with a seedling sprout. */
|
||||
function NetPotIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M11 19 H29 L25.5 33 H14.5 Z" fill={color} fillOpacity={0.35} />
|
||||
<path d="M14 22 L17 30 M19 22 L20 30 M24 22 L22 30" strokeWidth={1.3} strokeOpacity={0.7} />
|
||||
<rect x="8.5" y="15.5" width="23" height="4" rx="1.5" fill={color} fillOpacity={0.55} />
|
||||
<path d="M20 15 V8" stroke={LEAF} />
|
||||
<path d="M20 10 C16.5 10 14.5 7.5 14.5 4.5 C18 4.5 20 7 20 10 Z" fill={LEAF} stroke={LEAF} strokeWidth={1} />
|
||||
<path d="M20 10 C23.5 10 25.5 7.5 25.5 4.5 C22 4.5 20 7 20 10 Z" fill={LEAF} stroke={LEAF} strokeWidth={1} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spray emitter: nozzle with fanning spray and droplets. */
|
||||
function EmitterIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20 17 V10" stroke={color} strokeWidth={1.8} />
|
||||
<path d="M15.5 18.5 L10.5 12.5" stroke={color} strokeWidth={1.8} />
|
||||
<path d="M24.5 18.5 L29.5 12.5" stroke={color} strokeWidth={1.8} />
|
||||
<circle cx="20" cy="7.5" r="1.6" fill={color} stroke="none" />
|
||||
<circle cx="9" cy="10.5" r="1.6" fill={color} stroke="none" />
|
||||
<circle cx="31" cy="10.5" r="1.6" fill={color} stroke="none" />
|
||||
<rect x="17.5" y="20" width="5" height="5" rx="1" fill={color} fillOpacity={0.5} />
|
||||
<path d="M14 25 H26 L24 32 H16 Z" fill={color} fillOpacity={0.3} />
|
||||
<path d="M12 35 H28" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drain: funnel with grate slats and a falling droplet. */
|
||||
function DrainIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M7 9 H33 L24 21 V29 L16 26 V21 Z" fill={color} fillOpacity={0.3} />
|
||||
<path d="M14 12.5 V16" strokeWidth={1.5} strokeOpacity={0.8} />
|
||||
<path d="M20 12.5 V19.5" strokeWidth={1.5} strokeOpacity={0.8} />
|
||||
<path d="M26 12.5 V16" strokeWidth={1.5} strokeOpacity={0.8} />
|
||||
<path
|
||||
d="M20 31.5 C18 34.5 18.4 36.8 20 36.8 C21.6 36.8 22 34.5 20 31.5 Z"
|
||||
fill={color}
|
||||
stroke="none"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowTentIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M7 33 V10 L12 6 H28 L33 10 V33" fill={color} fillOpacity={0.18} />
|
||||
<path d="M12 6 V33 M28 6 V33 M7 15 H33" stroke={color} strokeWidth={1.6} />
|
||||
<path d="M20 15 V33" strokeWidth={1.4} strokeOpacity={0.7} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineFanIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="20" cy="20" r="11" fill={color} fillOpacity={0.25} />
|
||||
<circle cx="20" cy="20" r="4" fill={color} fillOpacity={0.6} />
|
||||
<path d="M20 9 C25 11 27 15 25 20" stroke={color} />
|
||||
<path d="M31 20 C29 25 25 27 20 25" stroke={color} />
|
||||
<path d="M20 31 C15 29 13 25 15 20" stroke={color} />
|
||||
<path d="M9 20 C11 15 15 13 20 15" stroke={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CirculationFanIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="20" cy="15" r="8" fill={color} fillOpacity={0.18} />
|
||||
<path d="M20 10 C23 11 24.5 13 23.5 16" stroke={color} />
|
||||
<path d="M25 15 C24 18 22 19.5 19 18.5" stroke={color} />
|
||||
<path d="M20 20 C17 19 15.5 17 16.5 14" stroke={color} />
|
||||
<path d="M20 23 V34" stroke={color} />
|
||||
<path d="M14 34 H26" stroke={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowLightIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="8" y="9" width="24" height="8" rx="2" fill={color} fillOpacity={0.4} />
|
||||
<path d="M12 9 L15 5 M28 9 L25 5" />
|
||||
<path d="M12 21 L10 27 M18 21 L18 29 M24 21 L26 27 M30 21 L32 27" stroke={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Co2TankIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="14" y="8" width="12" height="24" rx="4" fill={color} fillOpacity={0.25} />
|
||||
<rect x="17" y="5" width="6" height="4" rx="1.5" fill={color} fillOpacity={0.5} />
|
||||
<path d="M23 7 H28" />
|
||||
<text x="20" y="23" textAnchor="middle" fontSize="7" fill={STROKE} stroke="none">
|
||||
CO2
|
||||
</text>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback tile for unknown/new part types. */
|
||||
function GenericPartIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="9" y="9" width="22" height="22" rx="5" fill={color} fillOpacity={0.4} />
|
||||
<circle cx="20" cy="20" r="3" fill={color} stroke="none" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Air Compressor: rectangular box with nozzle and dial. */
|
||||
function AirCompressorIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="8" y="14" width="24" height="16" rx="3" fill={color} fillOpacity={0.25} />
|
||||
<circle cx="12" cy="32" r="2" fill={color} fillOpacity={0.6} />
|
||||
<circle cx="28" cy="32" r="2" fill={color} fillOpacity={0.6} />
|
||||
<rect x="30" y="19" width="4" height="4" fill={STROKE} />
|
||||
<circle cx="16" cy="22" r="3" fill={color} fillOpacity={0.4} />
|
||||
<line x1="16" y1="22" x2="18" y2="20" stroke={STROKE} strokeWidth={1.5} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed by part type string (not the PartType union) so new catalog
|
||||
* entries degrade gracefully to the generic tile until an icon exists.
|
||||
*/
|
||||
const PART_ICONS: Record<string, ComponentType<PartIconProps>> = {
|
||||
pipe: PipeIcon,
|
||||
elbow: ElbowIcon,
|
||||
tee: TeeIcon,
|
||||
valve: ValveIcon,
|
||||
pump: PumpIcon,
|
||||
reservoir: ReservoirIcon,
|
||||
tower: TowerIcon,
|
||||
tray: TrayIcon,
|
||||
wallPanel: WallPanelIcon,
|
||||
lattice: LatticeIcon,
|
||||
netPot: NetPotIcon,
|
||||
emitter: EmitterIcon,
|
||||
drain: DrainIcon,
|
||||
growTent: GrowTentIcon,
|
||||
inlineFan: InlineFanIcon,
|
||||
circulationFan: CirculationFanIcon,
|
||||
growLight: GrowLightIcon,
|
||||
co2Tank: Co2TankIcon,
|
||||
airCompressor: AirCompressorIcon,
|
||||
reducingTee: ReducingTeeIcon,
|
||||
reducingElbow: ReducingElbowIcon,
|
||||
bulkhead: BulkheadIcon,
|
||||
waterChiller: WaterChillerIcon,
|
||||
reverseOsmosis: ReverseOsmosisIcon,
|
||||
};
|
||||
|
||||
/** Reducing Tee: T-fitting with a thinner branch run. */
|
||||
function ReducingTeeIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M 6,18 L 34,18" stroke={color} strokeWidth={8} strokeLinecap="round" />
|
||||
<path d="M 6,18 L 34,18" stroke={STROKE} strokeWidth={1.5} />
|
||||
<path d="M 20,18 L 20,30" stroke={color} strokeWidth={4.5} strokeLinecap="round" />
|
||||
<path d="M 20,18 L 20,30" stroke={STROKE} strokeWidth={1.2} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reducing Elbow: 90-degree bend with a tapered arc. */
|
||||
function ReducingElbowIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M 8,28 A 12,12 0 0,1 28,8" fill="none" stroke={color} strokeWidth={7} strokeLinecap="round" />
|
||||
<path d="M 8,28 A 12,12 0 0,1 28,8" fill="none" stroke={STROKE} strokeWidth={1.5} />
|
||||
<line x1="8" y1="28" x2="12" y2="28" stroke={STROKE} strokeWidth={1.5} />
|
||||
<line x1="28" y1="8" x2="28" y2="12" stroke={STROKE} strokeWidth={1.5} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bulkhead: flange fitting representing tank connection. */
|
||||
function BulkheadIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="18" y="6" width="4" height="28" fill={color} fillOpacity={0.4} stroke={color} strokeWidth={1.2} />
|
||||
<rect x="10" y="14" width="20" height="12" fill={color} fillOpacity={0.25} rx="1" />
|
||||
<rect x="12" y="16" width="16" height="8" fill="none" stroke={STROKE} strokeWidth={1.5} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Water Chiller: cool metal cabinet with ports and digital display. */
|
||||
function WaterChillerIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="8" y="12" width="24" height="22" rx="2" fill={color} fillOpacity={0.25} />
|
||||
<rect x="12" y="7" width="3" height="5" fill={color} />
|
||||
<rect x="25" y="7" width="3" height="5" fill={color} />
|
||||
<rect x="14" y="17" width="12" height="6" fill="#09090b" rx="1" />
|
||||
<circle cx="20" cy="20" r="1.5" fill="#22c55e" />
|
||||
<line x1="12" y1="27" x2="28" y2="27" stroke={STROKE} strokeWidth={1.2} />
|
||||
<line x1="12" y1="30" x2="28" y2="30" stroke={STROKE} strokeWidth={1.2} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reverse Osmosis Filter: inline blue canister filter with inlet/outlet. */
|
||||
function ReverseOsmosisIcon({ color, className }: PartIconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="4" y="18" width="32" height="4" rx="1" fill={color} fillOpacity={0.4} />
|
||||
<rect x="11" y="10" width="18" height="20" rx="3" fill={color} fillOpacity={0.25} />
|
||||
<line x1="11" y1="15" x2="29" y2="15" stroke={STROKE} strokeWidth={1.5} />
|
||||
<line x1="11" y1="20" x2="29" y2="20" stroke={STROKE} strokeWidth={1.5} />
|
||||
<line x1="11" y1="25" x2="29" y2="25" stroke={STROKE} strokeWidth={1.5} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PartIcon({
|
||||
type,
|
||||
color,
|
||||
className,
|
||||
}: {
|
||||
type: string;
|
||||
/** Accent color override; defaults to the catalog color for the type. */
|
||||
color?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = PART_ICONS[type] ?? GenericPartIcon;
|
||||
const accent =
|
||||
color ?? (PART_DEFS as Record<string, { color: string } | undefined>)[type]?.color ?? '#71717a';
|
||||
return <Icon color={accent} className={className} />;
|
||||
}
|
||||
1235
src/components/waterVisuals.tsx
Normal file
1235
src/components/waterVisuals.tsx
Normal file
File diff suppressed because it is too large
Load Diff
162
src/environment/roomMetrics.ts
Normal file
162
src/environment/roomMetrics.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { PlacedPart } from '../types';
|
||||
|
||||
export interface RoomMetrics {
|
||||
roomId: string;
|
||||
label: string;
|
||||
sealed: boolean;
|
||||
volumeFt3: number;
|
||||
exhaustCfm: number;
|
||||
circulationCfm: number;
|
||||
lightWatts: number;
|
||||
co2TankCount: number;
|
||||
co2Cfh: number;
|
||||
airChangesPerMinute: number;
|
||||
airChangesPerHour: number;
|
||||
roomPPFD: number;
|
||||
roomDLI: number;
|
||||
roomTempF: number;
|
||||
roomRH: number;
|
||||
roomVPD: number;
|
||||
roomCo2: number;
|
||||
sensibleHeatGainsBtuHr: number;
|
||||
latentCoolingBtuHr: number;
|
||||
acSizingBtuHr: number;
|
||||
dehumidifierPintsDay: number;
|
||||
}
|
||||
|
||||
function contains(room: PlacedPart, part: PlacedPart): boolean {
|
||||
const width = room.params.width ?? 0;
|
||||
const depth = room.params.depth ?? 0;
|
||||
const height = room.params.height ?? 0;
|
||||
const [rx, ry, rz] = room.position;
|
||||
const [px, py, pz] = part.position;
|
||||
return (
|
||||
px >= rx - width / 2 &&
|
||||
px <= rx + width / 2 &&
|
||||
pz >= rz - depth / 2 &&
|
||||
pz <= rz + depth / 2 &&
|
||||
py >= ry &&
|
||||
py <= ry + height
|
||||
);
|
||||
}
|
||||
|
||||
export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMetrics[] {
|
||||
const parts = Object.values(partsMap);
|
||||
const rooms = parts.filter((part) => part.type === 'growTent');
|
||||
|
||||
return rooms.map((room) => {
|
||||
const roomParts = parts.filter((part) => part.id !== room.id && contains(room, part));
|
||||
const exhaustCfm = roomParts
|
||||
.filter((part) => part.type === 'inlineFan' && (part.params.exhaust ?? 1) > 0)
|
||||
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
|
||||
const circulationCfm = roomParts
|
||||
.filter((part) => part.type === 'circulationFan')
|
||||
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
|
||||
const lightWatts = roomParts
|
||||
.filter((part) => part.type === 'growLight')
|
||||
.reduce((sum, part) => sum + (part.params.watts ?? 0), 0);
|
||||
const co2 = roomParts.filter((part) => part.type === 'co2Tank');
|
||||
const co2Cfh = co2.reduce((sum, part) => sum + (part.params.regulatorCfh ?? 0), 0);
|
||||
const volumeFt3 =
|
||||
(room.params.width ?? 0) * (room.params.depth ?? 0) * (room.params.height ?? 0);
|
||||
const airChangesPerMinute = volumeFt3 > 0 ? exhaustCfm / volumeFt3 : 0;
|
||||
|
||||
const canopyArea = (room.params.width ?? 0) * (room.params.depth ?? 0);
|
||||
const roomPPFD = canopyArea > 0 ? (lightWatts * 2.0) / canopyArea : 0;
|
||||
|
||||
const lights = roomParts.filter((part) => part.type === 'growLight');
|
||||
const roomDLI = canopyArea > 0 && lights.length > 0
|
||||
? lights.reduce((sum, light) => {
|
||||
const w = light.params.watts ?? 0;
|
||||
const p = light.params.photoperiod ?? 18;
|
||||
const lightPPFD = (w * 2.0) / canopyArea;
|
||||
return sum + lightPPFD * p * 0.0036;
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
const activeLightWatts = lights.reduce((sum, light) => {
|
||||
const p = light.params.photoperiod ?? 18;
|
||||
const w = light.params.watts ?? 0;
|
||||
return sum + (p > 0 ? w : 0);
|
||||
}, 0);
|
||||
const roomTempF = 75 + 0.01 * activeLightWatts;
|
||||
const roomRH = room.params.humidity ?? 60;
|
||||
const T_c = (roomTempF - 32) * 5 / 9;
|
||||
const SVP = 0.61078 * Math.exp((17.27 * T_c) / (T_c + 237.3));
|
||||
const roomVPD = SVP * (1 - roomRH / 100);
|
||||
|
||||
const isSealed = (room.params.sealed ?? 0) > 0;
|
||||
const hasLightsOn = activeLightWatts > 0;
|
||||
let roomCo2 = 400;
|
||||
if (isSealed && hasLightsOn) {
|
||||
if (co2.length === 0) {
|
||||
roomCo2 = 150;
|
||||
} else {
|
||||
roomCo2 = 1200;
|
||||
}
|
||||
} else if (co2.length > 0) {
|
||||
roomCo2 = 1200;
|
||||
}
|
||||
|
||||
// 1. Sensible Heat Gains (BTU/hr)
|
||||
const sensibleHeatGainsBtuHr = activeLightWatts * 3.412;
|
||||
|
||||
// 2. Canopy Transpiration Latent Cooling (BTU/hr)
|
||||
const f_VPD = Math.max(0.1, Math.min(1.0, roomVPD / 1.2));
|
||||
const transpirationPintsDay = activeLightWatts * 0.032 * f_VPD;
|
||||
const latentCoolingBtuHr = (transpirationPintsDay / 24) * 1060;
|
||||
|
||||
// 3. Humidity Balance
|
||||
const e_room = SVP * (roomRH / 100);
|
||||
const rho_w_room = (0.01352 * e_room) / (T_c + 273.15);
|
||||
|
||||
// Outside reference conditions at 75°F and 50% RH
|
||||
const T_c_amb = (75 - 32) * 5 / 9;
|
||||
const SVP_amb = 0.61078 * Math.exp((17.27 * T_c_amb) / (T_c_amb + 237.3));
|
||||
const e_amb = SVP_amb * 0.50;
|
||||
const rho_w_amb = (0.01352 * e_amb) / (T_c_amb + 273.15);
|
||||
|
||||
// Moisture removal via exhaust ventilation
|
||||
let moistureVentPintsDay = 0;
|
||||
if (!isSealed && exhaustCfm > 0) {
|
||||
moistureVentPintsDay = Math.max(0, exhaustCfm * 1381 * (rho_w_room - rho_w_amb));
|
||||
}
|
||||
|
||||
const dehumidifierPintsDay = Math.max(0, transpirationPintsDay - moistureVentPintsDay);
|
||||
|
||||
// 4. AC Sizing Sizing (BTU/hr)
|
||||
let ventSensibleBtuHr = 0;
|
||||
if (!isSealed && exhaustCfm > 0) {
|
||||
ventSensibleBtuHr = exhaustCfm * 1.08 * (roomTempF - 75);
|
||||
}
|
||||
|
||||
const dehumHeatBtuHr = (dehumidifierPintsDay / 24) * 1200;
|
||||
|
||||
const netSensibleBtuHr = sensibleHeatGainsBtuHr - latentCoolingBtuHr - ventSensibleBtuHr + dehumHeatBtuHr;
|
||||
const acSizingBtuHr = Math.max(0, netSensibleBtuHr * 1.15);
|
||||
|
||||
return {
|
||||
roomId: room.id,
|
||||
label: room.label ?? 'Grow room',
|
||||
sealed: isSealed,
|
||||
volumeFt3,
|
||||
exhaustCfm,
|
||||
circulationCfm,
|
||||
lightWatts,
|
||||
co2TankCount: co2.length,
|
||||
co2Cfh,
|
||||
airChangesPerMinute,
|
||||
airChangesPerHour: airChangesPerMinute * 60,
|
||||
roomPPFD,
|
||||
roomDLI,
|
||||
roomTempF,
|
||||
roomRH,
|
||||
roomVPD,
|
||||
roomCo2,
|
||||
sensibleHeatGainsBtuHr,
|
||||
latentCoolingBtuHr,
|
||||
acSizingBtuHr,
|
||||
dehumidifierPintsDay,
|
||||
};
|
||||
});
|
||||
}
|
||||
159
src/hooks/useCollab.ts
Normal file
159
src/hooks/useCollab.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
const colors = [
|
||||
'#38bdf8',
|
||||
'#3d7bfa',
|
||||
'#34d399',
|
||||
'#3b7a57',
|
||||
'#fbbf24',
|
||||
'#f97316',
|
||||
'#e0564f',
|
||||
'#a78bfa',
|
||||
];
|
||||
|
||||
const stringToColor = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const index = Math.abs(hash) % colors.length;
|
||||
return colors[index];
|
||||
};
|
||||
|
||||
export interface RemoteCursor {
|
||||
position: [number, number, number];
|
||||
userName: string;
|
||||
color: string;
|
||||
activeRoomId: string;
|
||||
}
|
||||
|
||||
export function useCollab() {
|
||||
const { user } = useAuth();
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const projectName = useBuilder((s) => s.projectName);
|
||||
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const localPartsRef = useRef(parts);
|
||||
const isIncomingUpdateRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsPort = window.location.port === '5173' ? '3000' : window.location.port;
|
||||
const wsUrl = `${wsProto}//${window.location.hostname}${wsPort ? `:${wsPort}` : ''}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
const color = stringToColor(user.email);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'join',
|
||||
projectRoom: projectName,
|
||||
userId: user.id,
|
||||
userName: user.email,
|
||||
color: color,
|
||||
activeRoomId: useBuilder.getState().activeRoomId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'mutation') {
|
||||
isIncomingUpdateRef.current = true;
|
||||
localPartsRef.current = data.parts;
|
||||
useBuilder.getState().syncParts(data.parts);
|
||||
} else if (data.type === 'cursor') {
|
||||
setRemoteCursors((prev) => ({
|
||||
...prev,
|
||||
[data.userId]: {
|
||||
position: data.position,
|
||||
userName: data.userName,
|
||||
color: data.color,
|
||||
activeRoomId: data.activeRoomId,
|
||||
},
|
||||
}));
|
||||
} else if (data.type === 'leave') {
|
||||
setRemoteCursors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[data.userId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error handling WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Collaboration WebSocket disconnected');
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('Collaboration WebSocket error:', err);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [user, projectName]);
|
||||
|
||||
// Sync local changes to remote
|
||||
useEffect(() => {
|
||||
if (isIncomingUpdateRef.current) {
|
||||
isIncomingUpdateRef.current = false;
|
||||
localPartsRef.current = parts;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts !== localPartsRef.current) {
|
||||
localPartsRef.current = parts;
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'mutation',
|
||||
parts,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [parts]);
|
||||
|
||||
// Sync cursor movement
|
||||
useEffect(() => {
|
||||
let lastSent = 0;
|
||||
const handleCursor = (e: Event) => {
|
||||
const now = Date.now();
|
||||
if (now - lastSent < 50) return; // rate limit to 20fps
|
||||
lastSent = now;
|
||||
|
||||
const pos = (e as CustomEvent).detail;
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'cursor',
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
activeRoomId: useBuilder.getState().activeRoomId,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('collab:cursor', handleCursor);
|
||||
return () => {
|
||||
window.removeEventListener('collab:cursor', handleCursor);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { remoteCursors };
|
||||
}
|
||||
59
src/index.css
Normal file
59
src/index.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0e13;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Slim dark scrollbars */
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
@utility animate-toast-in {
|
||||
animation: toast-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@utility animate-toast-out {
|
||||
animation: toast-out 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
504
src/parts/catalog.ts
Normal file
504
src/parts/catalog.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import type { ConnectorDef, PartDefinition, PartType } from '../types';
|
||||
|
||||
/**
|
||||
* Part catalog — the single source of truth for every placeable part:
|
||||
* default parameters, inspector metadata, and connector layouts.
|
||||
*
|
||||
* To add a new part type:
|
||||
* 1. Add its name to `PartType` in types.ts.
|
||||
* 2. Add an entry here (defaults, params, connectors).
|
||||
* 3. Add a mesh case in components/PartMesh.tsx.
|
||||
* 4. (Optional) tune its resistance in simulation/flowSimulator.ts.
|
||||
*/
|
||||
|
||||
/** Bend radius used by elbows (ft). */
|
||||
export const ELBOW_BEND_RADIUS = 0.5;
|
||||
/** Supported nominal pipe sizes (in) used across plumbing parts. */
|
||||
export const STANDARD_PIPE_SIZES = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4] as const;
|
||||
export const DEFAULT_PIPE_SIZE = 1;
|
||||
|
||||
/** Visual radius (ft) for a pipe of `d` inches diameter. */
|
||||
export const pipeRadius = (d: number) => 0.05 + d * 0.055;
|
||||
|
||||
const deg = (d: number) => (d * Math.PI) / 180;
|
||||
|
||||
/** Connector layout for an elbow with a configurable sweep angle. */
|
||||
function elbowConnectors(params: Record<string, number>): ConnectorDef[] {
|
||||
const B = ELBOW_BEND_RADIUS;
|
||||
// Arc starts at local origin pointing -X and sweeps toward +Y.
|
||||
const t = deg(params.angle ?? 90) - Math.PI / 2;
|
||||
return [
|
||||
{ id: 'a', pos: [0, 0, 0], dir: [-1, 0, 0] },
|
||||
{
|
||||
id: 'b',
|
||||
pos: [B * Math.cos(t), B * Math.sin(t) + B, 0],
|
||||
dir: [-Math.sin(t), Math.cos(t), 0],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function snapPipeSize(value: number): number {
|
||||
let best: number = STANDARD_PIPE_SIZES[0];
|
||||
let bestDist = Math.abs(value - best);
|
||||
for (const size of STANDARD_PIPE_SIZES) {
|
||||
const dist = Math.abs(value - size);
|
||||
if (dist < bestDist) {
|
||||
best = size;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export const PART_DEFS: Record<PartType, PartDefinition> = {
|
||||
pipe: {
|
||||
type: 'pipe',
|
||||
label: 'Pipe',
|
||||
category: 'Plumbing',
|
||||
description: 'Straight pipe segment',
|
||||
color: '#b8bfc9',
|
||||
defaults: { length: 2, diameter: DEFAULT_PIPE_SIZE },
|
||||
params: {
|
||||
length: { label: 'Length', min: 0.5, max: 12, step: 0.5, unit: 'ft' },
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: (p) => [
|
||||
{ id: 'a', pos: [-p.length / 2, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [p.length / 2, 0, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
coupling: {
|
||||
type: 'coupling',
|
||||
label: 'Coupling',
|
||||
category: 'Plumbing',
|
||||
description: 'Straight fitting for joining two pipe ends',
|
||||
color: '#aeb7c2',
|
||||
defaults: { diameter: DEFAULT_PIPE_SIZE },
|
||||
params: {
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [-0.3, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.3, 0, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
reducer: {
|
||||
type: 'reducer',
|
||||
label: 'Reducer',
|
||||
category: 'Plumbing',
|
||||
description: 'Inline reducer fitting between two pipe diameters',
|
||||
color: '#a8b2bf',
|
||||
defaults: { diameterA: 1.5, diameterB: DEFAULT_PIPE_SIZE },
|
||||
params: {
|
||||
diameterA: { label: 'Diameter A', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
diameterB: { label: 'Diameter B', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [-0.35, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.35, 0, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
cap: {
|
||||
type: 'cap',
|
||||
label: 'Cap',
|
||||
category: 'Plumbing',
|
||||
description: 'End cap that terminates a pipe run',
|
||||
color: '#9aa3b0',
|
||||
defaults: { diameter: DEFAULT_PIPE_SIZE },
|
||||
params: {
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [{ id: 'in', pos: [-0.22, 0, 0], dir: [-1, 0, 0] }],
|
||||
},
|
||||
|
||||
elbow: {
|
||||
type: 'elbow',
|
||||
label: 'Elbow',
|
||||
category: 'Plumbing',
|
||||
description: 'Bend fitting (15–180°)',
|
||||
color: '#9aa3b0',
|
||||
defaults: { angle: 90, diameter: DEFAULT_PIPE_SIZE },
|
||||
params: {
|
||||
angle: { label: 'Bend angle', min: 15, max: 180, step: 15, unit: '°' },
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: elbowConnectors,
|
||||
},
|
||||
|
||||
tee: {
|
||||
type: 'tee',
|
||||
label: 'T-Joint',
|
||||
category: 'Plumbing',
|
||||
description: '3-way splitter fitting',
|
||||
color: '#9aa3b0',
|
||||
defaults: { diameter: DEFAULT_PIPE_SIZE },
|
||||
params: {
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [-0.6, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.6, 0, 0], dir: [1, 0, 0] },
|
||||
{ id: 'c', pos: [0, 0, 0.6], dir: [0, 0, 1] },
|
||||
],
|
||||
},
|
||||
|
||||
valve: {
|
||||
type: 'valve',
|
||||
label: 'Valve',
|
||||
category: 'Flow Control',
|
||||
description: 'Inline ball valve (0–100% open)',
|
||||
color: '#e0564f',
|
||||
defaults: { diameter: DEFAULT_PIPE_SIZE, open: 100 },
|
||||
params: {
|
||||
open: { label: 'Open', min: 0, max: 100, step: 5, unit: '%' },
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [-0.35, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.35, 0, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
pump: {
|
||||
type: 'pump',
|
||||
label: 'Pump',
|
||||
category: 'Flow Control',
|
||||
description: 'Submersible/inline pump — pressure source',
|
||||
color: '#3d7bfa',
|
||||
// `on` (0|1) is intentionally NOT in params meta — the inspector renders a
|
||||
// custom power toggle for it. The simulator treats on === 0 as "switched off".
|
||||
defaults: { gph: 400, maxHeadFt: 8, on: 1 },
|
||||
params: {
|
||||
gph: { label: 'Rated flow', min: 50, max: 2000, step: 25, unit: 'GPH' },
|
||||
maxHeadFt: { label: 'Max head', min: 2, max: 30, step: 1, unit: 'ft' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'in', pos: [-0.45, 0.25, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'out', pos: [0.45, 0.25, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
reservoir: {
|
||||
type: 'reservoir',
|
||||
label: 'Reservoir',
|
||||
category: 'Flow Control',
|
||||
description: 'Water tank — source & return sink',
|
||||
color: '#2a3950',
|
||||
defaults: { width: 2, depth: 1.4, height: 1, level: 70, ph: 6.0, ec: 1.2 },
|
||||
params: {
|
||||
width: { label: 'Width', min: 1, max: 6, step: 0.5, unit: 'ft' },
|
||||
depth: { label: 'Depth', min: 1, max: 6, step: 0.5, unit: 'ft' },
|
||||
height: { label: 'Height', min: 0.5, max: 4, step: 0.25, unit: 'ft' },
|
||||
level: { label: 'Fill level', min: 0, max: 100, step: 5, unit: '%' },
|
||||
ph: { label: 'Water pH', min: 4.0, max: 8.5, step: 0.1, unit: '' },
|
||||
ec: { label: 'Water EC', min: 0.0, max: 3.0, step: 0.1, unit: 'mS/cm' },
|
||||
},
|
||||
getConnectors: (p) => [
|
||||
{ id: 'a', pos: [-p.width / 2, 0.25, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [p.width / 2, 0.25, 0], dir: [1, 0, 0] },
|
||||
{ id: 'c', pos: [0, 0.25, -p.depth / 2], dir: [0, 0, -1] },
|
||||
{ id: 'd', pos: [0, 0.25, p.depth / 2], dir: [0, 0, 1] },
|
||||
],
|
||||
},
|
||||
|
||||
tower: {
|
||||
type: 'tower',
|
||||
label: 'Grow Tower',
|
||||
category: 'Growing',
|
||||
description: 'Vertical NFT tower with plant sites',
|
||||
color: '#e8eaed',
|
||||
defaults: { height: 4, sites: 6 },
|
||||
params: {
|
||||
height: { label: 'Height', min: 2, max: 10, step: 0.5, unit: 'ft' },
|
||||
sites: { label: 'Plant sites', min: 2, max: 16, step: 1 },
|
||||
},
|
||||
getConnectors: (p) => [
|
||||
{ id: 'in', pos: [0, p.height, 0], dir: [0, 1, 0] },
|
||||
{ id: 'out', pos: [0.45, 0.15, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
tray: {
|
||||
type: 'tray',
|
||||
label: 'Grow Tray',
|
||||
category: 'Growing',
|
||||
description: 'Flood/NFT channel tray',
|
||||
color: '#d7dce2',
|
||||
defaults: { length: 3, width: 1.2 },
|
||||
params: {
|
||||
length: { label: 'Length', min: 1, max: 8, step: 0.5, unit: 'ft' },
|
||||
width: { label: 'Width', min: 0.5, max: 4, step: 0.25, unit: 'ft' },
|
||||
},
|
||||
getConnectors: (p) => [
|
||||
{ id: 'in', pos: [-p.length / 2, 0.15, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'out', pos: [p.length / 2, 0.15, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
wallPanel: {
|
||||
type: 'wallPanel',
|
||||
label: 'Wall Panel',
|
||||
category: 'Growing',
|
||||
description: 'Vertical grow wall with pockets',
|
||||
color: '#3b7a57',
|
||||
defaults: { width: 3, height: 4, rows: 4, cols: 5 },
|
||||
params: {
|
||||
width: { label: 'Width', min: 1, max: 8, step: 0.5, unit: 'ft' },
|
||||
height: { label: 'Height', min: 1, max: 8, step: 0.5, unit: 'ft' },
|
||||
rows: { label: 'Pocket rows', min: 1, max: 8, step: 1 },
|
||||
cols: { label: 'Pocket cols', min: 1, max: 10, step: 1 },
|
||||
},
|
||||
getConnectors: (p) => [
|
||||
{ id: 'in', pos: [0, p.height, 0], dir: [0, 1, 0] },
|
||||
{ id: 'out', pos: [0, 0.05, 0], dir: [0, -1, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
lattice: {
|
||||
type: 'lattice',
|
||||
label: 'Lattice',
|
||||
category: 'Structure',
|
||||
description: 'Structural lattice frame (no flow)',
|
||||
color: '#8a7a5c',
|
||||
defaults: { width: 2.5, height: 3 },
|
||||
params: {
|
||||
width: { label: 'Width', min: 1, max: 8, step: 0.5, unit: 'ft' },
|
||||
height: { label: 'Height', min: 1, max: 8, step: 0.5, unit: 'ft' },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
netPot: {
|
||||
type: 'netPot',
|
||||
label: 'Net Pot',
|
||||
category: 'Growing',
|
||||
description: 'Plant cup with seedling (decorative)',
|
||||
color: '#222a33',
|
||||
defaults: { size: 1 },
|
||||
params: {
|
||||
size: { label: 'Size', min: 0.5, max: 2, step: 0.25 },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
emitter: {
|
||||
type: 'emitter',
|
||||
label: 'Emitter',
|
||||
category: 'Flow Control',
|
||||
description: 'Drip/spray outlet — terminates a line',
|
||||
color: '#46c0e8',
|
||||
defaults: {},
|
||||
params: {},
|
||||
getConnectors: () => [{ id: 'in', pos: [0.2, 0, 0], dir: [1, 0, 0] }],
|
||||
},
|
||||
|
||||
drain: {
|
||||
type: 'drain',
|
||||
label: 'Drain',
|
||||
category: 'Flow Control',
|
||||
description: 'Floor drain — limited-capacity exit point',
|
||||
color: '#64748b',
|
||||
defaults: { capacityGph: 250 },
|
||||
params: {
|
||||
capacityGph: { label: 'Capacity', min: 50, max: 2000, step: 25, unit: 'GPH' },
|
||||
},
|
||||
getConnectors: () => [{ id: 'in', pos: [-0.35, 0.25, 0], dir: [-1, 0, 0] }],
|
||||
},
|
||||
|
||||
growTent: {
|
||||
type: 'growTent',
|
||||
label: 'Grow Tent',
|
||||
category: 'Environment',
|
||||
description: 'Room/tent enclosure for planning air exchange and equipment',
|
||||
color: '#475569',
|
||||
defaults: { width: 7, depth: 5, height: 4, sealed: 0 },
|
||||
params: {
|
||||
width: { label: 'Width', min: 2, max: 20, step: 0.5, unit: 'ft' },
|
||||
depth: { label: 'Depth', min: 2, max: 20, step: 0.5, unit: 'ft' },
|
||||
height: { label: 'Height', min: 2, max: 12, step: 0.5, unit: 'ft' },
|
||||
sealed: { label: 'Sealed room', min: 0, max: 1, step: 1 },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
inlineFan: {
|
||||
type: 'inlineFan',
|
||||
label: 'Inline Fan',
|
||||
category: 'Environment',
|
||||
description: 'Duct fan for room exhaust/intake calculations',
|
||||
color: '#0f766e',
|
||||
defaults: { cfm: 600, diameter: 6, exhaust: 1 },
|
||||
params: {
|
||||
cfm: { label: 'Airflow', min: 50, max: 4000, step: 25, unit: 'CFM' },
|
||||
diameter: { label: 'Duct size', min: 4, max: 14, step: 2, unit: 'in' },
|
||||
exhaust: { label: 'Exhaust mode', min: 0, max: 1, step: 1 },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
circulationFan: {
|
||||
type: 'circulationFan',
|
||||
label: 'Circulation Fan',
|
||||
category: 'Environment',
|
||||
description: 'Internal airflow fan for canopy mixing',
|
||||
color: '#2563eb',
|
||||
defaults: { cfm: 250, sweep: 90 },
|
||||
params: {
|
||||
cfm: { label: 'Airflow', min: 50, max: 1500, step: 25, unit: 'CFM' },
|
||||
sweep: { label: 'Sweep', min: 0, max: 180, step: 15, unit: '°' },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
growLight: {
|
||||
type: 'growLight',
|
||||
label: 'Grow Light',
|
||||
category: 'Environment',
|
||||
description: 'Overhead grow fixture for room planning',
|
||||
color: '#fbbf24',
|
||||
defaults: { width: 2, depth: 2, watts: 650, hangingHeight: 1.5, photoperiod: 18 },
|
||||
params: {
|
||||
width: { label: 'Width', min: 0.5, max: 6, step: 0.25, unit: 'ft' },
|
||||
depth: { label: 'Depth', min: 0.5, max: 6, step: 0.25, unit: 'ft' },
|
||||
watts: { label: 'Power', min: 50, max: 1500, step: 25, unit: 'W' },
|
||||
hangingHeight: { label: 'Hang height', min: 0.25, max: 6, step: 0.25, unit: 'ft' },
|
||||
photoperiod: { label: 'Hours On', min: 0, max: 24, step: 1, unit: 'h' },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
co2Tank: {
|
||||
type: 'co2Tank',
|
||||
label: 'CO₂ Tank',
|
||||
category: 'Environment',
|
||||
description: 'CO₂ cylinder for sealed-room enrichment planning',
|
||||
color: '#22c55e',
|
||||
defaults: { pounds: 20, regulatorCfh: 20 },
|
||||
params: {
|
||||
pounds: { label: 'Tank size', min: 5, max: 100, step: 5, unit: 'lb' },
|
||||
regulatorCfh: { label: 'Release rate', min: 1, max: 100, step: 1, unit: 'CFH' },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
airCompressor: {
|
||||
type: 'airCompressor',
|
||||
label: 'Air Compressor',
|
||||
category: 'Flow Control',
|
||||
description: 'Aerate reservoir water with an attached airstone',
|
||||
color: '#0ea5e9',
|
||||
defaults: { gph: 120, frequency: 5, on: 1 },
|
||||
params: {
|
||||
gph: { label: 'Air Flow', min: 20, max: 400, step: 10, unit: 'GPH' },
|
||||
frequency: { label: 'Bubble Freq', min: 1, max: 10, step: 1, unit: 'Hz' },
|
||||
},
|
||||
getConnectors: () => [],
|
||||
},
|
||||
|
||||
reducingTee: {
|
||||
type: 'reducingTee',
|
||||
label: 'Reducing Tee',
|
||||
category: 'Plumbing',
|
||||
description: 'Tee branch with a reduced secondary outlet size',
|
||||
color: '#8b9bb4',
|
||||
defaults: { diameterA: 1.5, diameterB: 1.0 },
|
||||
params: {
|
||||
diameterA: { label: 'Main Run Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
diameterB: { label: 'Branch Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [-0.6, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.6, 0, 0], dir: [1, 0, 0] },
|
||||
{ id: 'c', pos: [0, 0, 0.6], dir: [0, 0, 1] },
|
||||
],
|
||||
},
|
||||
|
||||
reducingElbow: {
|
||||
type: 'reducingElbow',
|
||||
label: 'Reducing Elbow',
|
||||
category: 'Plumbing',
|
||||
description: '90-degree elbow transitioning between two diameters',
|
||||
color: '#9aa3b0',
|
||||
defaults: { diameterA: 1.5, diameterB: 1.0 },
|
||||
params: {
|
||||
diameterA: { label: 'Diameter A', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
diameterB: { label: 'Diameter B', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [0, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.18, 0.18, 0], dir: [0, 1, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
bulkhead: {
|
||||
type: 'bulkhead',
|
||||
label: 'Bulkhead Fitting',
|
||||
category: 'Plumbing',
|
||||
description: 'Tank wall pass-through connector',
|
||||
color: '#27272a',
|
||||
defaults: { diameter: 1.0 },
|
||||
params: {
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [-0.2, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.2, 0, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
waterChiller: {
|
||||
type: 'waterChiller',
|
||||
label: 'Water Chiller',
|
||||
category: 'Flow Control',
|
||||
description: 'Thermoelectric water chiller to keep reservoirs cool',
|
||||
color: '#0891b2',
|
||||
defaults: { targetTemp: 68, on: 1, diameter: 1.0 },
|
||||
params: {
|
||||
targetTemp: { label: 'Target Temp', min: 45, max: 80, step: 1, unit: '°F' },
|
||||
diameter: { label: 'Port Size', min: 0.5, max: 2.0, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'in', pos: [-0.2, 1.05, 0], dir: [0, 1, 0] },
|
||||
{ id: 'out', pos: [0.2, 1.05, 0], dir: [0, 1, 0] },
|
||||
],
|
||||
},
|
||||
|
||||
reverseOsmosis: {
|
||||
type: 'reverseOsmosis',
|
||||
label: 'RO Filter',
|
||||
category: 'Plumbing',
|
||||
description: 'Reverse osmosis filter to purify source water',
|
||||
color: '#3b82f6',
|
||||
defaults: { diameter: 1.0 },
|
||||
params: {
|
||||
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
|
||||
},
|
||||
getConnectors: () => [
|
||||
{ id: 'a', pos: [-0.3, 0, 0], dir: [-1, 0, 0] },
|
||||
{ id: 'b', pos: [0.3, 0, 0], dir: [1, 0, 0] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const CATEGORIES: { name: string; types: PartType[] }[] = [
|
||||
{ name: 'Plumbing', types: ['pipe', 'coupling', 'reducer', 'cap', 'elbow', 'tee', 'reducingTee', 'reducingElbow', 'bulkhead', 'reverseOsmosis'] },
|
||||
{ name: 'Flow Control', types: ['pump', 'reservoir', 'valve', 'emitter', 'drain', 'airCompressor', 'waterChiller'] },
|
||||
{ name: 'Growing', types: ['tower', 'tray', 'wallPanel', 'netPot'] },
|
||||
{ name: 'Structure', types: ['lattice'] },
|
||||
{ name: 'Environment', types: ['growTent', 'inlineFan', 'circulationFan', 'growLight', 'co2Tank'] },
|
||||
];
|
||||
|
||||
/** Parts that carry water (participate in the flow graph). */
|
||||
export const FLOW_PARTS = new Set<PartType>([
|
||||
'pipe', 'coupling', 'reducer', 'cap', 'elbow', 'tee', 'valve', 'pump', 'reservoir', 'tower', 'tray', 'wallPanel', 'emitter', 'drain',
|
||||
'reducingTee', 'reducingElbow', 'bulkhead', 'waterChiller', 'reverseOsmosis',
|
||||
]);
|
||||
|
||||
/** Plumbing parts whose unconnected ends count as leaks. */
|
||||
export const OPEN_END_WARN_PARTS = new Set<PartType>([
|
||||
'pipe', 'coupling', 'reducer', 'elbow', 'tee', 'valve', 'pump',
|
||||
'reducingTee', 'reducingElbow', 'bulkhead', 'waterChiller', 'reverseOsmosis',
|
||||
]);
|
||||
19
src/parts/hotbar.ts
Normal file
19
src/parts/hotbar.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PartType } from '../types';
|
||||
|
||||
/** Default builder-first hotbar order for fast keyboard placement. */
|
||||
export const HOTBAR_PARTS: PartType[] = [
|
||||
'pipe',
|
||||
'elbow',
|
||||
'tee',
|
||||
'valve',
|
||||
'pump',
|
||||
'reservoir',
|
||||
'tower',
|
||||
'tray',
|
||||
'emitter',
|
||||
];
|
||||
|
||||
export function getHotbarPartForKey(key: string): PartType | null {
|
||||
const idx = Number(key) - 1;
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < HOTBAR_PARTS.length ? HOTBAR_PARTS[idx] : null;
|
||||
}
|
||||
114
src/payments/PaywallModal.tsx
Normal file
114
src/payments/PaywallModal.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
|
||||
interface PaywallModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PaywallModal: React.FC<PaywallModalProps> = ({ onClose }) => {
|
||||
const { token, user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/payments/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create payment session');
|
||||
}
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
throw new Error('No checkout URL received');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-zinc-850 bg-zinc-900 shadow-2xl">
|
||||
{/* Decorative Top Banner */}
|
||||
<div className="h-2 bg-gradient-to-r from-sky-400 via-blue-500 to-indigo-600" />
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Crown Icon / Visual Asset */}
|
||||
<div className="mb-4 grid h-14 w-14 place-items-center rounded-full bg-sky-500/10 text-2xl text-sky-400">
|
||||
💎
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-zinc-100">
|
||||
Unlock Premium Features
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 text-xs text-zinc-400 leading-relaxed px-2">
|
||||
Free profiles do not have auto-saves or cloud build spaces. Upgrade to Premium permanently to enable browser auto-saves and unlock 10 cloud project slots.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Card */}
|
||||
<div className="my-6 rounded-xl border border-zinc-800 bg-zinc-950/50 p-5 text-center">
|
||||
<span className="text-xs font-semibold text-sky-400 uppercase tracking-wider">
|
||||
Premium Lifetime Upgrade
|
||||
</span>
|
||||
<div className="mt-2 flex items-baseline justify-center gap-1">
|
||||
<span className="text-3xl font-black text-zinc-100">$5</span>
|
||||
<span className="text-xs text-zinc-500">one-time purchase</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-1.5 text-left text-xs text-zinc-400 border-t border-zinc-850 pt-4">
|
||||
<div className="flex justify-between">
|
||||
<span>Slots Owned:</span>
|
||||
<span className="font-semibold text-zinc-200">{user && user.purchased_slots > 0 ? 10 : 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Active Projects:</span>
|
||||
<span className="font-semibold text-zinc-200">{user?.project_count ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-500/15 bg-red-500/5 p-3 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{user && (
|
||||
<div className="w-full flex justify-center py-1">
|
||||
<stripe-buy-button
|
||||
buy-button-id="buy_btn_1ThubIDPQ7Y9c4bKgoRwZuIm"
|
||||
publishable-key="pk_live_51ThuN5DPQ7Y9c4bKvvcy6bl6FuoQufKFWNYdkaXwINzWiEqyPlrU2WwO2mlCpKtuGXkBfKfozBVfT0aTBTU0TdLm00LIvAP3si"
|
||||
client-reference-id={String(user.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full rounded-lg bg-zinc-800/60 py-2.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
src/routing/types.ts
Normal file
63
src/routing/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { PlacedPart, Vec3 } from '../types';
|
||||
|
||||
/** Axis-aligned bounding box in world space (ft). */
|
||||
export interface Aabb {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
|
||||
/**
|
||||
* A port to route from/to: world position of the connector and its
|
||||
* outward-facing direction (the direction a mating pipe would extend).
|
||||
* Matches the pos/dir of a `WorldConnector` from `getWorldConnectors`.
|
||||
*/
|
||||
export interface PortRef {
|
||||
pos: Vec3;
|
||||
dir: Vec3;
|
||||
}
|
||||
|
||||
/** Static description of the scene the router must avoid / stay within. */
|
||||
export interface RouteWorld {
|
||||
/**
|
||||
* World AABBs of existing parts (see `partsToObstacles`). The caller should
|
||||
* EXCLUDE the two parts being routed from/to; as a safety net, obstacles
|
||||
* whose (clearance-inflated) box contains a port are ignored for the
|
||||
* stub leg leaving/entering that port.
|
||||
*/
|
||||
obstacles: Aabb[];
|
||||
/**
|
||||
* Search volume. Default: bounding box of start/end/obstacles padded by
|
||||
* 4 ft, with min.y clamped to 0 (routes never dip below the floor).
|
||||
*/
|
||||
bounds?: Aabb;
|
||||
/**
|
||||
* Lattice spacing (ft) for the A* search. Default 0.5.
|
||||
* Endpoints are ALWAYS refined exactly (their coordinates are injected
|
||||
* into the lattice), so a coarse step does not hurt mating precision —
|
||||
* it only limits where intermediate bends may sit. 0.25 quadruples the
|
||||
* per-axis resolution (~8x the node count); see search.ts for the budget.
|
||||
*/
|
||||
gridStep?: number;
|
||||
/** Minimum distance (ft) kept between pipe centerlines and obstacle AABBs. Default 0.3. */
|
||||
clearance?: number;
|
||||
/** Pipe diameter (inches) used for generated parts and collision radius. Default 1. */
|
||||
diameter?: number;
|
||||
}
|
||||
|
||||
export type RouteProfile = 'shortest' | 'fewestBends' | 'cleanest';
|
||||
|
||||
export interface RouteResult {
|
||||
/** Simplified axis-aligned polyline: [startPort, corners..., endPort]. */
|
||||
waypoints: Vec3[];
|
||||
/** Exactly-mated pipes + 90° elbows realizing the polyline. */
|
||||
parts: PlacedPart[];
|
||||
/** Total centerline length of the run (ft). */
|
||||
lengthFt: number;
|
||||
/** Number of 90° bends. */
|
||||
bends: number;
|
||||
ok: boolean;
|
||||
/** Human-readable failure description when ok is false. */
|
||||
reason?: string;
|
||||
/** Which profile produced this result. */
|
||||
profile?: RouteProfile;
|
||||
}
|
||||
70
src/routing/vec.ts
Normal file
70
src/routing/vec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Vec3 } from '../types';
|
||||
|
||||
/** Tolerance used for geometric comparisons throughout the router (ft). */
|
||||
export const EPS = 1e-6;
|
||||
|
||||
export const add = (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
export const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
export const scale = (a: Vec3, s: number): Vec3 => [a[0] * s, a[1] * s, a[2] * s];
|
||||
export const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
export const cross = (a: Vec3, b: Vec3): Vec3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
export const length = (a: Vec3) => Math.hypot(a[0], a[1], a[2]);
|
||||
export const dist = (a: Vec3, b: Vec3) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
export const manhattan = (a: Vec3, b: Vec3) =>
|
||||
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
|
||||
export const vecEquals = (a: Vec3, b: Vec3, eps = EPS) =>
|
||||
Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps && Math.abs(a[2] - b[2]) < eps;
|
||||
|
||||
/**
|
||||
* The six axis-aligned unit directions. Index layout: even = positive,
|
||||
* odd = negative, so `idx >> 1` is the axis (0=X 1=Y 2=Z) and `idx ^ 1`
|
||||
* is the reversed direction.
|
||||
*/
|
||||
export const AXIS_DIRS: readonly Vec3[] = [
|
||||
[1, 0, 0],
|
||||
[-1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, -1, 0],
|
||||
[0, 0, 1],
|
||||
[0, 0, -1],
|
||||
];
|
||||
|
||||
export const axisOfDir = (dirIdx: number) => dirIdx >> 1;
|
||||
export const signOfDir = (dirIdx: number) => (dirIdx & 1 ? -1 : 1);
|
||||
export const reverseDir = (dirIdx: number) => dirIdx ^ 1;
|
||||
|
||||
/** Index into AXIS_DIRS for an (exactly) axis-aligned unit vector, or -1. */
|
||||
export function dirIndexOf(v: Vec3, eps = EPS): number {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = AXIS_DIRS[i];
|
||||
if (Math.abs(v[0] - d[0]) < eps && Math.abs(v[1] - d[1]) < eps && Math.abs(v[2] - d[2]) < eps) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a roughly axis-aligned direction to the nearest exact axis direction.
|
||||
* Returns null when the vector is degenerate or too far from any axis
|
||||
* (more than ~25° off).
|
||||
*/
|
||||
export function snapAxisDir(v: Vec3): Vec3 | null {
|
||||
const l = length(v);
|
||||
if (l < EPS) return null;
|
||||
const n: Vec3 = [v[0] / l, v[1] / l, v[2] / l];
|
||||
let best = -1;
|
||||
let bestDot = 0.9; // cos(~25°) — refuse wildly diagonal directions
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = dot(n, AXIS_DIRS[i]);
|
||||
if (d > bestDot) {
|
||||
bestDot = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best >= 0 ? ([...AXIS_DIRS[best]] as Vec3) : null;
|
||||
}
|
||||
1448
src/simulation/flowSimulator.ts
Normal file
1448
src/simulation/flowSimulator.ts
Normal file
File diff suppressed because it is too large
Load Diff
66
src/simulation/waterState.ts
Normal file
66
src/simulation/waterState.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { FillState } from '../types';
|
||||
|
||||
/**
|
||||
* waterState — tiny module-level reactive store for per-part water visuals.
|
||||
*
|
||||
* The WaterDriver (mounted by FlowArrows) advances a global fill wavefront
|
||||
* every frame and writes each part's current fill fraction / state here.
|
||||
* Mesh components in PartBody read it inside their own useFrame and mutate
|
||||
* materials directly — no React state, no zustand, no re-renders.
|
||||
*/
|
||||
export interface WaterVisual {
|
||||
/** 0 (empty) → 1 (full of water). */
|
||||
fill: number;
|
||||
/** GPH currently flowing through the part. */
|
||||
flow: number;
|
||||
/** Line is backing up (undersized drain / dead end). */
|
||||
backedUp: boolean;
|
||||
/** Drain receiving more than its capacity — spilling over. */
|
||||
overflow: boolean;
|
||||
/** Net pot spilling from the rim. */
|
||||
spill: boolean;
|
||||
state: FillState;
|
||||
/** Connector id where water enters (orients the fill direction). */
|
||||
entry?: string;
|
||||
}
|
||||
|
||||
export const waterVisuals = new Map<string, WaterVisual>();
|
||||
|
||||
export function getWaterVisual(partId: string): WaterVisual | undefined {
|
||||
return waterVisuals.get(partId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Water clock — the time base for ALL water animation (ripples, arrows,
|
||||
// texture scroll, glow pulses). The WaterDriver advances it only while
|
||||
// `simRunning` is true, so pausing freezes every water visual in place.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let waterTime = 0;
|
||||
let waterDt = 0;
|
||||
|
||||
/** Advance the clock by one (running) frame. */
|
||||
export function tickWaterClock(dt: number): void {
|
||||
waterDt = dt;
|
||||
waterTime += dt;
|
||||
}
|
||||
|
||||
/** Mark the current frame as paused (freeze-frame). */
|
||||
export function freezeWaterClock(): void {
|
||||
waterDt = 0;
|
||||
}
|
||||
|
||||
/** Accumulated animation time (seconds). Frozen while paused. */
|
||||
export const getWaterTime = (): number => waterTime;
|
||||
|
||||
/** This frame's animation delta (0 while paused). */
|
||||
export const getWaterDt = (): number => waterDt;
|
||||
|
||||
export function ensureWaterVisual(partId: string): WaterVisual {
|
||||
let v = waterVisuals.get(partId);
|
||||
if (!v) {
|
||||
v = { fill: 0, flow: 0, backedUp: false, overflow: false, spill: false, state: 'empty' };
|
||||
waterVisuals.set(partId, v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
1164
src/store/builderStore.ts
Normal file
1164
src/store/builderStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
src/store/saveStatusStore.ts
Normal file
13
src/store/saveStatusStore.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type SaveStatus = 'saved' | 'unsaved' | 'saving';
|
||||
|
||||
interface SaveStatusStore {
|
||||
status: SaveStatus;
|
||||
setStatus: (status: SaveStatus) => void;
|
||||
}
|
||||
|
||||
export const useSaveStatus = create<SaveStatusStore>((set) => ({
|
||||
status: 'saved',
|
||||
setStatus: (status) => set({ status }),
|
||||
}));
|
||||
236
src/types.ts
Normal file
236
src/types.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Shared domain types for Hydro Builder.
|
||||
*
|
||||
* World units: 1 unit = 1 ft (used for head/elevation math).
|
||||
* Pipe diameters are expressed in inches, pump flow in GPH.
|
||||
*/
|
||||
|
||||
export type Vec3 = [number, number, number];
|
||||
|
||||
export type PartType =
|
||||
| 'pipe'
|
||||
| 'coupling'
|
||||
| 'reducer'
|
||||
| 'cap'
|
||||
| 'elbow'
|
||||
| 'tee'
|
||||
| 'valve'
|
||||
| 'pump'
|
||||
| 'reservoir'
|
||||
| 'tower'
|
||||
| 'tray'
|
||||
| 'wallPanel'
|
||||
| 'lattice'
|
||||
| 'netPot'
|
||||
| 'emitter'
|
||||
| 'drain'
|
||||
| 'growTent'
|
||||
| 'inlineFan'
|
||||
| 'circulationFan'
|
||||
| 'growLight'
|
||||
| 'co2Tank'
|
||||
| 'airCompressor'
|
||||
| 'reducingTee'
|
||||
| 'reducingElbow'
|
||||
| 'bulkhead'
|
||||
| 'waterChiller'
|
||||
| 'reverseOsmosis';
|
||||
|
||||
/** A part instance placed in the scene. */
|
||||
export interface PlacedPart {
|
||||
id: string;
|
||||
type: PartType;
|
||||
/** World position (ft). */
|
||||
position: Vec3;
|
||||
/** Euler rotation in radians, XYZ order. */
|
||||
rotation: Vec3;
|
||||
/** Type-specific parameters (length, diameter, gph, ...). */
|
||||
params: Record<string, number>;
|
||||
/** Optional user-given name shown in the inspector. */
|
||||
label?: string;
|
||||
/** Optional custom color (hex) overriding the catalog color. */
|
||||
color?: string;
|
||||
/** Room ID of the workspace this part belongs to. */
|
||||
roomId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-world joint style of a connection point. Validation/UI layers use it
|
||||
* to flag joints that don't exist at the hardware store (slip↔barb etc.).
|
||||
*/
|
||||
export type ConnectionStyle = 'slip' | 'npt' | 'barb';
|
||||
|
||||
/** A connection point defined in a part's local space. */
|
||||
export interface ConnectorDef {
|
||||
id: string;
|
||||
pos: Vec3;
|
||||
/** Outward-facing direction (unit vector, local space). */
|
||||
dir: Vec3;
|
||||
/** Physical joint style (slip = PVC cement, npt = threaded, barb = hose). */
|
||||
style?: ConnectionStyle;
|
||||
}
|
||||
|
||||
/** Metadata used to render a slider/input for one part parameter. */
|
||||
export interface ParamMeta {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export type PartCategory = 'Plumbing' | 'Flow Control' | 'Growing' | 'Structure' | 'Environment';
|
||||
|
||||
/** Static definition of a part type (catalog entry). */
|
||||
export interface PartDefinition {
|
||||
type: PartType;
|
||||
label: string;
|
||||
category: PartCategory;
|
||||
description: string;
|
||||
/** Accent color used in the library and as the base mesh color. */
|
||||
color: string;
|
||||
defaults: Record<string, number>;
|
||||
params: Record<string, ParamMeta>;
|
||||
getConnectors: (params: Record<string, number>) => ConnectorDef[];
|
||||
}
|
||||
|
||||
/** A connector resolved to world space. */
|
||||
export interface WorldConnector {
|
||||
key: string; // `${partId}:${connectorId}`
|
||||
partId: string;
|
||||
connectorId: string;
|
||||
partType: PartType;
|
||||
pos: Vec3;
|
||||
dir: Vec3;
|
||||
}
|
||||
|
||||
// ---------- Simulation ----------
|
||||
|
||||
export type WarningLevel = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface SimWarning {
|
||||
level: WarningLevel;
|
||||
message: string;
|
||||
partId?: string;
|
||||
}
|
||||
|
||||
/** Pump duty phase (timer mode). */
|
||||
export type PumpPhase = 'on' | 'off' | 'continuous';
|
||||
|
||||
export interface PumpResult {
|
||||
pumpId: string;
|
||||
ratedGph: number;
|
||||
/** Actual delivered flow after head + resistance losses. */
|
||||
gph: number;
|
||||
headFt: number;
|
||||
maxHeadFt: number;
|
||||
resistance: number;
|
||||
/** True when the pump is wired to a reservoir and pushing water. */
|
||||
running: boolean;
|
||||
/** Operating point where the pump curve meets the system curve. */
|
||||
opPoint: { gph: number; headFt: number };
|
||||
/** Current timer phase ('continuous' when no timer). */
|
||||
phase: PumpPhase;
|
||||
/** Reservoir this pump draws from (when wired correctly). */
|
||||
sourceReservoirId?: string;
|
||||
}
|
||||
|
||||
/** Directed segment carrying flow — used to render animated arrows. */
|
||||
export interface FlowSegment {
|
||||
partId: string;
|
||||
from: Vec3;
|
||||
to: Vec3;
|
||||
gph: number;
|
||||
/** Pipe diameter (in) of the part the segment runs through — drives arrow speed. */
|
||||
diameter?: number;
|
||||
}
|
||||
|
||||
/** Visual fill state of a flow part (driven over time by the water system). */
|
||||
export type FillState = 'empty' | 'filling' | 'full' | 'backedUp';
|
||||
|
||||
/** Per-drain inflow vs. capacity report (drain / standpipe / bellSiphon). */
|
||||
export interface DrainStatus {
|
||||
partId: string;
|
||||
inflowGph: number;
|
||||
capacityGph: number;
|
||||
overflowing: boolean;
|
||||
}
|
||||
|
||||
export interface NetPotStatus {
|
||||
partId: string;
|
||||
sourcePartId: string;
|
||||
inflowGph: number;
|
||||
rimHeightFt: number;
|
||||
waterHeightFt: number;
|
||||
overflowing: boolean;
|
||||
}
|
||||
|
||||
export interface ReservoirRuntimeStatus {
|
||||
partId: string;
|
||||
gallonsInitial: number;
|
||||
gallonsCurrent: number;
|
||||
gallonsCapacity: number;
|
||||
netOutflowGph: number;
|
||||
minutesRemaining: number | null;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
/** Per-vessel inflow/exit bookkeeping (consumed by the water ledger). */
|
||||
export interface VesselIO {
|
||||
/** GPH entering the vessel. */
|
||||
inGph: number;
|
||||
/** Exit parts (drain family) whose supply chain starts at this vessel. */
|
||||
exits: { partId: string; type: PartType; capacityGph: number }[];
|
||||
/** True when water can also leave via reservoir/emitter/open end. */
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export interface SimResult {
|
||||
/** partId -> GPH flowing through that part (0 if none). */
|
||||
flows: Record<string, number>;
|
||||
pumps: PumpResult[];
|
||||
segments: FlowSegment[];
|
||||
warnings: SimWarning[];
|
||||
/** Connector keys that are joined to another connector. */
|
||||
connectedKeys: Set<string>;
|
||||
connectionCount: number;
|
||||
totalGph: number;
|
||||
/** partId -> cumulative water-travel distance from its pump (ft). */
|
||||
fillDistance: Record<string, number>;
|
||||
/** partId -> internal water-path length (ft); fill spans [dist, dist+len]. */
|
||||
fillLength: Record<string, number>;
|
||||
/** Longest water path in the system (ft) — fill animation end point. */
|
||||
totalPathLength: number;
|
||||
/** partId -> connector id through which water enters the part. */
|
||||
entryConnector: Record<string, string>;
|
||||
/** Parts suffering backpressure (undersized drain / dead-end branch). */
|
||||
backedUp: Set<string>;
|
||||
/** Per-drain inflow vs capacity (for overflow visuals). */
|
||||
drains: DrainStatus[];
|
||||
/** Net pots overflowing because plumbing below them is backing up. */
|
||||
netPots: NetPotStatus[];
|
||||
/** partId -> water velocity through the part (ft/s, slope-adjusted). */
|
||||
velocities: Record<string, number>;
|
||||
/** partId -> rough gauge pressure (psi). */
|
||||
pressures: Record<string, number>;
|
||||
/** partId -> immediate upstream part along the supply path. */
|
||||
upstream: Record<string, string>;
|
||||
/** vesselId -> inflow/exit bookkeeping for accumulation & overflow. */
|
||||
vesselIO: Record<string, VesselIO>;
|
||||
/** Runtime drawdown state for finite reservoirs. */
|
||||
reservoirRuntime: Record<string, ReservoirRuntimeStatus>;
|
||||
}
|
||||
|
||||
// ---------- Persistence ----------
|
||||
|
||||
export interface ProjectFile {
|
||||
app: 'hydro-builder';
|
||||
version: 1;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
parts: PlacedPart[];
|
||||
}
|
||||
|
||||
export type ViewMode = 'orbit' | 'top' | 'front' | 'side';
|
||||
export type RenderMode = 'builder' | 'xray' | 'water';
|
||||
export type Tool = 'select' | 'measure' | 'pipeRun';
|
||||
89
src/utils/cadExporter.ts
Normal file
89
src/utils/cadExporter.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js';
|
||||
|
||||
/**
|
||||
* Traverses the scene, clones all physical part geometries (where name is 'part-body-mesh'),
|
||||
* applies their world transformations, and returns a single group containing them.
|
||||
* This filters out all UI guides, grids, handles, and water visuals (non-castShadow meshes).
|
||||
*/
|
||||
export function getExportGroup(scene: THREE.Scene): THREE.Group {
|
||||
const exportGroup = new THREE.Group();
|
||||
exportGroup.name = 'ExportedParts';
|
||||
|
||||
scene.traverse((child) => {
|
||||
if (child.name === 'part-body-mesh') {
|
||||
// Clone the part's visual hierarchy
|
||||
const clone = child.clone(true);
|
||||
|
||||
// Filter out non-castShadow meshes (which removes water visuals, etc.)
|
||||
const toRemove: THREE.Object3D[] = [];
|
||||
clone.traverse((o) => {
|
||||
if (o instanceof THREE.Mesh && !o.castShadow) {
|
||||
toRemove.push(o);
|
||||
}
|
||||
});
|
||||
for (const o of toRemove) {
|
||||
o.parent?.remove(o);
|
||||
}
|
||||
|
||||
// Compute and apply the world transform of the original part to the clone
|
||||
child.updateWorldMatrix(true, false);
|
||||
|
||||
// Reset the clone's local transform so applyMatrix4 doesn't double-apply it
|
||||
clone.position.set(0, 0, 0);
|
||||
clone.rotation.set(0, 0, 0);
|
||||
clone.scale.set(1, 1, 1);
|
||||
clone.updateMatrix();
|
||||
|
||||
clone.applyMatrix4(child.matrixWorld);
|
||||
|
||||
exportGroup.add(clone);
|
||||
}
|
||||
});
|
||||
|
||||
return exportGroup;
|
||||
}
|
||||
|
||||
/** Helper to trigger a browser file download */
|
||||
function downloadFile(content: BlobPart, mimeType: string, filename: string) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Export the scene's parts as a glTF JSON (.gltf) file */
|
||||
export function exportToGLTF(scene: THREE.Scene, projectName: string) {
|
||||
const exportGroup = getExportGroup(scene);
|
||||
const exporter = new GLTFExporter();
|
||||
|
||||
exporter.parse(
|
||||
exportGroup,
|
||||
(gltf) => {
|
||||
const output = typeof gltf === 'string' ? gltf : JSON.stringify(gltf, null, 2);
|
||||
const filename = `${projectName.replace(/\s+/g, '_') || 'hydro_system'}.gltf`;
|
||||
downloadFile(output, 'application/json', filename);
|
||||
},
|
||||
(error) => {
|
||||
console.error('Failed to export glTF:', error);
|
||||
},
|
||||
{
|
||||
binary: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Export the scene's parts as an OBJ (.obj) file */
|
||||
export function exportToOBJ(scene: THREE.Scene, projectName: string) {
|
||||
const exportGroup = getExportGroup(scene);
|
||||
const exporter = new OBJExporter();
|
||||
const objText = exporter.parse(exportGroup);
|
||||
const filename = `${projectName.replace(/\s+/g, '_') || 'hydro_system'}.obj`;
|
||||
downloadFile(objText, 'text/plain', filename);
|
||||
}
|
||||
176
src/utils/connectors.ts
Normal file
176
src/utils/connectors.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import * as THREE from 'three';
|
||||
import type { PlacedPart, Vec3, WorldConnector } from '../types';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
|
||||
/** Two connectors closer than this (ft) are considered joined. */
|
||||
export const CONNECT_DIST = 0.35;
|
||||
/** Drop a part within this distance of a mating connector and it snaps. */
|
||||
export const SNAP_DIST = 0.8;
|
||||
|
||||
const _e = new THREE.Euler();
|
||||
const _v = new THREE.Vector3();
|
||||
|
||||
/** Resolve a part's connectors into world space. */
|
||||
export function getWorldConnectors(part: PlacedPart): WorldConnector[] {
|
||||
const def = PART_DEFS[part.type];
|
||||
_e.set(part.rotation[0], part.rotation[1], part.rotation[2], 'XYZ');
|
||||
return def.getConnectors(part.params).map((c) => {
|
||||
const pos = _v.set(...c.pos).applyEuler(_e).toArray() as Vec3;
|
||||
pos[0] += part.position[0];
|
||||
pos[1] += part.position[1];
|
||||
pos[2] += part.position[2];
|
||||
const dir = new THREE.Vector3(...c.dir).applyEuler(_e).toArray() as Vec3;
|
||||
return { key: `${part.id}:${c.id}`, partId: part.id, connectorId: c.id, partType: part.type, pos, dir };
|
||||
});
|
||||
}
|
||||
|
||||
export const dist3 = (a: Vec3, b: Vec3) =>
|
||||
Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
|
||||
export interface PipeTapTarget {
|
||||
partId: string;
|
||||
pos: Vec3;
|
||||
branchPos: Vec3;
|
||||
rotationY: number;
|
||||
diameter: number;
|
||||
d: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the translation that snaps `part` onto the nearest open connector of
|
||||
* any other part (within SNAP_DIST). Returns null when nothing is in range.
|
||||
*/
|
||||
export function findSnapDelta(
|
||||
part: PlacedPart,
|
||||
others: PlacedPart[],
|
||||
): Vec3 | null {
|
||||
const mine = getWorldConnectors(part);
|
||||
if (!mine.length) return null;
|
||||
|
||||
let best: { d: number; delta: Vec3 } | null = null;
|
||||
for (const other of others) {
|
||||
if (other.id === part.id) continue;
|
||||
for (const oc of getWorldConnectors(other)) {
|
||||
for (const mc of mine) {
|
||||
const d = dist3(mc.pos, oc.pos);
|
||||
if (d < SNAP_DIST && (!best || d < best.d)) {
|
||||
best = {
|
||||
d,
|
||||
delta: [oc.pos[0] - mc.pos[0], oc.pos[1] - mc.pos[1], oc.pos[2] - mc.pos[2]],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Already perfectly seated — no need to move.
|
||||
if (best && best.d < 1e-4) return null;
|
||||
return best ? best.delta : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest *open* connector to `point` among `parts`, ignoring any
|
||||
* part in `excludeIds` (the group being dragged). A connector is open when no
|
||||
* other (non-excluded) connector sits within CONNECT_DIST of it.
|
||||
* Used to live-highlight + magnetically snap connector-handle drags.
|
||||
*/
|
||||
export function findNearestOpenConnector(
|
||||
point: Vec3,
|
||||
parts: PlacedPart[],
|
||||
excludeIds: ReadonlySet<string>,
|
||||
maxDist = SNAP_DIST,
|
||||
): (WorldConnector & { d: number }) | null {
|
||||
const candidates: WorldConnector[] = [];
|
||||
for (const p of parts) {
|
||||
if (excludeIds.has(p.id)) continue;
|
||||
candidates.push(...getWorldConnectors(p));
|
||||
}
|
||||
|
||||
const occupied = new Set<string>();
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
for (let j = i + 1; j < candidates.length; j++) {
|
||||
if (candidates[i].partId === candidates[j].partId) continue;
|
||||
if (dist3(candidates[i].pos, candidates[j].pos) < CONNECT_DIST) {
|
||||
occupied.add(candidates[i].key);
|
||||
occupied.add(candidates[j].key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let best: (WorldConnector & { d: number }) | null = null;
|
||||
for (const c of candidates) {
|
||||
if (occupied.has(c.key)) continue;
|
||||
const d = dist3(point, c.pos);
|
||||
if (d < maxDist && (!best || d < best.d)) best = { ...c, d };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a branchable point along the body of an existing straight pipe.
|
||||
* Used by Pipe Run to insert a tee and branch from the side of a line.
|
||||
*/
|
||||
export function findNearestPipeTap(
|
||||
point: Vec3,
|
||||
parts: PlacedPart[],
|
||||
maxDist = SNAP_DIST,
|
||||
): PipeTapTarget | null {
|
||||
let best: PipeTapTarget | null = null;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'pipe') continue;
|
||||
const conns = getWorldConnectors(part);
|
||||
const a = conns.find((c) => c.connectorId === 'a');
|
||||
const b = conns.find((c) => c.connectorId === 'b');
|
||||
if (!a || !b) continue;
|
||||
|
||||
const ax = a.pos[0];
|
||||
const az = a.pos[2];
|
||||
const bx = b.pos[0];
|
||||
const bz = b.pos[2];
|
||||
const vx = bx - ax;
|
||||
const vz = bz - az;
|
||||
const len2 = vx * vx + vz * vz;
|
||||
if (len2 < 1e-6) continue;
|
||||
|
||||
const wx = point[0] - ax;
|
||||
const wz = point[2] - az;
|
||||
const t = (wx * vx + wz * vz) / len2;
|
||||
if (t <= 0.15 || t >= 0.85) continue;
|
||||
|
||||
const px = ax + vx * t;
|
||||
const pz = az + vz * t;
|
||||
const d = Math.hypot(point[0] - px, point[2] - pz);
|
||||
if (d > maxDist) continue;
|
||||
|
||||
const baseYaw = part.rotation[1];
|
||||
const dx = point[0] - px;
|
||||
const dz = point[2] - pz;
|
||||
const angleToPoint = Math.atan2(-dz, dx);
|
||||
const relativeAngle = angleToPoint - baseYaw;
|
||||
const roundedRelativeAngle = Math.round(relativeAngle / (Math.PI / 2)) * (Math.PI / 2);
|
||||
let rotationY = baseYaw + roundedRelativeAngle + Math.PI / 2;
|
||||
rotationY = Math.atan2(Math.sin(rotationY), Math.cos(rotationY));
|
||||
|
||||
const tee: PlacedPart = {
|
||||
id: '__tap__',
|
||||
type: 'tee',
|
||||
position: [px, part.position[1], pz],
|
||||
rotation: [0, rotationY, 0],
|
||||
params: { ...PART_DEFS.tee.defaults, diameter: part.params.diameter ?? 1 },
|
||||
};
|
||||
const branch = getWorldConnectors(tee).find((c) => c.connectorId === 'c');
|
||||
if (!branch) continue;
|
||||
|
||||
const candidate: PipeTapTarget = {
|
||||
partId: part.id,
|
||||
pos: [px, part.position[1], pz],
|
||||
branchPos: branch.pos,
|
||||
rotationY,
|
||||
diameter: part.params.diameter ?? 1,
|
||||
d,
|
||||
};
|
||||
if (!best || d < best.d) best = candidate;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
514
src/utils/demoProject.ts
Normal file
514
src/utils/demoProject.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import type { PlacedPart, Vec3 } from '../types';
|
||||
|
||||
export type QuickBuildId = 'nftLoop' | 'dripManifold' | 'dwcSystem' | 'finiteDrainTest';
|
||||
|
||||
export const QUICK_BUILDS: { id: QuickBuildId; label: string; description: string }[] = [
|
||||
{
|
||||
id: 'nftLoop',
|
||||
label: 'NFT loop',
|
||||
description: 'Reservoir, pump, NFT tray, return drain, and plant cups',
|
||||
},
|
||||
{
|
||||
id: 'dripManifold',
|
||||
label: 'Drip manifold',
|
||||
description: 'Pump-fed header with tee branches, valves, and emitters',
|
||||
},
|
||||
{
|
||||
id: 'dwcSystem',
|
||||
label: 'DWC bucket system',
|
||||
description: 'Deep Water Culture bucket with aerator, airstone, net pots, and water chiller',
|
||||
},
|
||||
{
|
||||
id: 'finiteDrainTest',
|
||||
label: 'Finite drain test',
|
||||
description: 'Tiny reservoir feeding a drain so the source visibly runs out',
|
||||
},
|
||||
];
|
||||
|
||||
const quickId = (build: QuickBuildId, suffix: string) =>
|
||||
`quick-${build}-${suffix}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
const addOffset = (pos: Vec3, offset: Vec3): Vec3 => [
|
||||
pos[0] + offset[0],
|
||||
pos[1] + offset[1],
|
||||
pos[2] + offset[2],
|
||||
];
|
||||
|
||||
const place = (
|
||||
build: QuickBuildId,
|
||||
suffix: string,
|
||||
part: Omit<PlacedPart, 'id'>,
|
||||
offset: Vec3,
|
||||
): PlacedPart => ({
|
||||
...part,
|
||||
id: quickId(build, suffix),
|
||||
position: addOffset(part.position, offset),
|
||||
rotation: [...part.rotation],
|
||||
params: { ...part.params },
|
||||
});
|
||||
|
||||
export function nextQuickBuildOffset(parts: PlacedPart[]): Vec3 {
|
||||
if (!parts.length) return [0, 0, 0];
|
||||
const maxX = Math.max(...parts.map((p) => p.position[0]));
|
||||
return [Math.ceil(maxX / 2) * 2 + 4, 0, 0];
|
||||
}
|
||||
|
||||
export function quickBuildParts(build: QuickBuildId, offset: Vec3 = [0, 0, 0]): PlacedPart[] {
|
||||
const HALF_PI = Math.PI / 2;
|
||||
|
||||
if (build === 'dwcSystem') {
|
||||
return [
|
||||
place(build, 'dwc-res', {
|
||||
type: 'reservoir',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { width: 1.6, depth: 1.6, height: 1.5, level: 75, ph: 6.0, ec: 1.2 },
|
||||
label: 'DWC Bucket',
|
||||
}, offset),
|
||||
place(build, 'dwc-pot-a', {
|
||||
type: 'netPot',
|
||||
position: [-0.3, 1.5, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { size: 1.0 },
|
||||
}, offset),
|
||||
place(build, 'dwc-pot-b', {
|
||||
type: 'netPot',
|
||||
position: [0.3, 1.5, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { size: 1.0 },
|
||||
}, offset),
|
||||
place(build, 'dwc-comp', {
|
||||
type: 'airCompressor',
|
||||
position: [-1.6, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { gph: 60, frequency: 5, on: 1 },
|
||||
label: 'DWC Aerator',
|
||||
}, offset),
|
||||
place(build, 'dwc-chiller', {
|
||||
type: 'waterChiller',
|
||||
position: [0, 0, -1.6],
|
||||
rotation: [0, 0, 0],
|
||||
params: { targetTemp: 68, on: 1, diameter: 1.0 },
|
||||
label: 'Water Chiller',
|
||||
}, offset),
|
||||
];
|
||||
}
|
||||
|
||||
if (build === 'finiteDrainTest') {
|
||||
return [
|
||||
place(build, 'res', {
|
||||
type: 'reservoir',
|
||||
position: [-2, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { width: 1, depth: 1, height: 1, level: 10, ph: 6.0, ec: 1.2 },
|
||||
label: 'Finite test reservoir',
|
||||
}, offset),
|
||||
place(build, 'pump', {
|
||||
type: 'pump',
|
||||
position: [-1.05, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { gph: 400, maxHeadFt: 8, on: 1 },
|
||||
label: 'Drain test pump',
|
||||
}, offset),
|
||||
place(build, 'pipe', {
|
||||
type: 'pipe',
|
||||
position: [0.4, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 2, diameter: 1 },
|
||||
}, offset),
|
||||
place(build, 'drain', {
|
||||
type: 'drain',
|
||||
position: [1.75, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { capacityGph: 1000 },
|
||||
label: 'Open drain',
|
||||
}, offset),
|
||||
];
|
||||
}
|
||||
|
||||
if (build === 'dripManifold') {
|
||||
return [
|
||||
place(build, 'res', {
|
||||
type: 'reservoir',
|
||||
position: [-2, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { width: 2, depth: 1.4, height: 1, level: 70 },
|
||||
label: 'Drip reservoir',
|
||||
}, offset),
|
||||
place(build, 'pump', {
|
||||
type: 'pump',
|
||||
position: [-0.55, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { gph: 240, maxHeadFt: 8, on: 1 },
|
||||
label: 'Drip pump',
|
||||
}, offset),
|
||||
place(build, 'pipe-feed', {
|
||||
type: 'pipe',
|
||||
position: [0.4, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 1, diameter: 0.75 },
|
||||
}, offset),
|
||||
place(build, 'tee-a', {
|
||||
type: 'tee',
|
||||
position: [1.5, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { diameter: 0.75 },
|
||||
label: 'Manifold split',
|
||||
}, offset),
|
||||
place(build, 'header', {
|
||||
type: 'pipe',
|
||||
position: [2.35, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 0.5, diameter: 0.75 },
|
||||
}, offset),
|
||||
place(build, 'tee-b', {
|
||||
type: 'tee',
|
||||
position: [3.2, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { diameter: 0.75 },
|
||||
}, offset),
|
||||
place(build, 'valve-a', {
|
||||
type: 'valve',
|
||||
position: [1.5, 0.25, 0.95],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { diameter: 0.5, open: 75 },
|
||||
}, offset),
|
||||
place(build, 'branch-a', {
|
||||
type: 'pipe',
|
||||
position: [1.5, 0.25, 1.65],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { length: 0.7, diameter: 0.5 },
|
||||
}, offset),
|
||||
place(build, 'emit-a', {
|
||||
type: 'emitter',
|
||||
position: [1.5, 0.25, 2.2],
|
||||
rotation: [0, HALF_PI, 0],
|
||||
params: {},
|
||||
label: 'Dripper A',
|
||||
}, offset),
|
||||
place(build, 'valve-b', {
|
||||
type: 'valve',
|
||||
position: [3.2, 0.25, 0.95],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { diameter: 0.5, open: 75 },
|
||||
}, offset),
|
||||
place(build, 'branch-b', {
|
||||
type: 'pipe',
|
||||
position: [3.2, 0.25, 1.65],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { length: 0.7, diameter: 0.5 },
|
||||
}, offset),
|
||||
place(build, 'emit-b', {
|
||||
type: 'emitter',
|
||||
position: [3.2, 0.25, 2.2],
|
||||
rotation: [0, HALF_PI, 0],
|
||||
params: {},
|
||||
label: 'Dripper B',
|
||||
}, offset),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
place(build, 'res', {
|
||||
type: 'reservoir',
|
||||
position: [-2.5, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { width: 2, depth: 1.4, height: 1, level: 70 },
|
||||
label: 'NFT reservoir',
|
||||
}, offset),
|
||||
place(build, 'pump', {
|
||||
type: 'pump',
|
||||
position: [-1.05, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { gph: 300, maxHeadFt: 8, on: 1 },
|
||||
label: 'NFT pump',
|
||||
}, offset),
|
||||
place(build, 'feed', {
|
||||
type: 'pipe',
|
||||
position: [0.2, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 1.6, diameter: 1 },
|
||||
}, offset),
|
||||
place(build, 'tray', {
|
||||
type: 'tray',
|
||||
position: [2.5, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 3, width: 1.2 },
|
||||
label: 'NFT channel',
|
||||
}, offset),
|
||||
place(build, 'return', {
|
||||
type: 'pipe',
|
||||
position: [4.35, 0.15, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 0.7, diameter: 1 },
|
||||
}, offset),
|
||||
place(build, 'drain', {
|
||||
type: 'drain',
|
||||
position: [5.05, -0.1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { capacityGph: 350 },
|
||||
label: 'NFT return drain',
|
||||
}, offset),
|
||||
place(build, 'pot-a', {
|
||||
type: 'netPot',
|
||||
position: [1.8, 0.25, -0.2],
|
||||
rotation: [0, 0, 0],
|
||||
params: { size: 0.8 },
|
||||
}, offset),
|
||||
place(build, 'pot-b', {
|
||||
type: 'netPot',
|
||||
position: [2.5, 0.25, -0.2],
|
||||
rotation: [0, 0, 0],
|
||||
params: { size: 0.8 },
|
||||
}, offset),
|
||||
place(build, 'pot-c', {
|
||||
type: 'netPot',
|
||||
position: [3.2, 0.25, -0.2],
|
||||
rotation: [0, 0, 0],
|
||||
params: { size: 0.8 },
|
||||
}, offset),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Starter demo — two independent, fully plumbed runs:
|
||||
*
|
||||
* 1. Main loop: reservoir → pump → tee, which splits the flow:
|
||||
* - riser → overhead return line → emitter spraying back into the
|
||||
* reservoir,
|
||||
* - a narrow side branch feeding a correctly-sized floor drain.
|
||||
* 2. Tower run: a second reservoir + pump lifting water up and over into
|
||||
* the grow tower's top inlet; the tower's base outlet runs along +Z to
|
||||
* its own floor drain.
|
||||
*
|
||||
* Plus a tray / net-pot / lattice showcase. Connector positions are computed
|
||||
* so everything is exactly snapped and the demo simulates warning-free.
|
||||
*/
|
||||
export function demoParts(): PlacedPart[] {
|
||||
const HALF_PI = Math.PI / 2;
|
||||
return [
|
||||
{
|
||||
id: 'demo-res',
|
||||
type: 'reservoir',
|
||||
position: [-4, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { width: 2, depth: 1.4, height: 1, level: 70 },
|
||||
label: 'Main reservoir',
|
||||
},
|
||||
{
|
||||
// Inlet (-0.45,0.25,0) lands exactly on the reservoir's east port (-3,0.25,0).
|
||||
id: 'demo-pump',
|
||||
type: 'pump',
|
||||
position: [-2.55, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { gph: 550, maxHeadFt: 8, on: 1 },
|
||||
label: 'Main pump',
|
||||
},
|
||||
{
|
||||
// Splits the flow: a (-2.1,0.25,0) on the pump outlet, b (-0.9,0.25,0)
|
||||
// continues the main run, c (-1.5,0.25,0.6) feeds the drain branch.
|
||||
id: 'demo-tee',
|
||||
type: 'tee',
|
||||
position: [-1.5, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Main run: spans x -0.9 .. 0.1 at port height.
|
||||
id: 'demo-pipe1',
|
||||
type: 'pipe',
|
||||
position: [-0.4, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 1, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Turns the run from +X to +Y (upward). a=(0.1,0.25,0), b=(0.6,0.75,0).
|
||||
id: 'demo-elbow1',
|
||||
type: 'elbow',
|
||||
position: [0.1, 0.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { angle: 90, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Vertical riser: rotated 90° about Z so its axis is Y (y 0.75 .. 2.75).
|
||||
id: 'demo-pipe2',
|
||||
type: 'pipe',
|
||||
position: [0.6, 1.75, 0],
|
||||
rotation: [0, 0, HALF_PI],
|
||||
params: { length: 2, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Turns +Y back to -X (overhead return). a=(0.6,2.75,0), b=(0.1,3.25,0).
|
||||
id: 'demo-elbow2',
|
||||
type: 'elbow',
|
||||
position: [0.6, 2.75, 0],
|
||||
rotation: [0, 0, HALF_PI],
|
||||
params: { angle: 90, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Overhead return: spans x -2.9 .. 0.1 at y 3.25.
|
||||
id: 'demo-pipe3',
|
||||
type: 'pipe',
|
||||
position: [-1.4, 3.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 3, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Sprays down into the reservoir below (in connector at (-2.9,3.25,0)).
|
||||
id: 'demo-emitter',
|
||||
type: 'emitter',
|
||||
position: [-3.1, 3.25, 0],
|
||||
rotation: [0, 0, 0],
|
||||
params: {},
|
||||
label: 'Return emitter',
|
||||
},
|
||||
// --- drain branch (narrow pipe → correctly-sized floor drain) ---
|
||||
{
|
||||
// Narrow branch pipe along +Z: rotated -90° about Y so its axis is Z.
|
||||
// Connectors land on the tee's c port (-1.5,0.25,0.6) and (-1.5,0.25,1.6).
|
||||
id: 'demo-pipe4',
|
||||
type: 'pipe',
|
||||
position: [-1.5, 0.25, 1.1],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { length: 1, diameter: 0.5 },
|
||||
},
|
||||
{
|
||||
// Floor drain; inlet (local (-0.35,0.25,0)) rotates to (-1.5,0.25,1.6).
|
||||
// Branch carries ~65 GPH — 100 GPH capacity swallows it with margin.
|
||||
id: 'demo-drain',
|
||||
type: 'drain',
|
||||
position: [-1.5, 0, 1.95],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { capacityGph: 100 },
|
||||
label: 'Floor drain',
|
||||
},
|
||||
|
||||
// --- tower run: reservoir → pump → up & over → tower → floor drain ---
|
||||
{
|
||||
// West port at (6.5, 0.25, 1.5) feeds the tower pump's inlet.
|
||||
id: 'demo-res2',
|
||||
type: 'reservoir',
|
||||
position: [7.5, 0, 1.5],
|
||||
rotation: [0, 0, 0],
|
||||
params: { width: 2, depth: 1.4, height: 1, level: 60 },
|
||||
label: 'Tower reservoir',
|
||||
},
|
||||
{
|
||||
// Rotated 180° about Y: inlet lands on (6.5,0.25,1.5), outlet faces -X
|
||||
// at (5.6,0.25,1.5).
|
||||
id: 'demo-pump2',
|
||||
type: 'pump',
|
||||
position: [6.05, 0, 1.5],
|
||||
rotation: [0, Math.PI, 0],
|
||||
params: { gph: 240, maxHeadFt: 8, on: 1 },
|
||||
label: 'Tower pump',
|
||||
},
|
||||
{
|
||||
// Mirrored elbow: a=(5.6,0.25,1.5) on the pump outlet, b=(5.1,0.75,1.5)
|
||||
// pointing up.
|
||||
id: 'demo-elbow-t1',
|
||||
type: 'elbow',
|
||||
position: [5.6, 0.25, 1.5],
|
||||
rotation: [0, Math.PI, 0],
|
||||
params: { angle: 90, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Vertical riser: spans y 0.75 .. 4.0 (length 3.25 makes the top land
|
||||
// exactly where the overhead elbows can step down onto the tower inlet).
|
||||
id: 'demo-riser2',
|
||||
type: 'pipe',
|
||||
position: [5.1, 2.375, 1.5],
|
||||
rotation: [0, 0, HALF_PI],
|
||||
params: { length: 3.25, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Turns +Y to -X: a=(5.1,4.0,1.5), b=(4.6,4.5,1.5).
|
||||
id: 'demo-elbow-t2',
|
||||
type: 'elbow',
|
||||
position: [5.1, 4.0, 1.5],
|
||||
rotation: [0, 0, HALF_PI],
|
||||
params: { angle: 90, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Short overhead hop: spans x 3.5 .. 4.6 at y 4.5.
|
||||
id: 'demo-pipe-t1',
|
||||
type: 'pipe',
|
||||
position: [4.05, 4.5, 1.5],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 1.1, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Turns -X to -Y (down): a=(3.5,4.5,1.5), b=(3.0,4.0,1.5) — exactly the
|
||||
// tower's top inlet.
|
||||
id: 'demo-elbow-t3',
|
||||
type: 'elbow',
|
||||
position: [3.5, 4.5, 1.5],
|
||||
rotation: [0, 0, Math.PI],
|
||||
params: { angle: 90, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Tower outlet (3.45,0.15,1.5) → turn +X to +Z: b=(3.95,0.15,2.0).
|
||||
id: 'demo-elbow-t4',
|
||||
type: 'elbow',
|
||||
position: [3.45, 0.15, 1.5],
|
||||
rotation: [HALF_PI, 0, 0],
|
||||
params: { angle: 90, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Run to the drain along +Z: spans z 2.0 .. 3.5 at y 0.15.
|
||||
id: 'demo-pipe-t2',
|
||||
type: 'pipe',
|
||||
position: [3.95, 0.15, 2.75],
|
||||
rotation: [0, HALF_PI, 0],
|
||||
params: { length: 1.5, diameter: 1 },
|
||||
},
|
||||
{
|
||||
// Flush-mounted floor drain (sunk 0.1 ft so its inlet meets the pipe at
|
||||
// y 0.15). The run delivers ~75 GPH — 100 GPH capacity is plenty.
|
||||
id: 'demo-drain2',
|
||||
type: 'drain',
|
||||
position: [3.95, -0.1, 3.85],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { capacityGph: 100 },
|
||||
label: 'Tower drain',
|
||||
},
|
||||
|
||||
// --- showcase pieces ---
|
||||
{
|
||||
id: 'demo-tower',
|
||||
type: 'tower',
|
||||
position: [3, 0, 1.5],
|
||||
rotation: [0, 0, 0],
|
||||
params: { height: 4, sites: 6 },
|
||||
label: 'Tower A',
|
||||
},
|
||||
{
|
||||
id: 'demo-tray',
|
||||
type: 'tray',
|
||||
position: [3, 0, -1.5],
|
||||
rotation: [0, 0, 0],
|
||||
params: { length: 3, width: 1.2 },
|
||||
},
|
||||
{
|
||||
id: 'demo-pot1',
|
||||
type: 'netPot',
|
||||
position: [2.4, 0.25, -1.5],
|
||||
rotation: [0, 0, 0],
|
||||
params: { size: 1 },
|
||||
},
|
||||
{
|
||||
id: 'demo-pot2',
|
||||
type: 'netPot',
|
||||
position: [3.4, 0.25, -1.5],
|
||||
rotation: [0, 0, 0],
|
||||
params: { size: 1 },
|
||||
},
|
||||
{
|
||||
id: 'demo-lattice',
|
||||
type: 'lattice',
|
||||
position: [5.5, 0, 0],
|
||||
rotation: [0, -HALF_PI, 0],
|
||||
params: { width: 2.5, height: 3 },
|
||||
},
|
||||
];
|
||||
}
|
||||
94
src/utils/dragState.ts
Normal file
94
src/utils/dragState.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Vec3 } from '../types';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { getWorldConnectors } from './connectors';
|
||||
|
||||
/**
|
||||
* Transient (non-reactive) drag state shared between the drag initiators
|
||||
* (PartMesh body grabs, ConnectorHandles) and the DragPlane in SceneCanvas
|
||||
* (drag move/end). Kept out of the store to avoid re-rendering on every
|
||||
* pointer event.
|
||||
*/
|
||||
export const dragCtx = {
|
||||
/** Vector from grab point to the primary part's origin at drag start. */
|
||||
grabOffset: [0, 0, 0] as Vec3,
|
||||
/** Whether this drag already recorded an undo snapshot. */
|
||||
pushed: false,
|
||||
/** 'body' = grabbed the part itself, 'handle' = grabbed a connector handle. */
|
||||
mode: 'body' as 'body' | 'handle',
|
||||
/** All part ids moving rigidly together (the selection at drag start). */
|
||||
groupIds: [] as string[],
|
||||
/** Position of every group member when the drag started. */
|
||||
startPositions: {} as Record<string, Vec3>,
|
||||
/** World connector key being dragged (handle mode only). */
|
||||
connectorKey: null as string | null,
|
||||
/** Pipe endpoint stretch metadata (handle drags on straight pipes only). */
|
||||
pipeStretch: null as null | {
|
||||
anchorConnectorId: string;
|
||||
anchorPos: Vec3;
|
||||
axis: Vec3;
|
||||
startLength: number;
|
||||
},
|
||||
/** Identifies the active drag-plane orientation; the grab offset is
|
||||
* recomputed when it changes mid-drag (e.g. Shift toggles vertical). */
|
||||
planeKey: '',
|
||||
/** World point the drag plane passes through (grab point, re-anchored
|
||||
* whenever the plane orientation changes mid-drag). */
|
||||
planeAnchor: [0, 0, 0] as Vec3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Start dragging a part (and, rigidly, the rest of the selection if the part
|
||||
* is selected). `grabPoint` is the world-space point that should stay under
|
||||
* the cursor; for handle drags it is the connector position.
|
||||
*/
|
||||
export function beginPartDrag(opts: {
|
||||
partId: string;
|
||||
grabPoint: Vec3;
|
||||
mode: 'body' | 'handle';
|
||||
connectorKey?: string;
|
||||
}) {
|
||||
const s = useBuilder.getState();
|
||||
const part = s.parts[opts.partId];
|
||||
if (!part) return;
|
||||
const group = s.selectedIds.includes(opts.partId) ? [...s.selectedIds] : [opts.partId];
|
||||
dragCtx.mode = opts.mode;
|
||||
dragCtx.connectorKey = opts.connectorKey ?? null;
|
||||
dragCtx.pipeStretch = null;
|
||||
dragCtx.grabOffset = [
|
||||
opts.grabPoint[0] - part.position[0],
|
||||
opts.grabPoint[1] - part.position[1],
|
||||
opts.grabPoint[2] - part.position[2],
|
||||
];
|
||||
dragCtx.groupIds = group;
|
||||
dragCtx.startPositions = {};
|
||||
for (const id of group) {
|
||||
const p = s.parts[id];
|
||||
if (p) dragCtx.startPositions[id] = [...p.position] as Vec3;
|
||||
}
|
||||
if (opts.mode === 'handle' && part.type === 'pipe' && opts.connectorKey) {
|
||||
const connectorId = opts.connectorKey.split(':')[1];
|
||||
const anchorConnectorId = connectorId === 'a' ? 'b' : connectorId === 'b' ? 'a' : null;
|
||||
if (anchorConnectorId) {
|
||||
const conns = getWorldConnectors(part);
|
||||
const dragged = conns.find((c) => c.connectorId === connectorId);
|
||||
const anchor = conns.find((c) => c.connectorId === anchorConnectorId);
|
||||
if (dragged && anchor) {
|
||||
const dx = dragged.pos[0] - anchor.pos[0];
|
||||
const dy = dragged.pos[1] - anchor.pos[1];
|
||||
const dz = dragged.pos[2] - anchor.pos[2];
|
||||
const len = Math.hypot(dx, dy, dz) || (part.params.length ?? 2);
|
||||
dragCtx.pipeStretch = {
|
||||
anchorConnectorId,
|
||||
anchorPos: [...anchor.pos] as Vec3,
|
||||
axis: [dx / len, dy / len, dz / len],
|
||||
startLength: part.params.length ?? len,
|
||||
};
|
||||
dragCtx.groupIds = [opts.partId];
|
||||
}
|
||||
}
|
||||
}
|
||||
dragCtx.pushed = false;
|
||||
dragCtx.planeKey = '';
|
||||
dragCtx.planeAnchor = [...opts.grabPoint] as Vec3;
|
||||
s.setDraggingId(opts.partId);
|
||||
}
|
||||
325
src/utils/pathfinder.ts
Normal file
325
src/utils/pathfinder.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import * as THREE from 'three';
|
||||
import type { PlacedPart, Vec3, WorldConnector } from '../types';
|
||||
|
||||
/** Snap a value to the 0.5 ft grid. */
|
||||
const snapToGrid = (v: number) => Math.round(v * 2) / 2;
|
||||
|
||||
/** Round connector direction vectors to integer grid steps. */
|
||||
function getDirG(dir: Vec3): [number, number, number] {
|
||||
return [Math.round(dir[0]), Math.round(dir[1]), Math.round(dir[2])];
|
||||
}
|
||||
|
||||
/** Generates a simple UID. */
|
||||
const uid = () => Math.random().toString(36).substring(2, 10);
|
||||
|
||||
/**
|
||||
* Returns the Axis-Aligned Bounding Box (AABB) in world space for structural obstacles.
|
||||
* This rotates the local box coordinates and computes the enclosing AABB.
|
||||
*/
|
||||
export function getPartAABB(part: PlacedPart): THREE.Box3 | null {
|
||||
let w = 1, d = 1, h = 1;
|
||||
let originY = 0;
|
||||
|
||||
if (part.type === 'tray') {
|
||||
w = part.params.length ?? 3;
|
||||
d = part.params.width ?? 1.2;
|
||||
h = 0.25;
|
||||
} else if (part.type === 'wallPanel') {
|
||||
w = part.params.width ?? 3;
|
||||
d = 0.5;
|
||||
h = part.params.height ?? 4;
|
||||
} else if (part.type === 'tower') {
|
||||
w = 1.0;
|
||||
d = 1.0;
|
||||
h = part.params.height ?? 4;
|
||||
} else if (part.type === 'pipe') {
|
||||
const r = (part.params.diameter ?? 1) * 0.08;
|
||||
w = part.params.length ?? 2;
|
||||
d = r * 2.5;
|
||||
h = r * 2.5;
|
||||
originY = -h / 2;
|
||||
} else if (part.type === 'elbow' || part.type === 'tee' || part.type === 'coupling' || part.type === 'valve') {
|
||||
const r = (part.params.diameter ?? 1) * 0.08;
|
||||
w = 0.6; d = 0.6; h = 0.6;
|
||||
originY = -h / 2;
|
||||
} else if (part.type === 'pump') {
|
||||
w = 0.6; d = 0.6; h = 0.8;
|
||||
} else {
|
||||
w = part.params.width ?? 2;
|
||||
d = part.params.depth ?? 1.4;
|
||||
h = part.params.height ?? 1;
|
||||
}
|
||||
|
||||
const localMin = new THREE.Vector3(-w / 2, originY, -d / 2);
|
||||
const localMax = new THREE.Vector3(w / 2, originY + h, d / 2);
|
||||
const box = new THREE.Box3(localMin, localMax);
|
||||
|
||||
const pos = new THREE.Vector3(...part.position);
|
||||
const rot = new THREE.Euler(...part.rotation, 'XYZ');
|
||||
const mat = new THREE.Matrix4().compose(
|
||||
pos,
|
||||
new THREE.Quaternion().setFromEuler(rot),
|
||||
new THREE.Vector3(1, 1, 1)
|
||||
);
|
||||
|
||||
box.applyMatrix4(mat);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a 3D path of points between startPos and endPos on a 0.5 ft grid,
|
||||
* avoiding the given obstacle AABBs.
|
||||
*/
|
||||
export function find3DPath(
|
||||
startInput: Vec3 | THREE.Vector3,
|
||||
endInput: Vec3 | THREE.Vector3,
|
||||
obstacles: THREE.Box3[]
|
||||
): THREE.Vector3[] | null {
|
||||
const startPos = startInput instanceof THREE.Vector3 ? startInput.clone() : new THREE.Vector3(...startInput);
|
||||
const endPos = endInput instanceof THREE.Vector3 ? endInput.clone() : new THREE.Vector3(...endInput);
|
||||
|
||||
const startGx = Math.round(startPos.x * 2);
|
||||
const startGy = Math.round(startPos.y * 2);
|
||||
const startGz = Math.round(startPos.z * 2);
|
||||
|
||||
const endGx = Math.round(endPos.x * 2);
|
||||
const endGy = Math.round(endPos.y * 2);
|
||||
const endGz = Math.round(endPos.z * 2);
|
||||
|
||||
const startKey = `${startGx},${startGy},${startGz}`;
|
||||
const endKey = `${endGx},${endGy},${endGz}`;
|
||||
|
||||
// Bounding box of the search space
|
||||
let minGx = Math.min(startGx, endGx) - 12;
|
||||
let maxGx = Math.max(startGx, endGx) + 12;
|
||||
let minGy = Math.min(startGy, endGy) - 12;
|
||||
let maxGy = Math.max(startGy, endGy) + 12;
|
||||
let minGz = Math.min(startGz, endGz) - 12;
|
||||
let maxGz = Math.max(startGz, endGz) + 12;
|
||||
|
||||
minGy = Math.max(minGy, -4);
|
||||
|
||||
// Expand search box to cover all obstacles
|
||||
for (const box of obstacles) {
|
||||
minGx = Math.min(minGx, Math.round((box.min.x - 2) * 2));
|
||||
maxGx = Math.max(maxGx, Math.round((box.max.x + 2) * 2));
|
||||
minGy = Math.min(minGy, Math.round((box.min.y - 2) * 2));
|
||||
maxGy = Math.max(maxGy, Math.round((box.max.y + 2) * 2));
|
||||
minGz = Math.min(minGz, Math.round((box.min.z - 2) * 2));
|
||||
maxGz = Math.max(maxGz, Math.round((box.max.z + 2) * 2));
|
||||
}
|
||||
|
||||
const openSet = new Set<string>([startKey]);
|
||||
const cameFrom = new Map<string, string>();
|
||||
const gScore = new Map<string, number>();
|
||||
const fScore = new Map<string, number>();
|
||||
|
||||
gScore.set(startKey, 0);
|
||||
|
||||
const heuristic = (gx1: number, gy1: number, gz1: number, gx2: number, gy2: number, gz2: number) => {
|
||||
return Math.abs(gx1 - gx2) + Math.abs(gy1 - gy2) + Math.abs(gz1 - gz2);
|
||||
};
|
||||
|
||||
fScore.set(startKey, heuristic(startGx, startGy, startGz, endGx, endGy, endGz));
|
||||
|
||||
const maxIterations = 30000;
|
||||
let iterations = 0;
|
||||
const margin = 0.08; // 0.08 ft clearance around AABBs
|
||||
|
||||
while (openSet.size > 0) {
|
||||
iterations++;
|
||||
if (iterations > maxIterations) {
|
||||
break;
|
||||
}
|
||||
|
||||
let currentKey = '';
|
||||
let minF = Infinity;
|
||||
for (const key of openSet) {
|
||||
const f = fScore.get(key) ?? Infinity;
|
||||
if (f < minF) {
|
||||
minF = f;
|
||||
currentKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentKey) break;
|
||||
|
||||
const [cx, cy, cz] = currentKey.split(',').map(Number);
|
||||
|
||||
if (currentKey === endKey) {
|
||||
const gridPath: THREE.Vector3[] = [];
|
||||
let curr: string | undefined = currentKey;
|
||||
while (curr) {
|
||||
const [x, y, z] = curr.split(',').map(Number);
|
||||
gridPath.push(new THREE.Vector3(x / 2, y / 2, z / 2));
|
||||
curr = cameFrom.get(curr);
|
||||
}
|
||||
gridPath.reverse();
|
||||
|
||||
const rawPath: THREE.Vector3[] = [startPos.clone()];
|
||||
for (const pt of gridPath) {
|
||||
if (rawPath[rawPath.length - 1].distanceTo(pt) > 0.05) {
|
||||
rawPath.push(pt);
|
||||
}
|
||||
}
|
||||
if (rawPath[rawPath.length - 1].distanceTo(endPos) > 0.05) {
|
||||
rawPath.push(endPos.clone());
|
||||
}
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
openSet.delete(currentKey);
|
||||
|
||||
const neighbors = [
|
||||
[cx + 1, cy, cz],
|
||||
[cx - 1, cy, cz],
|
||||
[cx, cy + 1, cz],
|
||||
[cx, cy - 1, cz],
|
||||
[cx, cy, cz + 1],
|
||||
[cx, cy, cz - 1],
|
||||
];
|
||||
|
||||
for (const [nx, ny, nz] of neighbors) {
|
||||
if (nx < minGx || nx > maxGx || ny < minGy || ny > maxGy || nz < minGz || nz > maxGz) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const neighborKey = `${nx},${ny},${nz}`;
|
||||
const neighborPt = new THREE.Vector3(nx / 2, ny / 2, nz / 2);
|
||||
|
||||
// Check collisions with obstacles, ignoring start and end to allow entry/exit
|
||||
if (neighborKey !== endKey && neighborKey !== startKey) {
|
||||
let collides = false;
|
||||
for (const box of obstacles) {
|
||||
const expandedBox = box.clone().expandByScalar(margin);
|
||||
if (expandedBox.containsPoint(neighborPt)) {
|
||||
collides = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (collides) continue;
|
||||
}
|
||||
|
||||
const currentCost = gScore.get(currentKey) ?? Infinity;
|
||||
const dx = nx - cx;
|
||||
const dy = ny - cy;
|
||||
const dz = nz - cz;
|
||||
|
||||
let isTurn = false;
|
||||
const parentKey = cameFrom.get(currentKey);
|
||||
if (parentKey) {
|
||||
const [px, py, pz] = parentKey.split(',').map(Number);
|
||||
const prevDx = cx - px;
|
||||
const prevDy = cy - py;
|
||||
const prevDz = cz - pz;
|
||||
if (prevDx !== dx || prevDy !== dy || prevDz !== dz) {
|
||||
isTurn = true;
|
||||
}
|
||||
}
|
||||
|
||||
const stepCost = 0.5 + (isTurn ? 5.0 : 0);
|
||||
const tentativeGScore = currentCost + stepCost;
|
||||
|
||||
if (tentativeGScore < (gScore.get(neighborKey) ?? Infinity)) {
|
||||
cameFrom.set(neighborKey, currentKey);
|
||||
gScore.set(neighborKey, tentativeGScore);
|
||||
fScore.set(neighborKey, tentativeGScore + heuristic(nx, ny, nz, endGx, endGy, endGz));
|
||||
openSet.add(neighborKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a 3D path of points into straight pipes and 90-degree elbows,
|
||||
* simplifying collinear segments along the way.
|
||||
*/
|
||||
export function convertPathToParts(
|
||||
path: THREE.Vector3[],
|
||||
diameter: number,
|
||||
roomId?: string
|
||||
): PlacedPart[] {
|
||||
if (path.length < 2) return [];
|
||||
|
||||
// 1. Simplify collinear segments
|
||||
const simplified: THREE.Vector3[] = [path[0]];
|
||||
for (let i = 1; i < path.length - 1; i++) {
|
||||
const prev = simplified[simplified.length - 1];
|
||||
const curr = path[i];
|
||||
const next = path[i + 1];
|
||||
|
||||
const dir1 = curr.clone().sub(prev).normalize();
|
||||
const dir2 = next.clone().sub(curr).normalize();
|
||||
|
||||
if (dir1.dot(dir2) < 0.999) {
|
||||
simplified.push(curr);
|
||||
}
|
||||
}
|
||||
simplified.push(path[path.length - 1]);
|
||||
|
||||
const result: PlacedPart[] = [];
|
||||
const B = 0.5; // Elbow bend radius (0.5 ft)
|
||||
|
||||
const isCorner = new Array(simplified.length).fill(false);
|
||||
for (let i = 1; i < simplified.length - 1; i++) {
|
||||
isCorner[i] = true;
|
||||
}
|
||||
|
||||
// 2. Generate elbows and straight pipes
|
||||
for (let i = 0; i < simplified.length - 1; i++) {
|
||||
const pA = simplified[i];
|
||||
const pB = simplified[i + 1];
|
||||
const dir = pB.clone().sub(pA).normalize();
|
||||
|
||||
const pipeStart = pA.clone().addScaledVector(dir, isCorner[i] ? B : 0);
|
||||
const pipeEnd = pB.clone().addScaledVector(dir, isCorner[i + 1] ? -B : 0);
|
||||
|
||||
const length = pipeStart.distanceTo(pipeEnd);
|
||||
if (length > 0.05) {
|
||||
const pos = pipeStart.clone().add(pipeEnd).multiplyScalar(0.5);
|
||||
const quat = new THREE.Quaternion().setFromUnitVectors(
|
||||
new THREE.Vector3(1, 0, 0),
|
||||
dir
|
||||
);
|
||||
const euler = new THREE.Euler().setFromQuaternion(quat, 'XYZ');
|
||||
|
||||
result.push({
|
||||
id: `pipe-${uid()}`,
|
||||
type: 'pipe',
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
rotation: [euler.x, euler.y, euler.z],
|
||||
params: { length, diameter },
|
||||
roomId,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCorner[i + 1]) {
|
||||
const cornerPt = pB;
|
||||
const nextPt = simplified[i + 2];
|
||||
const dir1 = dir;
|
||||
const dir2 = nextPt.clone().sub(cornerPt).normalize();
|
||||
|
||||
const elbowPos = cornerPt.clone().addScaledVector(dir1, -B);
|
||||
|
||||
const m = new THREE.Matrix4().makeBasis(
|
||||
dir1,
|
||||
dir2,
|
||||
new THREE.Vector3().crossVectors(dir1, dir2).normalize()
|
||||
);
|
||||
const euler = new THREE.Euler().setFromRotationMatrix(m, 'XYZ');
|
||||
|
||||
result.push({
|
||||
id: `elbow-${uid()}`,
|
||||
type: 'elbow',
|
||||
position: [elbowPos.x, elbowPos.y, elbowPos.z],
|
||||
rotation: [euler.x, euler.y, euler.z],
|
||||
params: { angle: 90, diameter },
|
||||
roomId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
144
src/utils/serializer.ts
Normal file
144
src/utils/serializer.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { PlacedPart, ProjectFile, PartType } from '../types';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
|
||||
/**
|
||||
* ProjectSerializer — JSON persistence.
|
||||
* - Named projects in localStorage.
|
||||
* - Autosave (restored on next visit).
|
||||
* - Export/import .json files.
|
||||
*/
|
||||
|
||||
const PROJECTS_KEY = 'hydro-builder:projects';
|
||||
const AUTOSAVE_KEY = 'hydro-builder:autosave';
|
||||
|
||||
export function toProjectFile(name: string, parts: PlacedPart[]): ProjectFile {
|
||||
return { app: 'hydro-builder', version: 1, name, savedAt: new Date().toISOString(), parts };
|
||||
}
|
||||
|
||||
/** Validate an arbitrary JSON value as a ProjectFile, dropping bad parts or throwing on corruption. */
|
||||
export function parseProjectFile(raw: unknown): ProjectFile {
|
||||
const obj = raw as Partial<ProjectFile>;
|
||||
if (!obj || obj.app !== 'hydro-builder' || !Array.isArray(obj.parts)) {
|
||||
throw new Error('Not a valid Hydro Builder project file.');
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = new Set<string>([
|
||||
'Plumbing',
|
||||
'Flow Control',
|
||||
'Growing',
|
||||
'Structure',
|
||||
'Environment',
|
||||
]);
|
||||
|
||||
for (const p of obj.parts) {
|
||||
if (!p || typeof p !== 'object') {
|
||||
throw new Error('Project contains invalid or null part data.');
|
||||
}
|
||||
if (typeof p.id !== 'string' || !p.id) {
|
||||
throw new Error('Part is missing a valid ID.');
|
||||
}
|
||||
if (typeof p.type !== 'string' || !(p.type in PART_DEFS)) {
|
||||
throw new Error(`Part ${p.id} has an invalid or unrecognized type: ${p.type}.`);
|
||||
}
|
||||
|
||||
const category = PART_DEFS[p.type as PartType].category;
|
||||
if (!VALID_CATEGORIES.has(category)) {
|
||||
throw new Error(`Part ${p.id} has an invalid category: ${category}.`);
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(p.position) ||
|
||||
p.position.length !== 3 ||
|
||||
!p.position.every((num) => typeof num === 'number' && Number.isFinite(num))
|
||||
) {
|
||||
throw new Error(`Part ${p.id} must have a position with an array of 3 numbers.`);
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(p.rotation) ||
|
||||
p.rotation.length !== 3 ||
|
||||
!p.rotation.every((num) => typeof num === 'number' && Number.isFinite(num))
|
||||
) {
|
||||
throw new Error(`Part ${p.id} must have a rotation with an array of 3 numbers.`);
|
||||
}
|
||||
}
|
||||
|
||||
const parts = obj.parts as PlacedPart[];
|
||||
return toProjectFileNamed(obj.name ?? 'Imported project', parts, obj.savedAt);
|
||||
}
|
||||
|
||||
function toProjectFileNamed(name: string, parts: PlacedPart[], savedAt?: string): ProjectFile {
|
||||
return { app: 'hydro-builder', version: 1, name, savedAt: savedAt ?? new Date().toISOString(), parts };
|
||||
}
|
||||
|
||||
// ---------- localStorage projects ----------
|
||||
|
||||
function readProjects(): Record<string, ProjectFile> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PROJECTS_KEY) ?? '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function listProjects(): { name: string; savedAt: string }[] {
|
||||
return Object.values(readProjects())
|
||||
.map((p) => ({ name: p.name, savedAt: p.savedAt }))
|
||||
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
}
|
||||
|
||||
export function saveProject(name: string, parts: PlacedPart[]) {
|
||||
const all = readProjects();
|
||||
all[name] = toProjectFile(name, parts);
|
||||
localStorage.setItem(PROJECTS_KEY, JSON.stringify(all));
|
||||
}
|
||||
|
||||
export function loadProject(name: string): ProjectFile | null {
|
||||
return readProjects()[name] ?? null;
|
||||
}
|
||||
|
||||
export function deleteProject(name: string) {
|
||||
const all = readProjects();
|
||||
delete all[name];
|
||||
localStorage.setItem(PROJECTS_KEY, JSON.stringify(all));
|
||||
}
|
||||
|
||||
// ---------- autosave ----------
|
||||
|
||||
export function autosave(name: string, parts: PlacedPart[], email?: string) {
|
||||
try {
|
||||
const key = email ? `hydro-builder:autosave:${email}` : AUTOSAVE_KEY;
|
||||
localStorage.setItem(key, JSON.stringify(toProjectFile(name, parts)));
|
||||
} catch {
|
||||
/* storage full — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAutosave(email?: string): ProjectFile | null {
|
||||
try {
|
||||
const key = email ? `hydro-builder:autosave:${email}` : AUTOSAVE_KEY;
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? parseProjectFile(JSON.parse(raw)) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- file export / import ----------
|
||||
|
||||
export function downloadProject(name: string, parts: PlacedPart[]) {
|
||||
const blob = new Blob([JSON.stringify(toProjectFile(name, parts), null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${name.replace(/[^a-z0-9-_ ]/gi, '').trim() || 'hydro-system'}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function importProjectFile(file: File): Promise<ProjectFile> {
|
||||
const text = await file.text();
|
||||
return parseProjectFile(JSON.parse(text));
|
||||
}
|
||||
194
src/validation/DesignCheckPanel.tsx
Normal file
194
src/validation/DesignCheckPanel.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { Finding, FindingSeverity } from './types';
|
||||
|
||||
/**
|
||||
* DesignCheckPanel — self-contained results drawer for the Design Check.
|
||||
*
|
||||
* Purely presentational: findings are passed in, part navigation goes out via
|
||||
* `onSelectPart`. It deliberately does NOT touch the zustand store so it can
|
||||
* never break when the store shape changes.
|
||||
*
|
||||
* Usage (UI agent):
|
||||
* const findings = validateSystem(parts, sim); // on Play / "Check Build"
|
||||
* <DesignCheckPanel findings={findings} onSelectPart={setSelected} onClose={...} />
|
||||
*/
|
||||
|
||||
export interface DesignCheckPanelProps {
|
||||
findings: Finding[];
|
||||
/** Called with a part id when the user clicks "Show part". */
|
||||
onSelectPart?: (id: string) => void;
|
||||
onApplyFix?: (finding: Finding) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SEVERITIES: FindingSeverity[] = ['error', 'warning', 'info'];
|
||||
|
||||
const SEVERITY_META: Record<
|
||||
FindingSeverity,
|
||||
{ heading: string; noun: [string, string]; card: string; dot: string; text: string }
|
||||
> = {
|
||||
error: {
|
||||
heading: 'Errors',
|
||||
noun: ['error', 'errors'],
|
||||
card: 'border-rose-900/60 bg-rose-950/40',
|
||||
dot: 'bg-rose-500',
|
||||
text: 'text-rose-300',
|
||||
},
|
||||
warning: {
|
||||
heading: 'Warnings',
|
||||
noun: ['warning', 'warnings'],
|
||||
card: 'border-amber-900/60 bg-amber-950/30',
|
||||
dot: 'bg-amber-400',
|
||||
text: 'text-amber-300',
|
||||
},
|
||||
info: {
|
||||
heading: 'Tips',
|
||||
noun: ['tip', 'tips'],
|
||||
card: 'border-sky-900/60 bg-sky-950/30',
|
||||
dot: 'bg-sky-400',
|
||||
text: 'text-sky-300',
|
||||
},
|
||||
};
|
||||
|
||||
function summaryLine(findings: Finding[]): string {
|
||||
const chunks: string[] = [];
|
||||
for (const sev of SEVERITIES) {
|
||||
const n = findings.filter((f) => f.severity === sev).length;
|
||||
if (n > 0) chunks.push(`${n} ${SEVERITY_META[sev].noun[n === 1 ? 0 : 1]}`);
|
||||
}
|
||||
return chunks.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
function FindingCard({
|
||||
finding,
|
||||
onSelectPart,
|
||||
onApplyFix,
|
||||
}: {
|
||||
finding: Finding;
|
||||
onSelectPart?: (id: string) => void;
|
||||
onApplyFix?: (finding: Finding) => void;
|
||||
}) {
|
||||
const meta = SEVERITY_META[finding.severity];
|
||||
const canApplyFix =
|
||||
finding.id.startsWith('dead-leg:') ||
|
||||
finding.id.startsWith('diameter-mismatch:') ||
|
||||
finding.id.startsWith('pump-dead:');
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2.5 ${meta.card}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${meta.dot}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className={`text-xs font-semibold leading-snug ${meta.text}`}>{finding.title}</h4>
|
||||
<p className="mt-0.5 text-[11px] leading-snug text-zinc-400">{finding.detail}</p>
|
||||
{finding.fix && (
|
||||
<p className="mt-1.5 rounded-md bg-zinc-900/80 px-2 py-1 text-[11px] leading-snug text-zinc-300">
|
||||
<span className="font-semibold text-sky-400">Fix: </span>
|
||||
{finding.fix}
|
||||
</p>
|
||||
)}
|
||||
{finding.partIds.length > 0 && onSelectPart && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{finding.partIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onSelectPart(id)}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-sky-400 hover:border-sky-700 hover:bg-zinc-800"
|
||||
>
|
||||
Show part{finding.partIds.length > 1 ? ` ${i + 1}` : ''}
|
||||
</button>
|
||||
))}
|
||||
{canApplyFix && onApplyFix && (
|
||||
<button
|
||||
onClick={() => onApplyFix(finding)}
|
||||
className="rounded-md border border-emerald-700 bg-emerald-950 px-2 py-0.5 text-[10px] font-semibold text-emerald-300 hover:border-emerald-500 hover:bg-emerald-900"
|
||||
>
|
||||
Apply fix
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignCheckPanel({ findings, onSelectPart, onApplyFix, onClose }: DesignCheckPanelProps) {
|
||||
const clean = findings.length === 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Drawer */}
|
||||
<aside className="relative flex h-full w-[26rem] max-w-full flex-col border-l border-zinc-800 bg-zinc-950 shadow-2xl">
|
||||
<header className="flex items-start justify-between gap-3 border-b border-zinc-800 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wide text-zinc-100">Design Check</h2>
|
||||
<p className="mt-0.5 text-[11px] text-zinc-500">
|
||||
{clean ? 'All rules passed' : summaryLine(findings)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close design check"
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs font-semibold text-zinc-400 hover:border-zinc-600 hover:text-zinc-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{clean ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-950 ring-1 ring-emerald-800">
|
||||
<svg viewBox="0 0 20 20" className="h-6 w-6 text-emerald-400" fill="none">
|
||||
<path
|
||||
d="M4 10.5l4 4 8-9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-emerald-400">
|
||||
Build validated — no issues
|
||||
</p>
|
||||
<p className="max-w-[16rem] text-[11px] text-zinc-500">
|
||||
Every design rule passed. Press Play to watch the water run.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{SEVERITIES.map((sev) => {
|
||||
const group = findings.filter((f) => f.severity === sev);
|
||||
if (!group.length) return null;
|
||||
const meta = SEVERITY_META[sev];
|
||||
return (
|
||||
<section key={sev}>
|
||||
<h3 className="mb-1.5 flex items-center gap-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} />
|
||||
{meta.heading}
|
||||
<span className="font-mono text-zinc-600">{group.length}</span>
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{group.map((f) => (
|
||||
<FindingCard
|
||||
key={f.id}
|
||||
finding={f}
|
||||
onSelectPart={onSelectPart}
|
||||
onApplyFix={onApplyFix}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/validation/designCheck.ts
Normal file
59
src/validation/designCheck.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { PlacedPart, SimResult } from '../types';
|
||||
import { computeRoomMetrics } from '../environment/roomMetrics';
|
||||
import { simulate } from '../simulation/flowSimulator';
|
||||
import { buildSystemGraph } from './graph';
|
||||
import { ALL_RULES } from './rules';
|
||||
import type { Finding, FindingSeverity, RuleContext } from './types';
|
||||
|
||||
const SEVERITY_ORDER: Record<FindingSeverity, number> = { error: 0, warning: 1, info: 2 };
|
||||
|
||||
/**
|
||||
* Run the full Design Check over a build.
|
||||
*
|
||||
* @param parts Placed parts — either the store's `Record<id, PlacedPart>` or
|
||||
* a plain array.
|
||||
* @param sim Optional pre-computed simulation result (e.g. the one already
|
||||
* driving the viewport). When omitted, `simulate()` is run here.
|
||||
* @returns Findings sorted errors → warnings → info. Empty array = clean build.
|
||||
*/
|
||||
export function validateSystem(
|
||||
parts: Record<string, PlacedPart> | PlacedPart[],
|
||||
sim?: SimResult,
|
||||
): Finding[] {
|
||||
const partsMap: Record<string, PlacedPart> = Array.isArray(parts)
|
||||
? Object.fromEntries(parts.map((p) => [p.id, p]))
|
||||
: parts;
|
||||
const partList = Object.values(partsMap);
|
||||
|
||||
let simResult = sim;
|
||||
if (!simResult) {
|
||||
try {
|
||||
simResult = simulate(partsMap);
|
||||
} catch {
|
||||
simResult = undefined; // rules degrade gracefully without sim data
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: RuleContext = {
|
||||
partsMap,
|
||||
parts: partList,
|
||||
sim: simResult,
|
||||
graph: buildSystemGraph(partList),
|
||||
roomMetrics: computeRoomMetrics(partsMap),
|
||||
};
|
||||
|
||||
const findings: Finding[] = [];
|
||||
for (const rule of ALL_RULES) {
|
||||
try {
|
||||
findings.push(...rule.run(ctx));
|
||||
} catch (err) {
|
||||
// A broken rule (e.g. after a sibling changes a shape) must never take
|
||||
// down the whole check.
|
||||
console.warn(`[design-check] rule "${rule.id}" failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return findings.sort(
|
||||
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
|
||||
);
|
||||
}
|
||||
256
src/validation/graph.ts
Normal file
256
src/validation/graph.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { PlacedPart, PartType, Vec3, WorldConnector } from '../types';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
import { CONNECT_DIST, dist3, getWorldConnectors } from '../utils/connectors';
|
||||
|
||||
/**
|
||||
* Connectivity graph for the Design Check — built independently of the flow
|
||||
* simulator so validation rules keep working even if the simulator changes.
|
||||
*
|
||||
* Two levels of structure:
|
||||
* - Part level: `neighbors` / `components` (which parts are plugged together).
|
||||
* - Node level: `nodes` — joined-connector groups (`N:*`) and per-part
|
||||
* internal nodes (`P:<partId>`) with world positions, used for elevation
|
||||
* walks (siphon detection) and path tracing.
|
||||
*/
|
||||
|
||||
/** A pair of connectors (on different parts) sitting within CONNECT_DIST. */
|
||||
export interface Joint {
|
||||
a: WorldConnector;
|
||||
b: WorldConnector;
|
||||
}
|
||||
|
||||
/** Axis-aligned bounding box (ft, world space). */
|
||||
export interface AABB {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
/** Representative world position (connector position or part position). */
|
||||
pos: Vec3;
|
||||
edges: { to: string; partId: string }[];
|
||||
}
|
||||
|
||||
export interface SystemGraph {
|
||||
/** World-space connectors for every part (empty array if none/unknown type). */
|
||||
connectorsByPart: Map<string, WorldConnector[]>;
|
||||
/** All joined connector pairs, deduped. */
|
||||
joints: Joint[];
|
||||
/** Connector keys (`partId:connectorId`) that are joined to something. */
|
||||
joinedKeys: Set<string>;
|
||||
/** partId -> partIds it is physically connected to. */
|
||||
neighbors: Map<string, Set<string>>;
|
||||
/** partId -> index into `components`. */
|
||||
componentOf: Map<string, number>;
|
||||
/** Connected components (sets of part ids), parts with connectors only. */
|
||||
components: Set<string>[];
|
||||
/** Node-level graph for elevation/path walks. */
|
||||
nodes: Map<string, GraphNode>;
|
||||
/** connector key -> node id of its merged group. */
|
||||
nodeOfConnector: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Parts where water can legitimately exit the network. */
|
||||
export const EXIT_TYPES = new Set<string>([
|
||||
'reservoir',
|
||||
'emitter',
|
||||
'drain',
|
||||
'tower',
|
||||
'tray',
|
||||
'wallPanel',
|
||||
]);
|
||||
|
||||
/**
|
||||
* getWorldConnectors throws on part types missing from PART_DEFS (siblings
|
||||
* may add types); fall back to "no connectors" so unknown parts degrade to
|
||||
* passive obstacles instead of crashing validation.
|
||||
*/
|
||||
export function safeWorldConnectors(part: PlacedPart): WorldConnector[] {
|
||||
if (!PART_DEFS[part.type as PartType]) return [];
|
||||
try {
|
||||
return getWorldConnectors(part);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Display name for a part: user label, catalog label, or raw type. */
|
||||
export function partName(part: PlacedPart | undefined): string {
|
||||
if (!part) return 'Unknown part';
|
||||
return part.label ?? PART_DEFS[part.type as PartType]?.label ?? part.type;
|
||||
}
|
||||
|
||||
/** Is the pump explicitly switched off? (`on` per catalog; `enabled` legacy). */
|
||||
export function isPumpOff(part: PlacedPart | undefined): boolean {
|
||||
return part?.params?.on === 0 || part?.params?.enabled === 0;
|
||||
}
|
||||
|
||||
/** Water can exit the network through this part. */
|
||||
export function isExitPart(part: PlacedPart | undefined): boolean {
|
||||
if (!part) return false;
|
||||
if (EXIT_TYPES.has(part.type)) return true;
|
||||
// Generic: future "Growing" parts that carry water count as exits too.
|
||||
const def = PART_DEFS[part.type as PartType];
|
||||
return def?.category === 'Growing' && safeWorldConnectors(part).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate world AABB, ignoring rotation. Combines the part position +
|
||||
* connector positions (padded) with param-driven extents (width/depth/
|
||||
* length/height) so vessels and structures get a sensible volume.
|
||||
*/
|
||||
export function approxAABB(part: PlacedPart, conns: WorldConnector[]): AABB {
|
||||
const [x, y, z] = part.position;
|
||||
const p = part.params ?? {};
|
||||
const pad = 0.2;
|
||||
|
||||
const pts: Vec3[] = [part.position, ...conns.map((c) => c.pos)];
|
||||
const min: Vec3 = [Infinity, Infinity, Infinity];
|
||||
const max: Vec3 = [-Infinity, -Infinity, -Infinity];
|
||||
for (const pt of pts) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
min[i] = Math.min(min[i], pt[i] - pad);
|
||||
max[i] = Math.max(max[i], pt[i] + pad);
|
||||
}
|
||||
}
|
||||
|
||||
const halfW = (p.width ?? 0) / 2;
|
||||
if (halfW > 0) {
|
||||
min[0] = Math.min(min[0], x - halfW);
|
||||
max[0] = Math.max(max[0], x + halfW);
|
||||
}
|
||||
const halfL = (p.length ?? 0) / 2;
|
||||
if (halfL > 0) {
|
||||
min[0] = Math.min(min[0], x - halfL);
|
||||
max[0] = Math.max(max[0], x + halfL);
|
||||
}
|
||||
const halfD = (p.depth ?? p.width ?? 0) / 2;
|
||||
if (halfD > 0) {
|
||||
min[2] = Math.min(min[2], z - halfD);
|
||||
max[2] = Math.max(max[2], z + halfD);
|
||||
}
|
||||
// Height-bearing parts (towers, panels, lattices, tanks) sit on their base.
|
||||
if ((p.height ?? 0) > 0) {
|
||||
min[1] = Math.min(min[1], y);
|
||||
max[1] = Math.max(max[1], y + p.height);
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
// ---------- union-find ----------
|
||||
|
||||
class UnionFind {
|
||||
private parent = new Map<string, string>();
|
||||
find(x: string): string {
|
||||
let p = this.parent.get(x) ?? x;
|
||||
if (p !== x) {
|
||||
p = this.find(p);
|
||||
this.parent.set(x, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
union(a: string, b: string) {
|
||||
const ra = this.find(a);
|
||||
const rb = this.find(b);
|
||||
if (ra !== rb) this.parent.set(ra, rb);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- graph construction ----------
|
||||
|
||||
export function buildSystemGraph(parts: PlacedPart[]): SystemGraph {
|
||||
const connectorsByPart = new Map<string, WorldConnector[]>();
|
||||
const allConnectors: WorldConnector[] = [];
|
||||
for (const part of parts) {
|
||||
const conns = safeWorldConnectors(part);
|
||||
connectorsByPart.set(part.id, conns);
|
||||
allConnectors.push(...conns);
|
||||
}
|
||||
|
||||
// 1. Joints: connector pairs on different parts within CONNECT_DIST.
|
||||
const joints: Joint[] = [];
|
||||
const joinedKeys = new Set<string>();
|
||||
const uf = new UnionFind();
|
||||
for (let i = 0; i < allConnectors.length; i++) {
|
||||
for (let j = i + 1; j < allConnectors.length; j++) {
|
||||
const a = allConnectors[i];
|
||||
const b = allConnectors[j];
|
||||
if (a.partId === b.partId) continue;
|
||||
if (dist3(a.pos, b.pos) < CONNECT_DIST) {
|
||||
joints.push({ a, b });
|
||||
joinedKeys.add(a.key);
|
||||
joinedKeys.add(b.key);
|
||||
uf.union(a.key, b.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Part-level adjacency + components.
|
||||
const neighbors = new Map<string, Set<string>>();
|
||||
const addNeighbor = (a: string, b: string) => {
|
||||
let s = neighbors.get(a);
|
||||
if (!s) neighbors.set(a, (s = new Set()));
|
||||
s.add(b);
|
||||
};
|
||||
for (const { a, b } of joints) {
|
||||
addNeighbor(a.partId, b.partId);
|
||||
addNeighbor(b.partId, a.partId);
|
||||
}
|
||||
|
||||
const components: Set<string>[] = [];
|
||||
const componentOf = new Map<string, number>();
|
||||
for (const part of parts) {
|
||||
if (!connectorsByPart.get(part.id)?.length) continue;
|
||||
if (componentOf.has(part.id)) continue;
|
||||
const comp = new Set<string>();
|
||||
const stack = [part.id];
|
||||
componentOf.set(part.id, components.length);
|
||||
while (stack.length) {
|
||||
const id = stack.pop()!;
|
||||
comp.add(id);
|
||||
for (const nb of neighbors.get(id) ?? []) {
|
||||
if (!componentOf.has(nb)) {
|
||||
componentOf.set(nb, components.length);
|
||||
stack.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
components.push(comp);
|
||||
}
|
||||
|
||||
// 3. Node-level graph (connector groups + part internal nodes).
|
||||
const nodes = new Map<string, GraphNode>();
|
||||
const nodeOfConnector = new Map<string, string>();
|
||||
const getNode = (id: string, pos: Vec3): GraphNode => {
|
||||
let n = nodes.get(id);
|
||||
if (!n) {
|
||||
n = { id, pos, edges: [] };
|
||||
nodes.set(id, n);
|
||||
}
|
||||
return n;
|
||||
};
|
||||
for (const part of parts) {
|
||||
const conns = connectorsByPart.get(part.id) ?? [];
|
||||
if (!conns.length) continue;
|
||||
const pNode = getNode(`P:${part.id}`, part.position);
|
||||
for (const c of conns) {
|
||||
const groupId = `N:${uf.find(c.key)}`;
|
||||
nodeOfConnector.set(c.key, groupId);
|
||||
const gNode = getNode(groupId, c.pos);
|
||||
gNode.edges.push({ to: pNode.id, partId: part.id });
|
||||
pNode.edges.push({ to: groupId, partId: part.id });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connectorsByPart,
|
||||
joints,
|
||||
joinedKeys,
|
||||
neighbors,
|
||||
componentOf,
|
||||
components,
|
||||
nodes,
|
||||
nodeOfConnector,
|
||||
};
|
||||
}
|
||||
26
src/validation/index.ts
Normal file
26
src/validation/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Design Check — build validation for Hydro Builder.
|
||||
*
|
||||
* Public API:
|
||||
*
|
||||
* import { validateSystem, DesignCheckPanel } from './validation';
|
||||
*
|
||||
* // On Play (or a "Check Build" button). `sim` is optional — pass the
|
||||
* // result you already have from useSimulation() to avoid re-simulating.
|
||||
* const findings = validateSystem(parts, sim);
|
||||
*
|
||||
* // Render the results drawer (purely presentational):
|
||||
* <DesignCheckPanel
|
||||
* findings={findings}
|
||||
* onSelectPart={(id) => useBuilder.getState().setSelected(id)}
|
||||
* onClose={() => setShowCheck(false)}
|
||||
* />
|
||||
*/
|
||||
|
||||
export { validateSystem } from './designCheck';
|
||||
export { DesignCheckPanel } from './DesignCheckPanel';
|
||||
export type { DesignCheckPanelProps } from './DesignCheckPanel';
|
||||
export type { Finding, FindingSeverity, RuleContext, ValidationRule } from './types';
|
||||
export { ALL_RULES } from './rules';
|
||||
export { buildSystemGraph } from './graph';
|
||||
export type { SystemGraph } from './graph';
|
||||
885
src/validation/rules.ts
Normal file
885
src/validation/rules.ts
Normal file
@@ -0,0 +1,885 @@
|
||||
import type { PlacedPart } from '../types';
|
||||
import type { Finding, RuleContext, ValidationRule } from './types';
|
||||
import { approxAABB, isExitPart, isPumpOff, partName } from './graph';
|
||||
import { computeReservoirWaterStats } from '../simulation/flowSimulator';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { dist3 } from '../utils/connectors';
|
||||
|
||||
/**
|
||||
* Design-check rules. Each rule is a pure function over a RuleContext and is
|
||||
* registered in `ALL_RULES` at the bottom — add new rules there.
|
||||
*
|
||||
* Defensive style: sibling agents may evolve the simulator/part shapes, so
|
||||
* everything reads via optional chaining and tolerates missing data.
|
||||
*/
|
||||
|
||||
const fmt = (n: number, digits = 1) => Number(n.toFixed(digits)).toString();
|
||||
|
||||
/** Fraction of max head above which a pump is "near its limit". */
|
||||
export const HEAD_MARGIN_RATIO = 0.8;
|
||||
/** A flow path that descends this many feet after a climb risks siphoning. */
|
||||
export const SIPHON_DROP_FT = 2;
|
||||
/** Horizontal pipes above this height (ft) need support. */
|
||||
export const SPAN_MIN_HEIGHT_FT = 1.5;
|
||||
/** Horizontal pipes longer than this (ft) need support. */
|
||||
export const SPAN_MIN_LENGTH_FT = 4;
|
||||
/** A support must reach within this distance (ft) beneath a pipe end. */
|
||||
export const SUPPORT_REACH_FT = 0.6;
|
||||
|
||||
// ---------- a. pump head margin ----------
|
||||
|
||||
function getPumpStaticHead(ctx: RuleContext, pumpId: string): number {
|
||||
const pump = ctx.partsMap[pumpId];
|
||||
if (!pump) return 0;
|
||||
const conns = ctx.graph.connectorsByPart.get(pumpId) ?? [];
|
||||
const outlet = conns.find((c) => c.connectorId === 'out') ?? conns[conns.length - 1];
|
||||
if (!outlet) return 0;
|
||||
|
||||
let maxY = pump.position[1];
|
||||
const queue = [outlet.partId];
|
||||
const visited = new Set<string>([pumpId]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currId = queue.shift()!;
|
||||
if (visited.has(currId)) continue;
|
||||
visited.add(currId);
|
||||
|
||||
const part = ctx.partsMap[currId];
|
||||
if (part) {
|
||||
maxY = Math.max(maxY, part.position[1]);
|
||||
}
|
||||
|
||||
const nbs = ctx.graph.neighbors.get(currId);
|
||||
if (nbs) {
|
||||
for (const nb of nbs) {
|
||||
if (!visited.has(nb)) {
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, maxY - pump.position[1]);
|
||||
}
|
||||
|
||||
function pumpHeadMargin(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pr of ctx.sim?.pumps ?? []) {
|
||||
const pump = ctx.partsMap[pr?.pumpId ?? ''];
|
||||
if (!pump || isPumpOff(pump)) continue;
|
||||
const maxHead = pr.maxHeadFt ?? 0;
|
||||
const head = getPumpStaticHead(ctx, pump.id);
|
||||
if (maxHead <= 0) continue;
|
||||
const ratio = head / maxHead;
|
||||
if (ratio <= HEAD_MARGIN_RATIO) continue;
|
||||
|
||||
const name = partName(pump);
|
||||
// Lowering the high point by this much restores a 20% margin.
|
||||
const lowerBy = Math.max(0.1, head - HEAD_MARGIN_RATIO * maxHead);
|
||||
const fix = `Choose a higher-head pump or lower the highest point by ${fmt(lowerBy)} ft`;
|
||||
if (ratio >= 1) {
|
||||
findings.push({
|
||||
id: `pump-head:${pump.id}`,
|
||||
severity: 'error',
|
||||
title: 'Pump cannot reach the highest point',
|
||||
detail: `${name} must lift water ${fmt(head)} ft but its max head is ${fmt(maxHead)} ft — it delivers no flow.`,
|
||||
partIds: [pump.id],
|
||||
fix,
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
id: `pump-head:${pump.id}`,
|
||||
severity: 'warning',
|
||||
title: `Pump near its limit (${Math.round(ratio * 100)}%), keep \u226520% margin`,
|
||||
detail: `${name} is lifting ${fmt(head)} ft of ${fmt(maxHead)} ft max head, so flow is heavily reduced and the pump will wear quickly.`,
|
||||
partIds: [pump.id],
|
||||
fix,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
// ---------- b. pump starved / off ----------
|
||||
|
||||
/** BFS the part graph from the pump inlet (never through the pump) for a reservoir. */
|
||||
function inletReachesReservoir(ctx: RuleContext, pumpId: string, inletKey: string): boolean {
|
||||
const start: string[] = [];
|
||||
for (const { a, b } of ctx.graph.joints) {
|
||||
if (a.key === inletKey) start.push(b.partId);
|
||||
if (b.key === inletKey) start.push(a.partId);
|
||||
}
|
||||
const seen = new Set<string>([pumpId, ...start]);
|
||||
const queue = [...start];
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
if (ctx.partsMap[id]?.type === 'reservoir') return true;
|
||||
for (const nb of ctx.graph.neighbors.get(id) ?? []) {
|
||||
if (!seen.has(nb)) {
|
||||
seen.add(nb);
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pumpStarvedOrOff(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pump of ctx.parts) {
|
||||
if (pump.type !== 'pump') continue;
|
||||
const name = partName(pump);
|
||||
|
||||
if (isPumpOff(pump)) {
|
||||
findings.push({
|
||||
id: `pump-dead:${pump.id}`,
|
||||
severity: 'error',
|
||||
title: 'Pump is switched off',
|
||||
detail: `${name} is powered off, so it delivers no flow.`,
|
||||
partIds: [pump.id],
|
||||
fix: 'Turn the pump on (power toggle in the inspector).',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const conns = ctx.graph.connectorsByPart.get(pump.id) ?? [];
|
||||
const inlet = conns.find((c) => c.connectorId === 'in') ?? conns[0];
|
||||
const outlet = conns.find((c) => c.connectorId === 'out') ?? conns[conns.length - 1];
|
||||
|
||||
const hasOutlet = outlet && ctx.graph.joinedKeys.has(outlet.key);
|
||||
const hasInlet = inlet && ctx.graph.joinedKeys.has(inlet.key);
|
||||
|
||||
const pr = ctx.sim?.pumps?.find((p) => p?.pumpId === pump.id);
|
||||
if (pr && (pr.gph ?? 0) > 0 && hasOutlet && hasInlet) continue;
|
||||
// Over-head pumps are owned by the head-margin rule — don't double report.
|
||||
const staticHead = getPumpStaticHead(ctx, pump.id);
|
||||
if (pr && (pr.maxHeadFt ?? 0) > 0 && staticHead >= (pr.maxHeadFt ?? 0)) continue;
|
||||
|
||||
let detail: string;
|
||||
let fix: string;
|
||||
if (inlet && !ctx.graph.joinedKeys.has(inlet.key)) {
|
||||
detail = `${name}'s inlet is not connected to anything — there is no water source.`;
|
||||
fix = 'Connect the pump inlet to a reservoir.';
|
||||
} else if (inlet && !inletReachesReservoir(ctx, pump.id, inlet.key)) {
|
||||
detail = `${name}'s inlet line never reaches a reservoir — there is no water source.`;
|
||||
fix = 'Route the inlet line back to a reservoir.';
|
||||
} else if (outlet && !ctx.graph.joinedKeys.has(outlet.key)) {
|
||||
detail = `${name}'s outlet is not connected to anything — water has nowhere to go.`;
|
||||
fix = 'Connect the pump outlet to your plumbing.';
|
||||
} else {
|
||||
const simMsg = ctx.sim?.warnings?.find(
|
||||
(w) => w?.partId === pump.id && w?.level !== 'info',
|
||||
)?.message;
|
||||
detail = simMsg ?? `${name} delivers no flow.`;
|
||||
fix = 'Check for closed valves and verify the line reaches an outlet.';
|
||||
}
|
||||
|
||||
findings.push({
|
||||
id: `pump-dead:${pump.id}`,
|
||||
severity: 'error',
|
||||
title: 'Pump delivers no flow',
|
||||
detail,
|
||||
partIds: [pump.id],
|
||||
fix,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- c. mismatched diameters ----------
|
||||
|
||||
function getConnectorDiameter(part: PlacedPart, connId: string): number {
|
||||
if (part.type === 'reducer' || part.type === 'reducingElbow') {
|
||||
return connId === 'a' ? (part.params.diameterA ?? 1.5) : (part.params.diameterB ?? 1.0);
|
||||
}
|
||||
if (part.type === 'reducingTee') {
|
||||
return connId === 'c' || connId === 'branch' ? (part.params.diameterB ?? 1.0) : (part.params.diameterA ?? 1.5);
|
||||
}
|
||||
return part.params.diameter ?? 1.0;
|
||||
}
|
||||
|
||||
function mismatchedDiameters(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const { a, b } of ctx.graph.joints) {
|
||||
const pa = ctx.partsMap[a.partId];
|
||||
const pb = ctx.partsMap[b.partId];
|
||||
if (!pa || !pb) continue;
|
||||
const da = getConnectorDiameter(pa, a.connectorId);
|
||||
const db = getConnectorDiameter(pb, b.connectorId);
|
||||
if (Math.abs(da - db) < 0.01) continue;
|
||||
|
||||
const pairKey = [a.partId, b.partId].sort().join(':');
|
||||
if (seen.has(pairKey)) continue;
|
||||
seen.add(pairKey);
|
||||
|
||||
const nameA = partName(pa);
|
||||
const nameB = partName(pb);
|
||||
findings.push({
|
||||
id: `diameter-mismatch:${pairKey}`,
|
||||
severity: 'warning',
|
||||
title: 'Mismatched pipe diameters at a joint',
|
||||
detail: `${nameA} (${fmt(da, 2)} in) is joined to ${nameB} (${fmt(db, 2)} in) — the abrupt size change causes turbulence and pressure loss.`,
|
||||
partIds: [a.partId, b.partId],
|
||||
fix: `Add a reducer or match diameters (${nameA} is ${fmt(da, 2)} in, ${nameB} is ${fmt(db, 2)} in)`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- d. dead legs ----------
|
||||
|
||||
function deadLegs(ctx: RuleContext): Finding[] {
|
||||
const flows = ctx.sim?.flows ?? {};
|
||||
const flowing = new Set(Object.keys(flows).filter((id) => (flows[id] ?? 0) > 0));
|
||||
if (!flowing.size) return []; // nothing is flowing — orphan/pump rules apply instead
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const part of ctx.parts) {
|
||||
const conns = ctx.graph.connectorsByPart.get(part.id) ?? [];
|
||||
if (!conns.length) continue;
|
||||
if (part.type === 'pump' || isExitPart(part)) continue;
|
||||
const joinedCount = conns.filter((c) => ctx.graph.joinedKeys.has(c.key)).length;
|
||||
// Terminal = attached on one side but with an open (capped/dangling) end.
|
||||
if (joinedCount === 0 || joinedCount === conns.length) continue;
|
||||
|
||||
// Walk back through the plain-conduit chain to where the branch attaches.
|
||||
const chain = [part.id];
|
||||
let prev: string | null = null;
|
||||
let cur = part.id;
|
||||
let attached: string | null = null;
|
||||
for (let guard = 0; guard < ctx.parts.length; guard++) {
|
||||
const nbrs = [...(ctx.graph.neighbors.get(cur) ?? [])].filter(
|
||||
(n) => n !== prev && !chain.includes(n),
|
||||
);
|
||||
if (nbrs.length !== 1) break;
|
||||
const next = nbrs[0];
|
||||
const nextPart = ctx.partsMap[next];
|
||||
const isJunction = (ctx.graph.neighbors.get(next)?.size ?? 0) >= 3;
|
||||
if (!nextPart || nextPart.type === 'pump' || isExitPart(nextPart) || isJunction) {
|
||||
attached = next;
|
||||
break;
|
||||
}
|
||||
chain.push(next);
|
||||
prev = cur;
|
||||
cur = next;
|
||||
}
|
||||
|
||||
const branchFlows =
|
||||
(attached !== null && flowing.has(attached)) || chain.some((id) => flowing.has(id));
|
||||
if (!branchFlows) continue;
|
||||
if (chain.some((id) => isExitPart(ctx.partsMap[id]))) continue;
|
||||
|
||||
findings.push({
|
||||
id: `dead-leg:${part.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Dead leg — stagnant water',
|
||||
detail: `${partName(part)} ends a flowing branch with no reservoir, emitter, drain or grow part — water will sit stagnant (or leak) here.`,
|
||||
partIds: chain,
|
||||
fix: 'Cap with an emitter/drain or remove',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- e. unsupported spans ----------
|
||||
|
||||
function unsupportedSpans(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pipe of ctx.parts) {
|
||||
if (pipe.type !== 'pipe') continue;
|
||||
const ends = ctx.graph.connectorsByPart.get(pipe.id) ?? [];
|
||||
if (ends.length < 2) continue;
|
||||
const [ea, eb] = [ends[0].pos, ends[ends.length - 1].pos];
|
||||
if (Math.abs(ea[1] - eb[1]) > 0.3) continue; // not horizontal
|
||||
const centerY = (ea[1] + eb[1]) / 2;
|
||||
if (centerY <= SPAN_MIN_HEIGHT_FT) continue;
|
||||
const length = pipe.params?.length ?? Math.hypot(eb[0] - ea[0], eb[2] - ea[2]);
|
||||
if (length <= SPAN_MIN_LENGTH_FT) continue;
|
||||
|
||||
const supported = (end: [number, number, number]) =>
|
||||
ctx.parts.some((q) => {
|
||||
if (q.id === pipe.id) return false;
|
||||
const box = approxAABB(q, ctx.graph.connectorsByPart.get(q.id) ?? []);
|
||||
const margin = 0.3;
|
||||
const horizontallyUnder =
|
||||
end[0] >= box.min[0] - margin &&
|
||||
end[0] <= box.max[0] + margin &&
|
||||
end[2] >= box.min[2] - margin &&
|
||||
end[2] <= box.max[2] + margin;
|
||||
// The support must occupy the space just beneath the end: reach up to
|
||||
// within SUPPORT_REACH_FT of the pipe while extending from below it.
|
||||
return (
|
||||
horizontallyUnder &&
|
||||
box.max[1] >= end[1] - SUPPORT_REACH_FT &&
|
||||
box.min[1] <= end[1] - SUPPORT_REACH_FT
|
||||
);
|
||||
});
|
||||
|
||||
if (supported(ea) || supported(eb)) continue;
|
||||
|
||||
findings.push({
|
||||
id: `unsupported-span:${pipe.id}`,
|
||||
severity: 'info',
|
||||
title: `Unsupported span ${fmt(length)} ft`,
|
||||
detail: `${partName(pipe)} runs ${fmt(length)} ft at ${fmt(centerY)} ft height with nothing beneath either end — it will sag over time.`,
|
||||
partIds: [pipe.id],
|
||||
fix: 'Add a lattice/structure support',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- f. siphon risk ----------
|
||||
|
||||
function siphonRisk(ctx: RuleContext): Finding[] {
|
||||
const flows = ctx.sim?.flows ?? {};
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const pr of ctx.sim?.pumps ?? []) {
|
||||
if ((pr?.gph ?? 0) <= 0) continue;
|
||||
const pumpId = pr.pumpId;
|
||||
const startId = ctx.graph.nodeOfConnector.get(`${pumpId}:out`);
|
||||
const startNode = startId ? ctx.graph.nodes.get(startId) : undefined;
|
||||
if (!startNode) continue;
|
||||
|
||||
const startY = startNode.pos[1];
|
||||
let bestDrop = 0;
|
||||
let bestHighPart = pumpId;
|
||||
let bestHighY = startY;
|
||||
|
||||
const visited = new Set<string>([`P:${pumpId}`, startNode.id]);
|
||||
const stack: { id: string; pathMax: number; highPart: string }[] = [
|
||||
{ id: startNode.id, pathMax: startY, highPart: pumpId },
|
||||
];
|
||||
while (stack.length) {
|
||||
const { id, pathMax, highPart } = stack.pop()!;
|
||||
const node = ctx.graph.nodes.get(id);
|
||||
if (!node) continue;
|
||||
for (const e of node.edges) {
|
||||
if (visited.has(e.to)) continue;
|
||||
if ((flows[e.partId] ?? 0) <= 0 && e.partId !== pumpId) continue;
|
||||
visited.add(e.to);
|
||||
const to = ctx.graph.nodes.get(e.to);
|
||||
if (!to) continue;
|
||||
let max = pathMax;
|
||||
let nextHigh = highPart;
|
||||
if (to.pos[1] > max) {
|
||||
max = to.pos[1];
|
||||
nextHigh = e.partId;
|
||||
}
|
||||
const drop = max - to.pos[1];
|
||||
if (max > startY + 0.5 && drop > bestDrop) {
|
||||
bestDrop = drop;
|
||||
bestHighPart = nextHigh;
|
||||
bestHighY = max;
|
||||
}
|
||||
stack.push({ id: e.to, pathMax: max, highPart: nextHigh });
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDrop >= SIPHON_DROP_FT) {
|
||||
findings.push({
|
||||
id: `siphon:${pumpId}`,
|
||||
severity: 'info',
|
||||
title: 'Possible siphon on shutdown',
|
||||
detail: `Flow from ${partName(ctx.partsMap[pumpId])} climbs to ${fmt(bestHighY)} ft and then descends ${fmt(bestDrop)} ft — when the pump stops, gravity can keep siphoning water through the line.`,
|
||||
partIds: [bestHighPart],
|
||||
fix: 'Add an air gap or check valve at the high point',
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- g. undersized drains ----------
|
||||
|
||||
function undersizedDrains(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const part of ctx.parts) {
|
||||
const isDrain = part.type === 'drain' || typeof part.params?.capacityGph === 'number';
|
||||
if (!isDrain) continue;
|
||||
const capacity = part.params?.capacityGph ?? 250;
|
||||
// Prefer the sim's pre-clamp inflow report; fall back to the flow map.
|
||||
const reported = ctx.sim?.drains?.find?.((d) => d?.partId === part.id)?.inflowGph;
|
||||
const inflow = reported ?? ctx.sim?.flows?.[part.id] ?? 0;
|
||||
if (inflow <= capacity + 0.5) continue;
|
||||
|
||||
const needed = Math.ceil(inflow / 25) * 25;
|
||||
findings.push({
|
||||
id: `drain-capacity:${part.id}`,
|
||||
severity: 'error',
|
||||
title: 'Undersized drain — will overflow',
|
||||
detail: `${partName(part)} receives ${Math.round(inflow)} GPH but is rated for ${Math.round(capacity)} GPH — the excess will back up and flood.`,
|
||||
partIds: [part.id],
|
||||
fix: `Upsize drain to \u2265 ${needed} GPH capacity`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- h. net-pot spill risk ----------
|
||||
|
||||
function netPotSpills(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pot of ctx.sim?.netPots ?? []) {
|
||||
if (!pot?.overflowing) continue;
|
||||
const part = ctx.partsMap[pot.partId];
|
||||
if (!part) continue;
|
||||
const source = ctx.partsMap[pot.sourcePartId];
|
||||
findings.push({
|
||||
id: `net-pot-spill:${pot.partId}`,
|
||||
severity: 'error',
|
||||
title: 'Net pot will overflow from backed-up plumbing',
|
||||
detail: `${partName(part)} sits above ${partName(source)}. Water is rising to ${fmt(pot.waterHeightFt)} ft while the pot rim is ${fmt(pot.rimHeightFt)} ft, so nutrient solution will spill out of the top.`,
|
||||
partIds: source ? [pot.partId, source.id] : [pot.partId],
|
||||
fix: 'Add a drain/outlet, lower water level, or move the net pot off the flooded line.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- i. orphans ----------
|
||||
|
||||
function orphans(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const part of ctx.parts) {
|
||||
const conns = ctx.graph.connectorsByPart.get(part.id) ?? [];
|
||||
if (!conns.length) continue; // decorative/structural parts can't be orphans
|
||||
if (conns.some((c) => ctx.graph.joinedKeys.has(c.key))) continue;
|
||||
findings.push({
|
||||
id: `orphan:${part.id}`,
|
||||
severity: 'info',
|
||||
title: 'Not connected to anything',
|
||||
detail: `${partName(part)} has no connections — it is not part of any water circuit.`,
|
||||
partIds: [part.id],
|
||||
fix: 'Drag it onto a matching connector, or delete it if unused.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- j. closed valves ----------
|
||||
|
||||
function closedValves(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const valve of ctx.parts) {
|
||||
if (valve.type !== 'valve' || valve.params?.open !== 0) continue;
|
||||
const compIdx = ctx.graph.componentOf.get(valve.id);
|
||||
if (compIdx === undefined) continue;
|
||||
const comp = ctx.graph.components[compIdx];
|
||||
const hasLivePump = [...comp].some((id) => {
|
||||
const p: PlacedPart | undefined = ctx.partsMap[id];
|
||||
return p?.type === 'pump' && !isPumpOff(p);
|
||||
});
|
||||
if (!hasLivePump) continue;
|
||||
findings.push({
|
||||
id: `closed-valve:${valve.id}`,
|
||||
severity: 'info',
|
||||
title: 'Closed valve blocking a flowing line',
|
||||
detail: `${partName(valve)} is fully closed — everything beyond it gets no water.`,
|
||||
partIds: [valve.id],
|
||||
fix: 'Open the valve, or remove it if the branch is unused.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- k. environment / room planning ----------
|
||||
|
||||
function roomEnvironment(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const room of ctx.roomMetrics) {
|
||||
const roomPart = ctx.partsMap[room.roomId];
|
||||
const width = roomPart?.params.width ?? 0;
|
||||
const depth = roomPart?.params.depth ?? 0;
|
||||
const canopyArea = width * depth;
|
||||
|
||||
if (!room.sealed && room.co2TankCount > 0) {
|
||||
findings.push({
|
||||
id: `room-co2-open:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'CO2 enrichment wasted in open-air room',
|
||||
detail: `${room.label} is marked open air but has ${room.co2TankCount} CO2 tank${room.co2TankCount === 1 ? '' : 's'} — outside air exchange will purge enrichment before plants can use it.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Seal the room or remove CO2 hardware.',
|
||||
});
|
||||
}
|
||||
|
||||
if (room.sealed && room.airChangesPerMinute > 1) {
|
||||
findings.push({
|
||||
id: `room-sealed-exhaust:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Sealed room exhausting too aggressively',
|
||||
detail: `${room.label} is marked sealed but exhausts ${room.airChangesPerMinute.toFixed(2)} room volumes per minute — that behaves more like an open-air room and dumps conditioned CO2-rich air.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Reduce exhaust CFM or switch the room to open air.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!room.sealed && room.airChangesPerMinute < 0.5) {
|
||||
findings.push({
|
||||
id: `room-vent-low:${room.roomId}`,
|
||||
severity: 'info',
|
||||
title: 'Low air exchange for open-air room',
|
||||
detail: `${room.label} exchanges only ${room.airChangesPerMinute.toFixed(2)} room volumes per minute — heat and humidity can linger unless intake/exhaust is stronger.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Increase exhaust capacity or reduce room volume.',
|
||||
});
|
||||
}
|
||||
|
||||
if (room.sealed && room.co2TankCount === 0 && room.lightWatts >= 600) {
|
||||
findings.push({
|
||||
id: `room-co2-missing:${room.roomId}`,
|
||||
severity: 'info',
|
||||
title: 'High-light sealed room without CO2',
|
||||
detail: `${room.label} is sealed and carries ${Math.round(room.lightWatts)} W of lighting but no CO2 source — the room can be enriched, but currently cannot take advantage of the sealed configuration.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Add a CO2 tank or switch to open-air ventilation.',
|
||||
});
|
||||
}
|
||||
|
||||
if (canopyArea > 0 && room.lightWatts > 0) {
|
||||
const wattsPerFt2 = room.lightWatts / canopyArea;
|
||||
if (wattsPerFt2 < 25) {
|
||||
findings.push({
|
||||
id: `room-light-low:${room.roomId}`,
|
||||
severity: 'info',
|
||||
title: 'Low lighting density',
|
||||
detail: `${room.label} provides ${wattsPerFt2.toFixed(1)} W/ft2 across ${canopyArea.toFixed(1)} ft2 — useful for propagation or low-light crops, but light-hungry plants will underperform.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Add more fixture wattage or reduce active canopy area.',
|
||||
});
|
||||
} else if (wattsPerFt2 > 55) {
|
||||
findings.push({
|
||||
id: `room-light-high:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Very high lighting density',
|
||||
detail: `${room.label} is carrying ${wattsPerFt2.toFixed(1)} W/ft2 — that is intense enough to demand strong cooling, CO2 strategy, and careful canopy distance management.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Raise fixtures, reduce wattage, or tighten environmental control.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// New environment/lighting findings
|
||||
if (room.lightWatts > 0) {
|
||||
if (room.roomDLI < 10) {
|
||||
findings.push({
|
||||
id: `room-dli-low:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Daily Light Integral (DLI) is too low',
|
||||
detail: `${room.label} active DLI is ${room.roomDLI.toFixed(1)} — DLI < 10 is insufficient for healthy growth and will cause the crop to stretch.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Increase grow lights wattage, increase photoperiod (hours on), or decrease room/canopy area.',
|
||||
});
|
||||
} else if (room.roomDLI > 45) {
|
||||
findings.push({
|
||||
id: `room-dli-high:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Daily Light Integral (DLI) is too high',
|
||||
detail: `${room.label} active DLI is ${room.roomDLI.toFixed(1)} — DLI > 45 risks phototoxic bleaching and leaf tissue damage.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Reduce grow lights wattage, decrease photoperiod (hours on), or raise the fixtures.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (room.sealed && room.lightWatts > 400 && room.roomCo2 === 150) {
|
||||
findings.push({
|
||||
id: `room-co2-starved:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Sealed room CO₂ starvation risk',
|
||||
detail: `${room.label} is sealed and runs high-wattage lighting (${room.lightWatts.toFixed(0)} W > 400 W), but CO₂ levels drop to 150 PPM due to lack of a CO₂ tank.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Add a CO₂ tank or switch to open-air ventilation.',
|
||||
});
|
||||
}
|
||||
|
||||
const growthStage = useBuilder.getState().growthStage ?? 'vegetative';
|
||||
let minVpd = 0.8;
|
||||
let maxVpd = 1.6;
|
||||
if (growthStage === 'seedling') {
|
||||
minVpd = 0.4;
|
||||
maxVpd = 0.8;
|
||||
} else if (growthStage === 'vegetative') {
|
||||
minVpd = 0.8;
|
||||
maxVpd = 1.2;
|
||||
} else if (growthStage === 'flowering') {
|
||||
minVpd = 1.2;
|
||||
maxVpd = 1.6;
|
||||
}
|
||||
|
||||
if (room.roomVPD < minVpd) {
|
||||
findings.push({
|
||||
id: `room-vpd-low:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Vapor Pressure Deficit (VPD) is too low',
|
||||
detail: `${room.label} VPD is ${room.roomVPD.toFixed(2)} kPa, which is below the optimal range of ${minVpd}-${maxVpd} kPa for the ${growthStage} stage. Low VPD slows transpiration and can cause nutrient deficiencies.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Raise the temperature, lower relative humidity, or increase exhaust ventilation.',
|
||||
});
|
||||
} else if (room.roomVPD > maxVpd) {
|
||||
findings.push({
|
||||
id: `room-vpd-high:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Vapor Pressure Deficit (VPD) is too high',
|
||||
detail: `${room.label} VPD is ${room.roomVPD.toFixed(2)} kPa, which is above the optimal range of ${minVpd}-${maxVpd} kPa for the ${growthStage} stage. High VPD causes excessive transpiration, leading to plant stress and wilting.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Lower the temperature, increase relative humidity, or reduce lighting intensity.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- registry ----------
|
||||
|
||||
/** All design-check rules, in execution order. Add new rules here. */
|
||||
export const ALL_RULES: ValidationRule[] = [
|
||||
{
|
||||
id: 'pump-head',
|
||||
description: 'Pumps should keep ≥20% head margin below their max head.',
|
||||
run: pumpHeadMargin,
|
||||
},
|
||||
{
|
||||
id: 'pump-dead',
|
||||
description: 'Pumps that are off, starved of source water, or dead-ended.',
|
||||
run: pumpStarvedOrOff,
|
||||
},
|
||||
{
|
||||
id: 'diameter-mismatch',
|
||||
description: 'Joined connectors whose parts have different pipe diameters.',
|
||||
run: mismatchedDiameters,
|
||||
},
|
||||
{
|
||||
id: 'dead-leg',
|
||||
description: 'Flowing branches that terminate with no exit part (stagnation).',
|
||||
run: deadLegs,
|
||||
},
|
||||
{
|
||||
id: 'unsupported-span',
|
||||
description: 'Long elevated horizontal pipes with nothing beneath their ends.',
|
||||
run: unsupportedSpans,
|
||||
},
|
||||
{
|
||||
id: 'siphon',
|
||||
description: 'Flow paths that climb then descend ≥2 ft (siphon on shutdown).',
|
||||
run: siphonRisk,
|
||||
},
|
||||
{
|
||||
id: 'drain-capacity',
|
||||
description: 'Drains receiving more flow than their rated capacity.',
|
||||
run: undersizedDrains,
|
||||
},
|
||||
{
|
||||
id: 'net-pot-spill',
|
||||
description: 'Net pots positioned over backed-up wet lines that will spill from the rim.',
|
||||
run: netPotSpills,
|
||||
},
|
||||
{
|
||||
id: 'orphan',
|
||||
description: 'Flow-capable parts with no connections at all.',
|
||||
run: orphans,
|
||||
},
|
||||
{
|
||||
id: 'closed-valve',
|
||||
description: 'Fully closed valves on an otherwise powered circuit.',
|
||||
run: closedValves,
|
||||
},
|
||||
{
|
||||
id: 'room-environment',
|
||||
description: 'Grow-room ventilation, sealed/open-air behavior, CO2 use, and light density.',
|
||||
run: roomEnvironment,
|
||||
},
|
||||
{
|
||||
id: 'reservoir-chemistry',
|
||||
description: 'Reservoir temperature, pH, and EC/nutrient concentration values.',
|
||||
run: reservoirWaterChemistry,
|
||||
},
|
||||
{
|
||||
id: 'part-overlap',
|
||||
description: 'Parts that are overlapping or morphing into one another.',
|
||||
run: partOverlaps,
|
||||
},
|
||||
];
|
||||
|
||||
function reservoirWaterChemistry(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
const reservoirs = ctx.parts.filter((p) => p.type === 'reservoir');
|
||||
|
||||
for (const res of reservoirs) {
|
||||
const stats = computeReservoirWaterStats(res, ctx.partsMap);
|
||||
const runtime = ctx.sim?.reservoirRuntime?.[res.id];
|
||||
|
||||
if (runtime?.empty) {
|
||||
findings.push({
|
||||
id: `res-empty:${res.id}`,
|
||||
severity: 'error',
|
||||
title: 'Reservoir runs dry under current load',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} is fully depleted by the current pump/drain demand, so downstream flow stops entirely.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Increase reservoir volume, reduce draw rate, or close the loop with return flow.',
|
||||
});
|
||||
} else if (runtime && runtime.minutesRemaining !== null && runtime.minutesRemaining < 30) {
|
||||
findings.push({
|
||||
id: `res-runtime-low:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Reservoir runtime is too short',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} has only ${runtime.minutesRemaining.toFixed(1)} minutes of water left at the current net draw of ${runtime.netOutflowGph.toFixed(1)} GPH.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Increase source volume, reduce pump flow, or return more water to this reservoir.',
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Water Temperature check
|
||||
if (stats.tempF > 73.0) {
|
||||
findings.push({
|
||||
id: `res-temp-high:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Reservoir water temperature too high',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} water temperature is ${stats.tempF.toFixed(1)}°F — water above 73°F holds significantly less dissolved oxygen and increases Pythium (root rot) risks.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Add a water chiller, reduce room ambient temperature, or turn off unneeded inline pumps.',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. pH check
|
||||
if (stats.ph < 5.5) {
|
||||
findings.push({
|
||||
id: `res-ph-low:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Water pH is too low (acidic)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} pH is ${stats.ph.toFixed(1)} — pH below 5.5 locks out Calcium, Magnesium, and Phosphorus, starving the plant.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Add pH Up (potassium hydroxide) or dilute with fresh water to raise pH to 5.8 - 6.2.',
|
||||
});
|
||||
} else if (stats.ph > 6.5) {
|
||||
findings.push({
|
||||
id: `res-ph-high:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Water pH is too high (alkaline)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} pH is ${stats.ph.toFixed(1)} — pH above 6.5 locks out Iron, Manganese, Boron, and Zinc, causing leaf chlorosis.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Add pH Down (phosphoric/citric acid) to lower pH to 5.8 - 6.2.',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. EC check
|
||||
const growthStage = useBuilder.getState().growthStage;
|
||||
let ecLow = 1.0;
|
||||
let ecHigh = 1.6;
|
||||
let fixLow = 'Add more concentrated nutrient solution (part A/B) to raise EC to 1.0 - 1.6 mS/cm.';
|
||||
let fixHigh = 'Dilute the reservoir with fresh water to reduce nutrient concentration (EC).';
|
||||
|
||||
if (growthStage === 'seedling') {
|
||||
ecLow = 0.4;
|
||||
ecHigh = 0.8;
|
||||
fixLow = 'Add dilute nutrient solution to raise EC to 0.4 - 0.8 mS/cm for seedlings.';
|
||||
fixHigh = 'Dilute the reservoir with fresh water to lower EC to 0.4 - 0.8 mS/cm for seedlings.';
|
||||
} else if (growthStage === 'flowering') {
|
||||
ecLow = 1.5;
|
||||
ecHigh = 2.2;
|
||||
fixLow = 'Add bloom nutrients to raise EC to 1.5 - 2.2 mS/cm for flowering.';
|
||||
fixHigh = 'Dilute the reservoir with fresh water to lower EC to 1.5 - 2.2 mS/cm for flowering.';
|
||||
}
|
||||
|
||||
if (stats.ec < ecLow) {
|
||||
findings.push({
|
||||
id: `res-ec-low:${res.id}`,
|
||||
severity: 'info',
|
||||
title: 'Low nutrient concentration (EC)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} EC is ${stats.ec.toFixed(1)} mS/cm — very low nutrient levels will limit growth rate and lead to deficiencies in the ${growthStage} stage.`,
|
||||
partIds: [res.id],
|
||||
fix: fixLow,
|
||||
});
|
||||
} else if (stats.ec > ecHigh) {
|
||||
findings.push({
|
||||
id: `res-ec-high:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'High nutrient concentration (EC)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} EC is ${stats.ec.toFixed(1)} mS/cm — EC above ${ecHigh.toFixed(1)} risks nutrient burn or root dehydration in the ${growthStage} stage.`,
|
||||
partIds: [res.id],
|
||||
fix: fixHigh,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function partOverlaps(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < ctx.parts.length; i++) {
|
||||
const p1 = ctx.parts[i];
|
||||
|
||||
for (let j = i + 1; j < ctx.parts.length; j++) {
|
||||
const p2 = ctx.parts[j];
|
||||
|
||||
// Calculate distance between centers
|
||||
const dist = dist3(p1.position, p2.position);
|
||||
|
||||
// If centers are extremely close (e.g. < 0.15 ft) and they occupy the same space
|
||||
if (dist < 0.15) {
|
||||
const key = [p1.id, p2.id].sort().join(':');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
findings.push({
|
||||
id: `part-overlap:${key}`,
|
||||
severity: 'warning',
|
||||
title: 'Overlapping parts (clipping)',
|
||||
detail: `${partName(p1)} and ${partName(p2)} are occupying the same space and morphing into each other.`,
|
||||
partIds: [p1.id, p2.id],
|
||||
fix: 'Delete the duplicate part or move them apart',
|
||||
});
|
||||
} else if (p1.type === 'pipe' && p2.type === 'pipe') {
|
||||
// Check for partial collinear overlap of two parallel pipes
|
||||
const yaw1 = p1.rotation[1];
|
||||
const yaw2 = p2.rotation[1];
|
||||
const angleDiff = Math.abs(Math.atan2(Math.sin(yaw1 - yaw2), Math.cos(yaw1 - yaw2)));
|
||||
const isParallel = angleDiff < 0.05 || Math.abs(angleDiff - Math.PI) < 0.05;
|
||||
|
||||
if (isParallel) {
|
||||
const dirX = Math.cos(yaw1);
|
||||
const dirZ = -Math.sin(yaw1);
|
||||
|
||||
const dx = p2.position[0] - p1.position[0];
|
||||
const dy = p2.position[1] - p1.position[1];
|
||||
const dz = p2.position[2] - p1.position[2];
|
||||
|
||||
// Project distance along pipe vector and find lateral offset
|
||||
const proj = dx * dirX + dz * dirZ;
|
||||
const latX = dx - proj * dirX;
|
||||
const latZ = dz - proj * dirZ;
|
||||
const latDist = Math.hypot(latX, dy, latZ);
|
||||
|
||||
if (latDist < 0.12) {
|
||||
const len1 = p1.params.length ?? 2;
|
||||
const len2 = p2.params.length ?? 2;
|
||||
const maxAllowedDist = (len1 + len2) / 2 - 0.05;
|
||||
|
||||
if (dist < maxAllowedDist - 0.2) {
|
||||
const key = [p1.id, p2.id].sort().join(':');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
findings.push({
|
||||
id: `part-overlap:${key}`,
|
||||
severity: 'warning',
|
||||
title: 'Collinear pipes overlapping',
|
||||
detail: `${partName(p1)} and ${partName(p2)} are collinear but overlapping by ${(maxAllowedDist - dist).toFixed(1)} ft, causing them to morph together.`,
|
||||
partIds: [p1.id, p2.id],
|
||||
fix: 'Adjust their positions or delete one',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
294
src/validation/selftest.ts
Normal file
294
src/validation/selftest.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Design Check self-test — run with: npx tsx src/validation/selftest.ts
|
||||
*
|
||||
* Builds synthetic part layouts that exercise every rule and asserts the
|
||||
* expected findings fire (and that healthy scenes stay clean).
|
||||
*
|
||||
* Geometry notes: connectors join when world positions are < CONNECT_DIST
|
||||
* (0.35 ft) apart. Rotation [0,0,π/2] turns a pipe vertical (local -x end
|
||||
* down); [0,-π/2,0] turns it along +z.
|
||||
*/
|
||||
|
||||
import type { PartType, PlacedPart, Vec3 } from '../types';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
import { validateSystem } from './designCheck';
|
||||
import type { Finding, FindingSeverity } from './types';
|
||||
import { applyReservoirRuntime, simulate } from '../simulation/flowSimulator';
|
||||
|
||||
const Z90: Vec3 = [0, 0, Math.PI / 2]; // pipe vertical, 'a' end at bottom
|
||||
const Yn90: Vec3 = [0, -Math.PI / 2, 0]; // pipe along z, 'a' end at -z
|
||||
|
||||
let id = 0;
|
||||
function P(
|
||||
type: PartType,
|
||||
position: Vec3,
|
||||
params: Record<string, number> = {},
|
||||
rotation: Vec3 = [0, 0, 0],
|
||||
): PlacedPart {
|
||||
return {
|
||||
id: `${type}${++id}`,
|
||||
type,
|
||||
position,
|
||||
rotation,
|
||||
params: { ...PART_DEFS[type].defaults, ...params },
|
||||
};
|
||||
}
|
||||
|
||||
/** reservoir -> pump chain: reservoir 'b' @ [1,.25,0] joins pump 'in'. */
|
||||
const source = (pumpParams: Record<string, number> = {}): PlacedPart[] => [
|
||||
P('reservoir', [0, 0, 0]),
|
||||
P('pump', [1.45, 0, 0], pumpParams), // in @ [1,.25,0], out @ [1.9,.25,0]
|
||||
];
|
||||
|
||||
// ---------- tiny test harness ----------
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function check(rule: string, name: string, ok: boolean, findings?: Finding[]) {
|
||||
if (ok) {
|
||||
passed++;
|
||||
console.log(` PASS [${rule}] ${name}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(` FAIL [${rule}] ${name}`);
|
||||
if (findings) {
|
||||
for (const f of findings) console.log(` -> ${f.severity} ${f.id}: ${f.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const has = (fs: Finding[], prefix: string, severity?: FindingSeverity) =>
|
||||
fs.some((f) => f.id.startsWith(prefix) && (severity === undefined || f.severity === severity));
|
||||
|
||||
console.log('Design Check self-test\n');
|
||||
|
||||
// ---------- a. pump head margin ----------
|
||||
{
|
||||
// 7 ft riser on an 8 ft max-head pump => 91% of max head -> warning.
|
||||
const parts = [
|
||||
...source({ gph: 400, maxHeadFt: 8 }),
|
||||
P('pipe', [1.9, 3.75, 0], { length: 7 }, Z90), // [1.9,.25,0] .. [1.9,7.25,0]
|
||||
P('emitter', [1.7, 7.25, 0]),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('pump-head', 'riser at 91% of max head -> warning', has(fs, 'pump-head:', 'warning'), fs);
|
||||
check('pump-dead', 'near-limit pump still flows -> no starved error', !has(fs, 'pump-dead:'), fs);
|
||||
}
|
||||
{
|
||||
// 9 ft riser exceeds the 8 ft max head -> error (and no duplicate starved error).
|
||||
const parts = [
|
||||
...source({ gph: 400, maxHeadFt: 8 }),
|
||||
P('pipe', [1.9, 4.75, 0], { length: 9 }, Z90), // top @ 9.25 ft
|
||||
P('emitter', [1.7, 9.25, 0]),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('pump-head', 'riser above max head -> error', has(fs, 'pump-head:', 'error'), fs);
|
||||
check('pump-dead', 'over-head pump owned by head rule -> no starved error', !has(fs, 'pump-dead:'), fs);
|
||||
}
|
||||
|
||||
// ---------- b. pump starved / off ----------
|
||||
{
|
||||
const parts = [
|
||||
...source({ on: 0 }),
|
||||
P('pipe', [2.9, 0.25, 0], { length: 2 }),
|
||||
P('emitter', [3.7, 0.25, 0]),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
const f = fs.find((x) => x.id.startsWith('pump-dead:'));
|
||||
check('pump-dead', 'switched-off pump -> error', f?.severity === 'error', fs);
|
||||
check('pump-dead', 'off reason mentioned', /off/i.test(f?.detail ?? ''), fs);
|
||||
}
|
||||
{
|
||||
// No reservoir anywhere -> starved (inlet not connected).
|
||||
const parts = [
|
||||
P('pump', [0, 0, 0]), // out @ [0.45,.25,0]
|
||||
P('pipe', [1.45, 0.25, 0], { length: 2 }),
|
||||
P('emitter', [2.25, 0.25, 0]),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
const f = fs.find((x) => x.id.startsWith('pump-dead:'));
|
||||
check('pump-dead', 'no water source -> error', f?.severity === 'error', fs);
|
||||
check('pump-dead', 'inlet reason mentioned', /inlet/i.test(f?.detail ?? ''), fs);
|
||||
}
|
||||
{
|
||||
// Reservoir + pump, outlet dangling -> starved (nothing downstream).
|
||||
const fs = validateSystem(source());
|
||||
const f = fs.find((x) => x.id.startsWith('pump-dead:'));
|
||||
check('pump-dead', 'nothing downstream -> error', f?.severity === 'error', fs);
|
||||
check('pump-dead', 'outlet reason mentioned', /outlet/i.test(f?.detail ?? ''), fs);
|
||||
}
|
||||
|
||||
// ---------- c. mismatched diameters ----------
|
||||
{
|
||||
const parts = [
|
||||
P('pipe', [0, 1, 0], { length: 2, diameter: 1 }), // b @ [1,1,0]
|
||||
P('pipe', [2, 1, 0], { length: 2, diameter: 2 }), // a @ [1,1,0]
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('diameter-mismatch', '1 in joined to 2 in -> warning', has(fs, 'diameter-mismatch:', 'warning'), fs);
|
||||
check('diameter-mismatch', 'no other findings in mismatch scene', fs.length === 1, fs);
|
||||
}
|
||||
{
|
||||
const parts = [
|
||||
P('pipe', [0, 1, 0], { length: 2, diameter: 1 }),
|
||||
P('pipe', [2, 1, 0], { length: 2, diameter: 1 }),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('diameter-mismatch', 'matching diameters -> clean', !has(fs, 'diameter-mismatch:'), fs);
|
||||
}
|
||||
|
||||
// ---------- d. dead legs ----------
|
||||
{
|
||||
// Tee splits: main run ends in an emitter, side branch ends open.
|
||||
const branch = P('pipe', [2.5, 0.25, 1.6], { length: 2 }, Yn90); // [2.5,.25,.6]..[2.5,.25,2.6]
|
||||
const parts = [
|
||||
...source(),
|
||||
P('tee', [2.5, 0.25, 0]), // a @ [1.9,.25,0], b @ [3.1,.25,0], c @ [2.5,.25,.6]
|
||||
P('pipe', [4.1, 0.25, 0], { length: 2 }), // b @ [5.1,.25,0]
|
||||
P('emitter', [4.9, 0.25, 0]),
|
||||
branch,
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
const f = fs.find((x) => x.id === `dead-leg:${branch.id}`);
|
||||
check('dead-leg', 'open side branch off a flowing tee -> warning', f?.severity === 'warning', fs);
|
||||
check('dead-leg', 'dead leg points at the branch pipe', f?.partIds.includes(branch.id) === true, fs);
|
||||
}
|
||||
|
||||
// ---------- e. unsupported spans ----------
|
||||
{
|
||||
const fs = validateSystem([P('pipe', [0, 2, 0], { length: 6 })]);
|
||||
check('unsupported-span', '6 ft pipe at 2 ft, nothing beneath -> info', has(fs, 'unsupported-span:', 'info'), fs);
|
||||
}
|
||||
{
|
||||
const parts = [
|
||||
P('pipe', [0, 2, 0], { length: 6 }),
|
||||
P('lattice', [-3, 0, 0], { width: 2.5, height: 3 }),
|
||||
P('lattice', [3, 0, 0], { width: 2.5, height: 3 }),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('unsupported-span', 'same span with lattices beneath -> clean', !has(fs, 'unsupported-span:'), fs);
|
||||
}
|
||||
{
|
||||
const fs = validateSystem([P('pipe', [0, 1, 0], { length: 6 })]);
|
||||
check('unsupported-span', 'long pipe below 1.5 ft -> clean', !has(fs, 'unsupported-span:'), fs);
|
||||
}
|
||||
|
||||
// ---------- f. siphon risk ----------
|
||||
{
|
||||
// Up 4 ft, across, then down 3 ft to an emitter -> siphon tip.
|
||||
const parts = [
|
||||
...source(),
|
||||
P('pipe', [1.9, 2.25, 0], { length: 4 }, Z90), // riser to [1.9,4.25,0]
|
||||
P('pipe', [2.9, 4.25, 0], { length: 2 }), // across to [3.9,4.25,0]
|
||||
P('pipe', [3.9, 2.75, 0], { length: 3 }, Z90), // down to [3.9,1.25,0]
|
||||
P('emitter', [3.7, 1.25, 0]),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('siphon', 'climb then 3 ft descent -> info', has(fs, 'siphon:', 'info'), fs);
|
||||
}
|
||||
|
||||
// ---------- g. undersized drain ----------
|
||||
{
|
||||
const drain = P('drain', [4.25, 0, 0], { capacityGph: 100 }); // in @ [3.9,.25,0]
|
||||
const parts = [
|
||||
...source({ gph: 600 }),
|
||||
P('pipe', [2.9, 0.25, 0], { length: 2 }),
|
||||
drain,
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
const f = fs.find((x) => x.id === `drain-capacity:${drain.id}`);
|
||||
check('drain-capacity', '~535 GPH into a 100 GPH drain -> error', f?.severity === 'error', fs);
|
||||
check('drain-capacity', 'fix suggests an upsized capacity', /\d+ GPH/.test(f?.fix ?? ''), fs);
|
||||
}
|
||||
|
||||
// ---------- h. orphans ----------
|
||||
{
|
||||
const lone = P('pipe', [10, 0.5, 10], { length: 2 });
|
||||
const parts = [
|
||||
...source(),
|
||||
P('pipe', [2.9, 0.25, 0], { length: 2 }),
|
||||
P('emitter', [3.7, 0.25, 0]),
|
||||
lone,
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('orphan', 'disconnected pipe -> info', has(fs, `orphan:${lone.id}`, 'info'), fs);
|
||||
check('orphan', 'connected parts not flagged', fs.filter((f) => f.id.startsWith('orphan:')).length === 1, fs);
|
||||
}
|
||||
|
||||
// ---------- i. finite source runtime ----------
|
||||
{
|
||||
const realNow = Date.now;
|
||||
let fakeNow = 1_000_000;
|
||||
Date.now = () => fakeNow;
|
||||
const parts: PlacedPart[] = [
|
||||
{ ...P('reservoir', [0, 0, 0], { width: 1, depth: 1, height: 1, level: 10 }), id: 'res-runtime' },
|
||||
{ ...P('pump', [1.45, 0, 0], { gph: 400, maxHeadFt: 8, on: 1 }), id: 'pump-runtime' },
|
||||
P('pipe', [2.9, 0.25, 0], { length: 2 }),
|
||||
{ ...P('drain', [4.25, 0, 0], { capacityGph: 1000 }), id: 'drain-runtime' },
|
||||
];
|
||||
const partsMap = Object.fromEntries(parts.map((p) => [p.id, p])) as Record<string, PlacedPart>;
|
||||
const base = simulate(partsMap);
|
||||
const start = applyReservoirRuntime(base, partsMap, true, 0);
|
||||
fakeNow += 3_600_000;
|
||||
const drained = applyReservoirRuntime(base, partsMap, true, 1);
|
||||
Date.now = realNow;
|
||||
const fs = validateSystem(parts, drained);
|
||||
check('reservoir-runtime', 'finite source starts with runtime < 30 min', (start.reservoirRuntime['res-runtime']?.minutesRemaining ?? 999) < 30, fs);
|
||||
check('reservoir-runtime', 'source drains fully after long run', drained.reservoirRuntime['res-runtime']?.empty === true, fs);
|
||||
check('reservoir-runtime', 'dry source stops pump flow', drained.pumps[0]?.gph === 0 && drained.totalGph === 0, fs);
|
||||
check('reservoir-runtime', 'design check reports empty reservoir', has(fs, 'res-empty:', 'error'), fs);
|
||||
}
|
||||
|
||||
// ---------- j. closed valve ----------
|
||||
{
|
||||
const valve = P('valve', [2.25, 0.25, 0], { open: 0 }); // a @ [1.9,.25,0], b @ [2.6,.25,0]
|
||||
const parts = [
|
||||
...source(),
|
||||
valve,
|
||||
P('pipe', [3.6, 0.25, 0], { length: 2 }),
|
||||
P('emitter', [4.4, 0.25, 0]),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('closed-valve', 'closed valve on a powered circuit -> info', has(fs, `closed-valve:${valve.id}`, 'info'), fs);
|
||||
}
|
||||
|
||||
// ---------- k. part overlap ----------
|
||||
{
|
||||
// Two pipes directly on top of each other
|
||||
const parts = [
|
||||
P('pipe', [0, 0, 0], { length: 2 }),
|
||||
P('pipe', [0.05, 0, 0.05], { length: 2 }),
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('part-overlap', 'overlapping pipes -> warning', has(fs, 'part-overlap:', 'warning'), fs);
|
||||
}
|
||||
{
|
||||
// Two collinear pipes overlapping
|
||||
const parts = [
|
||||
P('pipe', [0, 0, 0], { length: 2 }),
|
||||
P('pipe', [1.5, 0, 0], { length: 2 }), // perfectly end-to-end is 2.0. At 1.5 they overlap by 0.5 ft.
|
||||
];
|
||||
const fs = validateSystem(parts);
|
||||
check('part-overlap', 'collinear overlapping pipes -> warning', has(fs, 'part-overlap:', 'warning'), fs);
|
||||
}
|
||||
|
||||
// ---------- healthy scenes ----------
|
||||
{
|
||||
const parts = [
|
||||
...source({ gph: 300, maxHeadFt: 8 }),
|
||||
P('pipe', [2.9, 0.25, 0], { length: 2 }),
|
||||
P('emitter', [3.7, 0.25, 0]),
|
||||
];
|
||||
const fs = validateSystem(parts).filter((f) => !f.id.startsWith('res-temp-high:'));
|
||||
check('healthy', 'reservoir -> pump -> pipe -> emitter -> zero findings', fs.length === 0, fs);
|
||||
}
|
||||
{
|
||||
const fs = validateSystem([]);
|
||||
check('healthy', 'empty scene -> zero findings', fs.length === 0, fs);
|
||||
}
|
||||
|
||||
// ---------- summary ----------
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
// The project has no @types/node, so reach process via globalThis.
|
||||
const proc = (globalThis as { process?: { exitCode?: number } }).process;
|
||||
if (failed > 0 && proc) proc.exitCode = 1;
|
||||
55
src/validation/types.ts
Normal file
55
src/validation/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { PlacedPart, SimResult } from '../types';
|
||||
import type { RoomMetrics } from '../environment/roomMetrics';
|
||||
import type { SystemGraph } from './graph';
|
||||
|
||||
/**
|
||||
* Validation (Design Check) types.
|
||||
*
|
||||
* Severity philosophy:
|
||||
* - `error` — the system won't work or will flood (no flow, overflow).
|
||||
* - `warning` — it will work, but badly (stagnation, turbulence, near-limit).
|
||||
* - `info` — good-practice tips (supports, check valves, tidiness).
|
||||
*/
|
||||
|
||||
export type FindingSeverity = 'error' | 'warning' | 'info';
|
||||
|
||||
/** One issue discovered by the Design Check. */
|
||||
export interface Finding {
|
||||
/** Stable id, unique within one validation run (e.g. "dead-leg:ab12cd34"). */
|
||||
id: string;
|
||||
severity: FindingSeverity;
|
||||
/** Short headline shown on the finding card. */
|
||||
title: string;
|
||||
/** One-to-two sentence explanation of what is wrong and why it matters. */
|
||||
detail: string;
|
||||
/** Ids of the offending part(s) — used for "Show part" navigation. */
|
||||
partIds: string[];
|
||||
/** Suggested fix, when one can be recommended. */
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
/** Everything a rule may inspect. Built once per validation run. */
|
||||
export interface RuleContext {
|
||||
/** All placed parts, keyed by id. */
|
||||
partsMap: Record<string, PlacedPart>;
|
||||
/** All placed parts as an array (same objects as `partsMap`). */
|
||||
parts: PlacedPart[];
|
||||
/**
|
||||
* Simulation result for the current build. May be missing if the simulator
|
||||
* threw — rules must use optional chaining and degrade gracefully.
|
||||
*/
|
||||
sim: SimResult | undefined;
|
||||
/** Connectivity graph built independently of the simulator. */
|
||||
graph: SystemGraph;
|
||||
/** Derived grow-room air and equipment metrics. */
|
||||
roomMetrics: RoomMetrics[];
|
||||
}
|
||||
|
||||
/** A single registered design-check rule. */
|
||||
export interface ValidationRule {
|
||||
/** Rule id — used as the prefix of every finding id it emits. */
|
||||
id: string;
|
||||
/** Human description (what the rule checks). */
|
||||
description: string;
|
||||
run: (ctx: RuleContext) => Finding[];
|
||||
}
|
||||
11
src/vite-env.d.ts
vendored
Normal file
11
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import React from 'react';
|
||||
|
||||
declare module 'react' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'stripe-buy-button': any;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user