From 6f3c30f44da3fa2d4286e93da622c3b63633e812 Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 13 Jun 2026 23:51:43 -0700 Subject: [PATCH] Implement derived grow check rules, auto-fix behaviors, and import hardening --- src/App.tsx | 15 ++ src/store/builderStore.ts | 11 +- src/utils/serializer.ts | 2 +- src/validation/DesignCheckPanel.tsx | 33 +++- src/validation/rules.ts | 224 ++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 985c4d47..21ebe178 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -348,6 +348,21 @@ function BuilderApp() { if (ids.length >= 2) { s.removeParts([ids[1]]); } + } else if (finding.id.startsWith('nutrient-depletion:')) { + const resId = finding.partIds[0]; + if (resId) { + const p = s.parts[resId]; + if (p) { + s.updatePart(resId, { + params: { + ...p.params, + width: Math.min(5, (p.params.width ?? 2) * 1.5), + depth: Math.min(4, (p.params.depth ?? 1.4) * 1.5), + height: Math.min(3, (p.params.height ?? 1) * 1.2), + } + }); + } + } } else if (finding.id.startsWith('orphan:')) { s.removeParts(finding.partIds); } diff --git a/src/store/builderStore.ts b/src/store/builderStore.ts index 7600226b..7462c617 100644 --- a/src/store/builderStore.ts +++ b/src/store/builderStore.ts @@ -1016,8 +1016,15 @@ export const useBuilder = create((set, get) => ({ const s = get(); if (!pasted.length) return []; - // Rigorously validate and sanitize each pasted part - const sanitized = pasted.map((p) => sanitizePart(p)); + const sanitized: PlacedPart[] = []; + for (const p of pasted) { + try { + sanitized.push(sanitizePart(p)); + } catch (err) { + console.warn('Skipping corrupted part during paste:', p, err); + } + } + if (!sanitized.length) return []; s.pushHistory('paste'); const offset: Vec3 = [1, 0, 1]; diff --git a/src/utils/serializer.ts b/src/utils/serializer.ts index 12dc48cf..9e94d664 100644 --- a/src/utils/serializer.ts +++ b/src/utils/serializer.ts @@ -104,7 +104,7 @@ export function parseProjectFile(raw: unknown): ProjectFile { try { validatedParts.push(sanitizePart(p)); } catch (err) { - throw new Error(`Project file validation failed: ${(err as Error).message}`); + console.warn('Skipping corrupted part during import:', p, err); } } diff --git a/src/validation/DesignCheckPanel.tsx b/src/validation/DesignCheckPanel.tsx index d5b6f748..833ca589 100644 --- a/src/validation/DesignCheckPanel.tsx +++ b/src/validation/DesignCheckPanel.tsx @@ -68,10 +68,35 @@ function FindingCard({ 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:'); + const fixablePrefixes = [ + 'dead-leg:', + 'diameter-mismatch:', + 'pump-dead:', + 'pump-head:', + 'unsupported-span:', + 'drain-capacity:', + 'res-temp-high:', + 'res-ph-low:', + 'res-ph-high:', + 'res-ec-low:', + 'res-ec-high:', + 'room-co2-open:', + 'room-co2-missing:', + 'room-co2-starved:', + 'room-sealed-exhaust:', + 'room-vent-low:', + 'room-light-low:', + 'room-light-high:', + 'room-dli-low:', + 'room-dli-high:', + 'room-vpd-low:', + 'room-vpd-high:', + 'closed-valve:', + 'part-overlap:', + 'orphan:', + 'nutrient-depletion:', + ]; + const canApplyFix = fixablePrefixes.some((prefix) => finding.id.startsWith(prefix)); return (
diff --git a/src/validation/rules.ts b/src/validation/rules.ts index 9b2ac10e..32725015 100644 --- a/src/validation/rules.ts +++ b/src/validation/rules.ts @@ -703,6 +703,21 @@ export const ALL_RULES: ValidationRule[] = [ description: 'Parts that are overlapping or morphing into one another.', run: partOverlaps, }, + { + id: 'structural-floor-load', + description: 'Total equipment and water load per square foot of room footprint.', + run: structuralFloorLoad, + }, + { + id: 'nutrient-depletion', + description: 'Reservoir buffer volume capacity relative to plant count.', + run: nutrientDepletion, + }, + { + id: 'manifold-imbalance', + description: 'Uniformity of delivered flow rates across multiple emitters.', + run: manifoldImbalance, + }, ]; function reservoirWaterChemistry(ctx: RuleContext): Finding[] { @@ -883,3 +898,212 @@ function partOverlaps(ctx: RuleContext): Finding[] { } return findings; } + +function structuralFloorLoad(ctx: RuleContext): Finding[] { + const findings: Finding[] = []; + const rooms = new Set(); + for (const p of ctx.parts) { + if (p.roomId) rooms.add(p.roomId); + } + if (rooms.size === 0) rooms.add('room-1'); + + for (const roomId of rooms) { + const roomParts = ctx.parts.filter((p) => (p.roomId || 'room-1') === roomId); + if (roomParts.length === 0) continue; + + let totalWeight = 0; + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + + for (const p of roomParts) { + if (p.type !== 'growTent') { + minX = Math.min(minX, p.position[0]); + maxX = Math.max(maxX, p.position[0]); + minZ = Math.min(minZ, p.position[2]); + maxZ = Math.max(maxZ, p.position[2]); + } + + let dryWeight = 1; + if (p.type === 'reservoir') dryWeight = 15; + else if (p.type === 'tray') dryWeight = 10; + else if (p.type === 'pump') dryWeight = 5; + else if (p.type === 'waterChiller') dryWeight = 35; + else if (p.type === 'growLight') dryWeight = 12; + else if (p.type === 'inlineFan') dryWeight = 8; + else if (p.type === 'circulationFan') dryWeight = 3; + else if (p.type === 'co2Tank') dryWeight = 30; + else if (p.type === 'tower') dryWeight = 12; + + let waterWeight = 0; + if (p.type === 'reservoir') { + const w = p.params.width ?? 2; + const d = p.params.depth ?? 1.4; + const h = p.params.height ?? 1; + const gallons = w * d * h * 7.48; + waterWeight = gallons * 8.34; + } else if (p.type === 'tray') { + const l = p.params.length ?? 3; + const w = p.params.width ?? 1.2; + const gallons = l * w * 0.1 * 7.48; + waterWeight = gallons * 8.34; + } else if (p.type === 'pipe') { + const d = p.params.diameter ?? 1.0; + const len = p.params.length ?? 2; + const r = 0.05 + d * 0.055; + const volFt3 = Math.PI * r * r * len; + waterWeight = volFt3 * 7.48 * 8.34; + } + + totalWeight += dryWeight + waterWeight; + } + + if (totalWeight <= 20) continue; + + const dx = maxX - minX; + const dz = maxZ - minZ; + const footprintArea = Math.max(4, (dx > 0 ? dx : 2) * (dz > 0 ? dz : 2)); + const floorPressure = totalWeight / footprintArea; + + const tent = roomParts.find((p) => p.type === 'growTent'); + const roomName = tent ? (tent.label || 'Grow Tent') : `Room (${roomId === 'room-1' ? 'Room 1' : roomId})`; + + if (floorPressure > 60) { + findings.push({ + id: `floor-weight:${roomId}`, + severity: 'error', + title: 'Critical floor weight loading', + detail: `${roomName} total weight is ${totalWeight.toFixed(0)} lbs over ${footprintArea.toFixed(1)} sq ft, resulting in a load of ${floorPressure.toFixed(1)} lbs/sq ft. This exceeds standard residential ceiling/loft safety limits (40 lbs/sq ft).`, + partIds: roomParts.filter(p => p.type === 'reservoir' || p.type === 'tray').map(p => p.id), + fix: 'Reduce reservoir sizes, split the load across multiple rooms, or move the layout to a ground/concrete slab floor.', + }); + } else if (floorPressure > 40) { + findings.push({ + id: `floor-weight:${roomId}`, + severity: 'warning', + title: 'High floor weight loading', + detail: `${roomName} total weight is ${totalWeight.toFixed(0)} lbs over ${footprintArea.toFixed(1)} sq ft, resulting in a load of ${floorPressure.toFixed(1)} lbs/sq ft. This is near the standard 40 lbs/sq ft residential loft load rating limit.`, + partIds: roomParts.filter(p => p.type === 'reservoir' || p.type === 'tray').map(p => p.id), + fix: 'Ensure your floor has structural joist support directly under the heavy reservoirs, or reduce reservoir water levels.', + }); + } else { + findings.push({ + id: `floor-weight:${roomId}`, + severity: 'info', + title: 'Floor weight loading normal', + detail: `${roomName} total weight is ${totalWeight.toFixed(0)} lbs over ${footprintArea.toFixed(1)} sq ft, resulting in a safe load of ${floorPressure.toFixed(1)} lbs/sq ft.`, + partIds: roomParts.filter(p => p.type === 'reservoir' || p.type === 'tray').map(p => p.id), + }); + } + } + + return findings; +} + +function nutrientDepletion(ctx: RuleContext): Finding[] { + const findings: Finding[] = []; + const reservoirs = ctx.parts.filter((p) => p.type === 'reservoir'); + + for (const res of reservoirs) { + const w = res.params.width ?? 2; + const d = res.params.depth ?? 1.4; + const h = res.params.height ?? 1; + const gallons = w * d * h * 7.48; + + const netPots = ctx.parts.filter( + (p) => (p.roomId || 'room-1') === (res.roomId || 'room-1') && (p.type === 'netPot' || p.type === 'tower') + ); + const plantCount = netPots.length; + if (plantCount === 0) continue; + + const gallonsPerPlant = gallons / plantCount; + const label = res.label || `Reservoir #${res.id}`; + + if (gallonsPerPlant < 0.4) { + findings.push({ + id: `nutrient-depletion:${res.id}`, + severity: 'error', + title: 'Critical reservoir size (EC/pH drift)', + detail: `${label} has only ${gallonsPerPlant.toFixed(2)} gal per plant site (Total: ${gallons.toFixed(0)} gal for ${plantCount} sites). Roots will suffer from severe nutrient depletion, water stress, and rapid pH drift.`, + partIds: [res.id], + fix: 'Increase reservoir size or reduce the number of plant sites in this room to achieve at least 1.0 gal/plant.', + }); + } else if (gallonsPerPlant < 0.8) { + findings.push({ + id: `nutrient-depletion:${res.id}`, + severity: 'warning', + title: 'High pH/EC drift risk', + detail: `${label} has ${gallonsPerPlant.toFixed(2)} gal per plant site. Hydroponic setups require at least 1.0 gal/plant to maintain chemical buffer stability. Expected daily pH/EC drift is high.`, + partIds: [res.id], + fix: 'Increase reservoir capacity or add a secondary top-off tank.', + }); + } else if (gallonsPerPlant < 1.5) { + findings.push({ + id: `nutrient-depletion:${res.id}`, + severity: 'info', + title: 'pH/EC drift warning', + detail: `${label} has ${gallonsPerPlant.toFixed(2)} gal per plant site. Monitor pH and nutrient strength (EC) daily as buffer levels are moderate.`, + partIds: [res.id], + fix: 'Recommended target is 2.0 gallons per plant site for hands-off stability.', + }); + } + } + + return findings; +} + +function manifoldImbalance(ctx: RuleContext): Finding[] { + const findings: Finding[] = []; + if (!ctx.sim) return findings; + + const rooms = new Set(); + for (const p of ctx.parts) { + if (p.roomId) rooms.add(p.roomId); + } + if (rooms.size === 0) rooms.add('room-1'); + + for (const roomId of rooms) { + const roomEmitters = ctx.parts.filter( + (p) => (p.roomId || 'room-1') === roomId && p.type === 'emitter' + ); + if (roomEmitters.length < 2) continue; + + const flows = roomEmitters + .map((e) => ctx.sim!.flows[e.id] ?? 0) + .filter((q) => q > 0); + + if (flows.length < 2) continue; + + const maxFlow = Math.max(...flows); + const minFlow = Math.min(...flows); + + const sum = flows.reduce((a, b) => a + b, 0); + const avg = sum / flows.length; + if (avg <= 0.01) continue; + + const variancePct = ((maxFlow - minFlow) / avg) * 100; + + if (variancePct > 50) { + findings.push({ + id: `manifold-imbalance:${roomId}`, + severity: 'error', + title: 'Critical flow rate imbalance', + detail: `Manifold has a critical flow variance of ${variancePct.toFixed(0)}% between drip emitters (max: ${maxFlow.toFixed(1)} GPH, min: ${minFlow.toFixed(1)} GPH). Plants will experience severe uneven watering.`, + partIds: roomEmitters.map((e) => e.id), + fix: 'Use pressure-compensating emitters, balanced loops, or increase main pipe size to distribute pressure evenly.', + }); + } else if (variancePct > 25) { + findings.push({ + id: `manifold-imbalance:${roomId}`, + severity: 'warning', + title: 'Uneven emitter flow distribution', + detail: `Manifold has a flow variance of ${variancePct.toFixed(0)}% between drip emitters (max: ${maxFlow.toFixed(1)} GPH, min: ${minFlow.toFixed(1)} GPH). Some plants will receive more water than others.`, + partIds: roomEmitters.map((e) => e.id), + fix: 'Ensure the main manifold is looped or adjust pipe lengths to equalize pressure drop.', + }); + } + } + + return findings; +}