diff --git a/app.ts b/app.ts index 5f317e7..58a5007 100644 --- a/app.ts +++ b/app.ts @@ -11,7 +11,9 @@ interface BracketParams { ribbingThickness: number; holeDiameter: number; earWidth: number; - hasBottom: boolean; + bottomType: 'none' | 'solid' | 'lip'; + lipSize?: number; + color: string; } // Initialize the preview @@ -19,90 +21,225 @@ const canvas = document.getElementById("preview") as HTMLCanvasElement; const updateBracket = setupPreview(canvas); const controls = document.querySelector("#controls"); +if (!controls) throw new Error("Could not find controls form element"); // Get all range inputs -const inputs = Array.from(controls?.querySelectorAll("input") ?? []).filter(input => !input.classList.contains('value-display')); -// todo - I have a tip somewhere on an easy way to split this into two arrays -const displayInputs = Array.from(controls?.querySelectorAll("input") ?? []).filter(input => input.classList.contains('value-display')); +const inputs = Array.from(controls.querySelectorAll("input")); +const rangeInputs = inputs.filter(input => input.type === 'range'); +const valueInputs = inputs.filter(input => input.classList.contains('value-display')); +const bottomTypeSelect = document.getElementById('bottomType') as HTMLSelectElement; +const lipSizeContainer = document.getElementById('lipSizeContainer') as HTMLDivElement; +const lipSizeInput = document.getElementById('lipSize') as HTMLInputElement; +const widthInput = document.getElementById('width') as HTMLInputElement; +const heightInput = document.getElementById('height') as HTMLInputElement; + +// Connect range inputs with their corresponding value display inputs +rangeInputs.forEach(rangeInput => { + const valueInput = document.getElementById(`${rangeInput.id}Value`) as HTMLInputElement; + if (valueInput) { + // Update value display when range changes + rangeInput.addEventListener('input', () => { + valueInput.value = rangeInput.value; + }); + + // Update range when value display changes + valueInput.addEventListener('input', () => { + rangeInput.value = valueInput.value; + // Trigger change event on the range input to update the model + rangeInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + + // Also handle change event (when user finishes typing) + valueInput.addEventListener('change', () => { + rangeInput.value = valueInput.value; + // Trigger change event on the range input to update the model + rangeInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + } +}); + +// Update the lip size max value based on current dimensions +function updateLipSizeMax() { + const width = parseFloat(widthInput.value); + const height = parseFloat(heightInput.value); + + // Allow the lip to cover almost the entire side, leaving a 2mm minimum opening + const minOpeningSize = 2; // minimum opening size in mm + const maxWidth = Math.max(width - minOpeningSize, 1); + const maxHeight = Math.max(height - minOpeningSize, 1); + + // Set max to half the max width/height to allow for the lip on both sides + const maxLipSize = Math.min(maxWidth / 2, maxHeight / 2); + + lipSizeInput.max = maxLipSize.toString(); + + // Ensure current value doesn't exceed new max + if (parseFloat(lipSizeInput.value) > maxLipSize) { + lipSizeInput.value = maxLipSize.toString(); + } + + // Update the display + const lipSizeDisplay = document.getElementById('lipSizeValue') as HTMLInputElement; + if (lipSizeDisplay) { + lipSizeDisplay.value = lipSizeInput.value; + } +} +// Show/hide lip size control based on bottom type +function updateLipSizeVisibility() { + if (bottomTypeSelect.value === 'lip') { + lipSizeContainer.style.display = 'flex'; + updateLipSizeMax(); + } else { + lipSizeContainer.style.display = 'none'; + } +} function parseFormData(data: FormData) { const params: Record = {}; for(const [key, value] of data.entries()) { - // First see if it's a checkbox - if(value === "on") { - params[key] = true; + // Handle bottomType as a string + if(key === 'bottomType') { + params[key] = value.toString(); } else { - const maybeNumber = parseFloat(value); - params[key] = isNaN(maybeNumber) ? value : maybeNumber; + // First see if it's a checkbox + if(value === "on") { + params[key] = true; + } else { + const maybeNumber = parseFloat(value.toString()); + params[key] = isNaN(maybeNumber) ? value.toString() : maybeNumber; + } } } return params as BracketParams; } - function displayValues(params: BracketParams) { - for(const input of inputs) { - console.log(input); - const label = input.nextElementSibling as HTMLDivElement; - const unit = input.getAttribute("data-unit") ?? 'mm'; - if(label && label.classList.contains('value-display')) { - label.value = `${input.value}`; + // Update all input fields based on params + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = params[key as keyof BracketParams]; + if (value !== undefined) { + const input = document.getElementById(key) as HTMLInputElement | null; + if (input) { + input.value = value.toString(); + + // Also update the corresponding value display + const valueDisplay = document.getElementById(`${key}Value`) as HTMLInputElement | null; + if (valueDisplay) { + valueDisplay.value = value.toString(); + } + } + } } } + // Also pop the color on the root so we can use in css - document.documentElement.style.setProperty('--color', params.color); -} - -function handleInput(e: Event) { - // If someone types into a valueDisplay, update the input - if(e.target.classList.contains('value-display')) { - const input = e.target.previousElementSibling as HTMLInputElement; - input.value = e.target.value; + if (params.color) { + document.documentElement.style.setProperty('--color', params.color); } - const data = new FormData(controls); - const params = parseFormData(data); - displayValues(params); - updateBracket(params); } function updateUrl() { + // Ensure controls exists + if (!controls) return; + const data = new FormData(controls); - const url = new URLSearchParams(data); - history.pushState({}, '', `?${url.toString()}`); + const urlParams = new URLSearchParams(); + + // Convert FormData to URLSearchParams + for (const [key, value] of data.entries()) { + urlParams.append(key, value.toString()); + } + + history.pushState({}, '', `?${urlParams.toString()}`); } - -controls.addEventListener("input", handleInput); -controls.addEventListener("change", updateUrl); - // On page load, check if there is a url param and parse it function restoreState() { - const url = new URLSearchParams(window.location.search); - const params = { - defaultParams, - ...parseFormData(url) - } - // Merge in any defaults + const urlParams = Object.fromEntries(url.entries()); + + // Safe access helper function + const safeParseFloat = (value: string | undefined, defaultValue: number): number => { + if (value === undefined) return defaultValue; + const parsed = parseFloat(value); + return isNaN(parsed) ? defaultValue : parsed; + }; + + const safeParseInt = (value: string | undefined, defaultValue: number): number => { + if (value === undefined) return defaultValue; + const parsed = parseInt(value); + return isNaN(parsed) ? defaultValue : parsed; + }; + + const safeString = (value: string | undefined, defaultValue: string): string => { + return value !== undefined ? value : defaultValue; + }; + + const params: BracketParams = { + ...defaultParams, + width: safeParseFloat(urlParams.width, defaultParams.width), + depth: safeParseFloat(urlParams.depth, defaultParams.depth), + height: safeParseFloat(urlParams.height, defaultParams.height), + bracketThickness: safeParseFloat(urlParams.bracketThickness, defaultParams.bracketThickness), + ribbingCount: safeParseInt(urlParams.ribbingCount, defaultParams.ribbingCount), + ribbingThickness: safeParseFloat(urlParams.ribbingThickness, defaultParams.ribbingThickness), + holeDiameter: safeParseFloat(urlParams.holeDiameter, defaultParams.holeDiameter), + earWidth: safeParseFloat(urlParams.earWidth, defaultParams.earWidth), + bottomType: (urlParams.bottomType as 'none' | 'solid' | 'lip' | undefined) || defaultParams.bottomType, + color: safeString(urlParams.color, defaultParams.color || '#44ff00'), + lipSize: safeParseFloat(urlParams.lipSize, defaultParams.lipSize || 10) + }; + // Restore any params from the URL for(const [key, value] of Object.entries(params)) { - const input = document.getElementById(key) as HTMLInputElement; + const input = document.getElementById(key) as HTMLInputElement | null; if(input) { input.value = value.toString(); } } + + // Update the model with initial parameters + updateBracket(params); + displayValues(params); + // trigger an input event to update the values const event = new Event('input', { bubbles: true }); - controls.dispatchEvent(event); + if (controls) { + controls.dispatchEvent(event); + } } - +// On page load, restore state restoreState(); +updateLipSizeVisibility(); + +// Add event listeners +function handleFormUpdate() { + if (!controls) return; + + const params = parseFormData(new FormData(controls)); + updateBracket(params); + displayValues(params); + updateUrl(); + + if (params.bottomType === 'lip') { + updateLipSizeMax(); + } +} + +controls.addEventListener("input", handleFormUpdate); +controls.addEventListener("change", handleFormUpdate); +// Add additional listeners for width and height changes +widthInput.addEventListener("input", updateLipSizeMax); +heightInput.addEventListener("input", updateLipSizeMax); +bottomTypeSelect.addEventListener("change", updateLipSizeVisibility); const exportButton = document.getElementById("export-button") as HTMLButtonElement; -exportButton.addEventListener("click", async () => { +if (!exportButton) throw new Error("Could not find export button"); +exportButton.addEventListener("click", async () => { const params = parseFormData(new FormData(controls)); const model = createBracket(params); const dimensions = `${params.width}x${params.depth}x${params.height}`; diff --git a/index.html b/index.html index 2c8ae5a..32048ad 100644 --- a/index.html +++ b/index.html @@ -194,17 +194,42 @@

Bracket.Engineer

/> + +
- - + +
- +
+ + diff --git a/preview.ts b/preview.ts index f989b63..3dd4a94 100644 --- a/preview.ts +++ b/preview.ts @@ -8,8 +8,13 @@ interface BracketParams { depth: number; height: number; holeDiameter: number; - holeOffset: number; earWidth: number; + bracketThickness: number; + ribbingThickness: number; + ribbingCount: number; + bottomType: 'none' | 'solid' | 'lip'; + lipSize?: number; + color?: string; } @@ -105,7 +110,10 @@ export function setupPreview(canvas: HTMLCanvasElement, onParamsChange?: (params bracketMesh.geometry.dispose(); } - material.color.set(params.color); + // Check if color is defined before setting it + if (params.color) { + material.color.set(params.color); + } // Create new bracket const bracket = createBracket(params); diff --git a/psu-bracket.ts b/psu-bracket.ts index 10eb1a2..d10067d 100644 --- a/psu-bracket.ts +++ b/psu-bracket.ts @@ -14,7 +14,9 @@ type BracketParams = { bracketThickness: number; ribbingThickness: number; ribbingCount: number; - hasBottom: boolean; + bottomType: 'none' | 'solid' | 'lip'; + lipSize?: number; // Optional parameter for lip size + color?: string; } // Function to create the bracket with given parameters @@ -22,31 +24,94 @@ export function createBracket(params: BracketParams) { // Create the main bracket body const BRACKET_THICKNESS = params.bracketThickness; const HEIGHT_WITH_THICKNESS = params.height + BRACKET_THICKNESS; - const WIDTH_WITH_THICKNESS = params.width + BRACKET_THICKNESS * 2; const HOLE_DIAMETER = Math.min(params.holeDiameter, (params.earWidth / 2) - 1, (params.depth / 2) - 1); - const COMMAND_STRIP = { - LENGTH: 46, - WIDTH: 15.8, - THICKNESS: 1.6, - } - const mainBody = Manifold.cube( [params.width + BRACKET_THICKNESS * 2, params.height + BRACKET_THICKNESS * 2, params.depth] ); - const cutOut = Manifold.cube([params.width, params.height + BRACKET_THICKNESS, params.depth]).translate([0, BRACKET_THICKNESS, params.hasBottom ? -BRACKET_THICKNESS : 0]).translate([BRACKET_THICKNESS, 0, 0]) + // Create the cutout based on bottom type + let cutOut; + + if (params.bottomType === 'none') { + // For none, cut out the entire inside, including the bottom + cutOut = Manifold.cube([params.width, params.height + BRACKET_THICKNESS, params.depth]) + .translate([BRACKET_THICKNESS, BRACKET_THICKNESS, 0]); + } else if (params.bottomType === 'solid') { + // For solid, leave the bottom intact + cutOut = Manifold.cube([params.width, params.height + BRACKET_THICKNESS, params.depth - BRACKET_THICKNESS]) + .translate([BRACKET_THICKNESS, BRACKET_THICKNESS, BRACKET_THICKNESS]); + } else { // bottomType === 'lip' or any other case + // For lip, we'll do a more complex set of operations: + // 1. First create the main cutout like for "none" - cutting everything out + cutOut = Manifold.cube([params.width, params.height + BRACKET_THICKNESS, params.depth]) + .translate([BRACKET_THICKNESS, BRACKET_THICKNESS, 0]); + } - const shell = Manifold.difference(mainBody, cutOut); + // Create the basic shell by removing the cutout + let shell = Manifold.difference(mainBody, cutOut); + + // Now add the lip if needed + if (params.bottomType === 'lip') { + // Get lip size parameter or use default + const lipSize = params.lipSize || 10; + + // Calculate the maximum possible lip size + // Allow covering almost the entire dimension (leave at least 2mm opening) + const minOpeningSize = 2; // minimum opening size in mm + const maxWidth = Math.max(params.width - minOpeningSize, 1); + const maxHeight = Math.max(params.height - minOpeningSize, 1); + + // Constrain the lip size to avoid exceeding bracket dimensions + const constrainedLipSize = Math.min(lipSize, maxWidth / 2, maxHeight / 2); + + // Create the U-shaped lip as three separate pieces + + // 1. Bottom lip (spans the entire width) + const bottomLip = Manifold.cube([ + params.width, // Full width of bracket + constrainedLipSize, // Height is the lip size + BRACKET_THICKNESS // Same thickness as bracket + ]).translate([ + BRACKET_THICKNESS, // Align with bracket wall + BRACKET_THICKNESS, // Align with bracket floor + 0 // At the bottom + ]); + + // 2. Left side lip - extend to the full height from bottom lip to top of bracket + const leftLip = Manifold.cube([ + constrainedLipSize, // Width is the lip size + params.height - constrainedLipSize + BRACKET_THICKNESS, // Full height from bottom lip to top edge (including top thickness) + BRACKET_THICKNESS // Same thickness as bracket + ]).translate([ + BRACKET_THICKNESS, // Align with bracket wall + BRACKET_THICKNESS + constrainedLipSize, // Start above the bottom lip + 0 // At the bottom + ]); + + // 3. Right side lip - extend to the full height from bottom lip to top of bracket + const rightLip = Manifold.cube([ + constrainedLipSize, // Width is the lip size + params.height - constrainedLipSize + BRACKET_THICKNESS, // Full height from bottom lip to top edge (including top thickness) + BRACKET_THICKNESS // Same thickness as bracket + ]).translate([ + BRACKET_THICKNESS + params.width - constrainedLipSize, // Align with right edge minus lip width + BRACKET_THICKNESS + constrainedLipSize, // Start above the bottom lip + 0 // At the bottom + ]); + + // Union the three lips together + const lipFrame = Manifold.union([bottomLip, leftLip, rightLip]); + + // Add the lip frame to the shell + shell = Manifold.union([shell, lipFrame]); + } // Create mounting ears const ear = Manifold.cube([params.earWidth, BRACKET_THICKNESS, params.depth]); - // Create the command strip cutout - const commandStripCutout = Manifold.cube([COMMAND_STRIP.WIDTH, COMMAND_STRIP.THICKNESS, COMMAND_STRIP.LENGTH]); - const ribbingSpacing = calculateSpacing({ availableWidth: params.depth, itemWidth: params.ribbingThickness, @@ -119,5 +184,7 @@ export const defaultParams: BracketParams = { bracketThickness: 1, ribbingThickness: 1, ribbingCount: 0, - hasBottom: false, + bottomType: 'none', + lipSize: 10, // Default lip size is 10mm + color: "#44ff00" }; diff --git a/styles.css b/styles.css index 1d6544c..7e39487 100644 --- a/styles.css +++ b/styles.css @@ -143,6 +143,32 @@ input[type="range"]::-moz-range-thumb { background: var(--color); } +select { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border: none; + border-radius: 4px; + padding: 5px 10px; + font-family: inherit; + font-size: 16px; + cursor: pointer; + min-width: 120px; + outline: none; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ffffff%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E"); + background-repeat: no-repeat; + background-position: right 10px top 50%; + background-size: 12px auto; + padding-right: 30px; +} + +select:hover, select:focus { + background-color: rgba(255, 255, 255, 0.2); + outline: none; +} + .value-display { flex: 0 0 40px; font-size: 16px;