Initial project import
This commit is contained in:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user