import React, { useMemo, useState } from "react";
// GenieClip RST Load Calculator (pure React / JS)
// - Computes recommended furring-channel spacing (OC) and clip spacing (OC)
// - Treats clouds either as distributed average psf or as dedicated clips (4/each)
// - Clip capacity fixed at 36 lb per GenieClip RST
// - No TypeScript; ASCII-only strings to avoid parser quirks
// -----------------------------
// Pure calculation helpers
// -----------------------------
function calcBaseAssemblyPsf(opts) {
const osb = opts.includeOSB ? opts.osbPsf : 0;
const gyp = opts.drywallLayers * opts.drywallPsf;
return osb + gyp + opts.insulPsf; // excludes misc; that's handled separately as another distributed psf
}
function calcCloudAvgPsf(mountMode, areaFt2, totalCloudWeightLb) {
if (mountMode === "distributed" && areaFt2 > 0) return totalCloudWeightLb / areaFt2;
return 0;
}
function calcCombos(params) {
const { gridPsf, allowedChannelSpacings, allowedClipSpacings, constrainToStructure, structureSpacing, clipCap } = params;
const channels = Array.from(new Set(allowedChannelSpacings)).sort((a, b) => a - b);
const clips = constrainToStructure ? [structureSpacing] : Array.from(new Set(allowedClipSpacings)).sort((a, b) => a - b);
const out = [];
for (let i = 0; i < channels.length; i++) {
const ch = channels[i];
for (let j = 0; j < clips.length; j++) {
const cl = clips[j];
const tribAreaFt2 = (ch * cl) / 144.0; // in^2 -> ft^2
const loadPerClip = tribAreaFt2 * gridPsf;
const pass = isFinite(loadPerClip) && loadPerClip <= clipCap;
const safety = isFinite(loadPerClip) && loadPerClip > 0 ? (clipCap / loadPerClip) : Infinity;
out.push({ channelOC: ch, clipOC: cl, tribAreaFt2, loadPerClip, pass, safety });
}
}
// sort by tributary product (wider first)
out.sort((a, b) => (b.channelOC * b.clipOC) - (a.channelOC * a.clipOC));
return out;
}
function firstPassing(combos) {
for (let k = 0; k < combos.length; k++) if (combos[k].pass) return combos[k];
return null;
}
function round2(x) { return Math.round(x * 100) / 100; }
// -----------------------------
// Component
// -----------------------------
const App = () => {
// Assembly
const [area, setArea] = useState(400); // ft^2
const [includeOSB, setIncludeOSB] = useState(true);
const [osbPsf, setOsbPsf] = useState(2.7);
const [drywallLayers, setDrywallLayers] = useState(2); // 0..3
const [drywallPsf, setDrywallPsf] = useState(2.5);
const [insulPsf, setInsulPsf] = useState(0.2);
const [miscPsf, setMiscPsf] = useState(0); // NEW: miscellaneous distributed psf (lights, speakers, etc.)
// Clouds
const [mountMode, setMountMode] = useState("distributed"); // distributed | dedicated
const [c4x1, setC4x1] = useState(0); // 15 lb
const [c4x2, setC4x2] = useState(0); // 30 lb
const [c4x3, setC4x3] = useState(0); // 45 lb
const [c4x4, setC4x4] = useState(0); // 60 lb
// Spacing constraints
const [allowedChannelSpacings, setAllowedChannelSpacings] = useState([12, 16, 24]);
const [allowedClipSpacings, setAllowedClipSpacings] = useState([24, 32, 40, 48]);
const [constrainToStructure, setConstrainToStructure] = useState(false);
const [structureSpacing, setStructureSpacing] = useState(48);
const CLIP_CAP = 36;
const totalCloudWeight = (c4x1 * 15) + (c4x2 * 30) + (c4x3 * 45) + (c4x4 * 60);
const baseAssemblyPsf = useMemo(() => calcBaseAssemblyPsf({ includeOSB, osbPsf, drywallLayers, drywallPsf, insulPsf }), [includeOSB, osbPsf, drywallLayers, drywallPsf, insulPsf]);
const cloudAvgPsf = useMemo(() => calcCloudAvgPsf(mountMode, area, totalCloudWeight), [mountMode, area, totalCloudWeight]);
const gridPsf = useMemo(() => baseAssemblyPsf + cloudAvgPsf + miscPsf, [baseAssemblyPsf, cloudAvgPsf, miscPsf]);
const maxAreaPerClip = useMemo(() => (gridPsf > 0 ? (CLIP_CAP / gridPsf) : Infinity), [gridPsf]);
const maxSpacingProduct = useMemo(() => maxAreaPerClip * 144.0, [maxAreaPerClip]);
const combos = useMemo(() => calcCombos({
gridPsf,
allowedChannelSpacings,
allowedClipSpacings,
constrainToStructure,
structureSpacing,
clipCap: CLIP_CAP
}), [gridPsf, allowedChannelSpacings, allowedClipSpacings, constrainToStructure, structureSpacing]);
const rec = useMemo(() => firstPassing(combos), [combos]);
const estimatedClipsOnGrid = useMemo(() => {
if (!rec || area <= 0) return 0;
const tribFt2 = rec.tribAreaFt2;
return Math.ceil(area / Math.max(tribFt2, 1e-6));
}, [rec, area]);
const dedicatedCloudClips = useMemo(() => {
if (mountMode !== "dedicated") return 0;
const totalClouds = c4x1 + c4x2 + c4x3 + c4x4;
return totalClouds * 4;
}, [mountMode, c4x1, c4x2, c4x3, c4x4]);
const totalClips = useMemo(() => estimatedClipsOnGrid + dedicatedCloudClips, [estimatedClipsOnGrid, dedicatedCloudClips]);
// Dedicated check rows (per-clip loads)
const dedicatedRows = useMemo(() => {
if (mountMode !== "dedicated") return [];
const items = [
{ name: "4x1 (15 lb)", load: 15 / 4 },
{ name: "4x2 (30 lb)", load: 30 / 4 },
{ name: "4x3 (45 lb)", load: 45 / 4 },
{ name: "4x4 (60 lb)", load: 60 / 4 }
];
return items.map(x => ({ name: x.name, load: x.load, pass: x.load <= CLIP_CAP, safety: x.load > 0 ? (CLIP_CAP / x.load) : Infinity }));
}, [mountMode]);
// UI helpers
const Pill = ({ children, tone }) => {
const styles = {
success: "bg-green-100 text-green-700",
danger: "bg-rose-100 text-rose-700",
neutral: "bg-gray-100 text-gray-800"
};
const cls = styles[tone || "neutral"];
return {children};
};
const NumberField = ({ label, value, setValue, min, step, suffix, title }) => (
);
const Toggle = ({ label, checked, onChange }) => (
);
// -----------------------------
// Render
// -----------------------------
return (
{includeOSB ? (
) : null}
{mountMode === "dedicated" && dedicatedRows.length > 0 ? (
) : null}
) : (
{/* Self-tests panel to validate core math (in lieu of a test runner) */}
);
};
// -----------------------------
// Tiny test harness (renders results)
// -----------------------------
function TestPanel() {
// Share calc logic here
const run = (cfg) => {
const base = calcBaseAssemblyPsf(cfg);
const cloud = calcCloudAvgPsf(cfg.mountMode, cfg.area, cfg.totalCloudWeight || 0);
const grid = base + cloud + (cfg.miscPsf || 0);
const combos = calcCombos({
gridPsf: grid,
allowedChannelSpacings: cfg.allowedChannelSpacings,
allowedClipSpacings: cfg.allowedClipSpacings,
constrainToStructure: cfg.constrainToStructure || false,
structureSpacing: cfg.structureSpacing || 48,
clipCap: 36
});
return { base, cloud, grid, combos, rec: firstPassing(combos) };
};
// Test cases
const tests = [
{
name: "No clouds, OSB + 2x gyp; only (16,48) and (24,48) allowed -> no pass",
cfg: { includeOSB: true, osbPsf: 2.7, drywallLayers: 2, drywallPsf: 2.5, insulPsf: 0.2, mountMode: "distributed", area: 400, totalCloudWeight: 0, allowedChannelSpacings: [16,24], allowedClipSpacings: [48] },
expect: (r) => r.rec === null
},
{
name: "Same assembly; allow (16,24) -> recommend 16/24 (pass)",
cfg: { includeOSB: true, osbPsf: 2.7, drywallLayers: 2, drywallPsf: 2.5, insulPsf: 0.2, mountMode: "distributed", area: 400, totalCloudWeight: 0, allowedChannelSpacings: [16], allowedClipSpacings: [24] },
expect: (r) => r.rec && r.rec.channelOC === 16 && r.rec.clipOC === 24
},
{
name: "Add distributed clouds (4x 60 lb), wide menu -> 24/24 should pass",
cfg: { includeOSB: true, osbPsf: 2.7, drywallLayers: 2, drywallPsf: 2.5, insulPsf: 0.2, mountMode: "distributed", area: 400, totalCloudWeight: 240, allowedChannelSpacings: [12,16,24], allowedClipSpacings: [24,32,40,48] },
expect: (r) => r.rec && r.rec.channelOC === 24 && r.rec.clipOC === 24
},
{
name: "Same as above but +1.0 psf misc makes 24/24 fail; expect 16/32",
cfg: { includeOSB: true, osbPsf: 2.7, drywallLayers: 2, drywallPsf: 2.5, insulPsf: 0.2, miscPsf: 1.0, mountMode: "distributed", area: 400, totalCloudWeight: 240, allowedChannelSpacings: [12,16,24], allowedClipSpacings: [24,32,40,48] },
expect: (r) => r.rec && r.rec.channelOC === 16 && r.rec.clipOC === 32
}
];
const results = tests.map((t) => {
const r = run(t.cfg);
const ok = !!t.expect(r);
return { name: t.name, ok, details: r.rec ? ("rec="+r.rec.channelOC+"/"+r.rec.clipOC+" load="+round2(r.rec.loadPerClip)) : "rec=null" };
});
return (
);
}
export default App;
GenieClip RST Load Calculator
Compute recommended channel and clip spacing from uniform loads (36 lb/clip limit).
Assembly
Base assembly load{round2(baseAssemblyPsf)} psf
Misc distributed load{round2(miscPsf)} psf
Clouds
Total cloud weight
{round2(totalCloudWeight)} lb
Total grid load
{round2(gridPsf)} psf
Dedicated cloud clip check (per clip)
Type | Load/clip | Status |
---|---|---|
{r.name} | {round2(r.load)} lb | {r.pass ? |
Assumes 4 clips per cloud.
Spacing options
Calculator picks the widest spacing that still passes.
Channel spacing (in)
{[12,16,24].map((v) => (
))}
Clip spacing (in)
{[24,32,40,48].map((v) => (
))}
Max spacing product
{isFinite(maxSpacingProduct) ? Math.round(maxSpacingProduct) : "-"} in^2
Max tributary area/clip
{isFinite(maxAreaPerClip) ? round2(maxAreaPerClip) : "-"} ft^2
Recommendation
{rec ? (Recommended spacing
Channels: {rec.channelOC}" OC ยท Clips: {rec.clipOC}" OC
Load/clip: {round2(rec.loadPerClip)} lb
Safety factor: x{round2(rec.safety)}
Estimated clips on grid{estimatedClipsOnGrid}
{mountMode === "dedicated" ? (+ Cloud clips{dedicatedCloudClips}
) : null}
Total estimated clips{totalClips}
Grid load{round2(gridPsf)} psf
Clip capacity{CLIP_CAP} lb
No passing spacing combination with current constraints.
)}
All evaluated combos
Channels (OC) | Clips (OC) | Trib. area | Load/clip | Status |
---|---|---|---|---|
{c.channelOC}" | {c.clipOC}" | {round2(c.tribAreaFt2)} ft^2 | {isFinite(c.loadPerClip) ? round2(c.loadPerClip) : "-"} lb | {c.pass ? |
Sorted from widest to densest; the first PASS is recommended.
Built-in tests
-
{results.map((x, i) => (
- {x.ok ? "PASS" : "FAIL"} - {x.name} ({x.details}) ))}