Initial project import

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

89
src/utils/cadExporter.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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));
}