diff --git a/app.ts b/app.ts index 85ee5b1..28cba54 100644 --- a/app.ts +++ b/app.ts @@ -2,6 +2,10 @@ import { exportTo3MF } from './export'; import { setupPreview } from "./preview"; import { createBracket, defaultParams } from "./psu-bracket"; +// Bambu X1C textured plate defaults +const DEFAULT_PLATE_WIDTH = 256; +const DEFAULT_PLATE_DEPTH = 256; + interface BracketParams { width: number; depth: number; @@ -12,6 +16,8 @@ interface BracketParams { holeDiameter: number; earWidth: number; hasBottom: boolean; + plateWidth: number; + plateDepth: number; } // Initialize the preview @@ -41,14 +47,58 @@ function parseFormData(data: FormData) { } +function calculateTotalWidth(params: BracketParams) { + return params.width + (params.bracketThickness * 2) + (params.earWidth * 2); +} + +function calculateMaxInnerWidth(earWidth: number, bracketThickness: number, plateWidth: number) { + return plateWidth - (earWidth * 2) - (bracketThickness * 2); +} + function displayValues(params: BracketParams) { + const plateWidth = params.plateWidth || DEFAULT_PLATE_WIDTH; + + // Update the max width based on ear width and bracket thickness FIRST + const widthInput = document.getElementById('width') as HTMLInputElement; + const maxWidth = calculateMaxInnerWidth(params.earWidth, params.bracketThickness, plateWidth); + if (widthInput) { + widthInput.max = maxWidth.toString(); + // Clamp current value if it exceeds new max + if (params.width > maxWidth) { + params.width = maxWidth; // Update params so total width calculation is correct + widthInput.value = maxWidth.toString(); + const widthValueInput = document.getElementById('widthValue') as HTMLInputElement; + if (widthValueInput) { + widthValueInput.value = maxWidth.toString(); + } + } + } + for(const input of inputs) { - const label = input.nextElementSibling as HTMLDivElement; - const unit = input.getAttribute("data-unit") ?? 'mm'; - if(label && label.classList.contains('value-display')) { - label.value = `${input.value}`; + // Find the value display - could be direct sibling or inside a wrapper + let valueDisplay = input.nextElementSibling as HTMLElement; + if (valueDisplay?.classList.contains('value-with-unit')) { + valueDisplay = valueDisplay.querySelector('.value-display') as HTMLInputElement; } + if(valueDisplay && valueDisplay.classList.contains('value-display')) { + (valueDisplay as HTMLInputElement).value = `${input.value}`; + } + } + + // Calculate and display total width (after clamping) + const totalWidth = calculateTotalWidth(params); + const totalWidthDisplay = document.getElementById('totalWidth'); + if (totalWidthDisplay) { + totalWidthDisplay.textContent = `${totalWidth}mm`; + totalWidthDisplay.classList.toggle('over-limit', totalWidth > plateWidth); } + + // Update plate limit display + const plateLimitDisplay = document.getElementById('plateLimitDisplay'); + if (plateLimitDisplay) { + plateLimitDisplay.textContent = plateWidth.toString(); + } + // Also pop the color on the root so we can use in css document.documentElement.style.setProperty('--color', params.color); } @@ -75,6 +125,44 @@ function updateUrl() { controls.addEventListener("input", handleInput); controls.addEventListener("change", updateUrl); +// Plate preset buttons +const presetButtons = document.querySelectorAll('.plate-preset-btn'); +const plateWidthInput = document.getElementById('plateWidth') as HTMLInputElement; +const plateDepthInput = document.getElementById('plateDepth') as HTMLInputElement; + +function updateActivePreset() { + const currentWidth = parseInt(plateWidthInput.value); + const currentDepth = parseInt(plateDepthInput.value); + + presetButtons.forEach(btn => { + const btnWidth = parseInt(btn.dataset.width || '0'); + const btnDepth = parseInt(btn.dataset.depth || '0'); + btn.classList.toggle('active', btnWidth === currentWidth && btnDepth === currentDepth); + }); +} + +presetButtons.forEach(btn => { + btn.addEventListener('click', () => { + const width = btn.dataset.width; + const depth = btn.dataset.depth; + if (width && depth) { + plateWidthInput.value = width; + plateDepthInput.value = depth; + // Trigger input event to update the UI + controls?.dispatchEvent(new Event('input', { bubbles: true })); + controls?.dispatchEvent(new Event('change', { bubbles: true })); + updateActivePreset(); + } + }); +}); + +// Update active preset when plate dimensions change manually +plateWidthInput.addEventListener('input', updateActivePreset); +plateDepthInput.addEventListener('input', updateActivePreset); + +// Initialize active preset on page load +updateActivePreset(); + // On page load, check if there is a url param and parse it function restoreState() { diff --git a/index.html b/index.html index 9846ef7..a3d5064 100644 --- a/index.html +++ b/index.html @@ -47,6 +47,43 @@

Bracket.Engineer

+
+ Build Plate +
+
+ + mm +
+ × +
+ + mm +
+
+
+ + + + + +
+
Bracket.Engineer id="width" name="width" min="10" - max="200" + max="236" step="0.5" - value="86" - /> - +
+ + mm +
+
+
+ + -- + / 256mm plate
@@ -76,12 +121,15 @@

Bracket.Engineer

step="0.5" value="16" /> - +
+ + mm +
@@ -94,12 +142,15 @@

Bracket.Engineer

step="1" value="25" /> - +
+ + mm +
@@ -113,12 +164,15 @@

Bracket.Engineer

step="0.5" value="3" /> - +
+ + mm +
@@ -135,7 +189,7 @@

Bracket.Engineer

@@ -150,12 +204,15 @@

Bracket.Engineer

step="0.5" value="2" /> - +
+ + mm +
@@ -168,12 +225,15 @@

Bracket.Engineer

step="0.5" value="2" /> - +
+ + mm +
@@ -189,7 +249,7 @@

Bracket.Engineer

@@ -200,16 +260,19 @@

Bracket.Engineer

id="earWidth" name="earWidth" min="5" - max="30" + max="40" step="1" - value="15" - /> - +
+ + mm +
diff --git a/psu-bracket.ts b/psu-bracket.ts index ac8924b..f7bcd0e 100644 --- a/psu-bracket.ts +++ b/psu-bracket.ts @@ -170,15 +170,15 @@ function calculateSpacing({ } export const defaultParams: BracketParams = { - width: 35.5, - depth: 16, - height: 15, + width: 200, + depth: 25, + height: 16, holeDiameter: 2, earWidth: 10, - bracketThickness: 1, - ribbingThickness: 1, - ribbingCount: 0, + bracketThickness: 3, + ribbingThickness: 2, + ribbingCount: 3, hasBottom: false, - holeCount: 2, + holeCount: 1, keyHole: false, }; diff --git a/readme.md b/readme.md index 45701bf..e7b2425 100644 --- a/readme.md +++ b/readme.md @@ -2,8 +2,39 @@ # Bracket Engineer -A website to generate 3D printable Power Supply brackets. +A website to generate 3D printable power supply / power brick brackets. Design custom mounting solutions for your devices and export them as 3MF files ready for slicing. -Built with Manifold 3D and Three.js +## Features -Run it with `npm run dev` build with `npm build`. Deployed to Cloudflare Workers. +- **Real-time 3D Preview** — See your bracket design update live as you adjust parameters +- **Build Plate Presets** — Quick selection for popular 3D printers: + - Bambu X1C (256 × 256 mm) + - Bambu A1 Mini (180 × 180 mm) + - Prusa MK4 (250 × 210 mm) + - Ender 3 (220 × 220 mm) + - Voron 350 (350 × 350 mm) +- **Custom Plate Sizes** — Manually enter any build plate dimensions +- **Smart Constraints** — Width automatically clamps to fit your selected plate +- **Parametric Design** — Adjust width, height, depth, thickness, ribbing, mounting holes, and more +- **URL State** — All parameters saved in URL for easy sharing +- **3MF Export** — Download print-ready files with one click + +## Tech Stack + +- [Manifold 3D](https://github.com/elalish/manifold) — Geometry kernel for CSG operations +- [Three.js](https://threejs.org/) — 3D rendering +- [Vite](https://vitejs.dev/) — Build tooling +- Deployed to Cloudflare Workers + +## Development + +```bash +# Install dependencies +npm install + +# Start dev server +npm run dev + +# Build for production +npm run build +``` diff --git a/styles.css b/styles.css index 1d6544c..9781115 100644 --- a/styles.css +++ b/styles.css @@ -219,3 +219,145 @@ button { text-transform: uppercase; color: black; } + +/* Total width display */ +.total-width-display { + background: rgba(255, 255, 255, 0.05); + padding: 8px 12px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.total-width-display label { + font-size: 14px; + opacity: 0.7; +} + +.total-width-display #totalWidth { + font-size: 20px; + font-weight: 600; + color: var(--color); + transition: color 0.2s; +} + +.total-width-display #totalWidth.over-limit { + color: #ff4444; +} + +.total-width-display .plate-limit { + font-size: 14px; + opacity: 0.5; +} + +/* Value with unit styling */ +.value-with-unit { + display: flex; + align-items: center; + gap: 2px; +} + +.value-with-unit .value-display { + width: 50px; +} + +.unit { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + text-transform: lowercase; + font-weight: 400; +} + +.value-display.no-unit { + width: 60px; +} + +/* Build Plate Settings */ +.plate-settings { + grid-column: 1 / -1; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.03); +} + +.plate-settings legend { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.5); + padding: 0 8px; +} + +.plate-inputs { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.plate-input-group { + display: flex; + align-items: center; + gap: 3px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.plate-input-group input { + width: 60px; + background: none; + border: none; + color: #fff; + font-family: inherit; + font-size: 16px; + text-align: center; +} + +.plate-input-group input:focus { + outline: none; +} + +.plate-input-group .unit { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +.plate-divider { + font-size: 16px; + color: rgba(255, 255, 255, 0.3); +} + +.plate-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.plate-preset-btn { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + padding: 5px 12px; + background: rgba(255, 255, 255, 0.05); + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; + text-transform: none; +} + +.plate-preset-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); +} + +.plate-preset-btn.active { + background: var(--color); + border-color: var(--color); + color: #000; + font-weight: 500; +}