Created
April 5, 2026 15:21
-
-
Save aschmelyun/c50fba55778a310cd0728c3a96307c71 to your computer and use it in GitHub Desktop.
Crescent cookie cutter STL generator with Three.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Crescent Moon Cookie Cutter</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg: #1a1a1e; | |
| --surface: #232328; | |
| --surface2: #2c2c33; | |
| --border: #3a3a44; | |
| --text: #e8e6e3; | |
| --text2: #9a979f; | |
| --accent: #f0a856; | |
| --accent2: #e8853a; | |
| --accent-dim: rgba(240,168,86,0.12); | |
| } | |
| body { | |
| font-family: 'DM Sans', sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| height: 100vh; | |
| overflow: hidden; | |
| display: flex; | |
| } | |
| #controls { | |
| width: 320px; | |
| min-width: 320px; | |
| background: var(--surface); | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--border) transparent; | |
| } | |
| .controls-header { | |
| padding: 24px 20px 16px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .controls-header h1 { | |
| font-size: 18px; | |
| font-weight: 700; | |
| letter-spacing: -0.3px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .controls-header h1 span { | |
| color: var(--accent); | |
| } | |
| .controls-header p { | |
| font-size: 12px; | |
| color: var(--text2); | |
| margin-top: 6px; | |
| line-height: 1.4; | |
| } | |
| .section { | |
| padding: 16px 20px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .section-title { | |
| font-size: 10px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| color: var(--accent); | |
| margin-bottom: 14px; | |
| } | |
| .param-row { | |
| margin-bottom: 12px; | |
| } | |
| .param-label { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| margin-bottom: 5px; | |
| } | |
| .param-label label { | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--text); | |
| } | |
| .param-label .value { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 12px; | |
| color: var(--accent); | |
| font-weight: 500; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 4px; | |
| background: var(--border); | |
| border-radius: 2px; | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: var(--accent); | |
| border-radius: 50%; | |
| border: 2px solid var(--bg); | |
| box-shadow: 0 0 6px rgba(240,168,86,0.4); | |
| cursor: grab; | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| background: var(--accent); | |
| border-radius: 50%; | |
| border: 2px solid var(--bg); | |
| box-shadow: 0 0 6px rgba(240,168,86,0.4); | |
| cursor: grab; | |
| } | |
| #export-btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| width: calc(100% - 40px); | |
| margin: 16px 20px; | |
| padding: 12px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| color: #1a1a1e; | |
| border: none; | |
| border-radius: 8px; | |
| font-family: 'DM Sans', sans-serif; | |
| font-size: 14px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| letter-spacing: 0.3px; | |
| transition: transform 0.15s, box-shadow 0.15s; | |
| } | |
| #export-btn:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 16px rgba(240,168,86,0.35); | |
| } | |
| #export-btn:active { transform: translateY(0); } | |
| .dims-display { | |
| padding: 12px 20px 16px; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 11px; | |
| color: var(--text2); | |
| line-height: 1.7; | |
| } | |
| .dims-display strong { | |
| color: var(--text); | |
| font-weight: 500; | |
| } | |
| #canvas-container { | |
| flex: 1; | |
| position: relative; | |
| overflow: hidden; | |
| background: var(--bg); | |
| } | |
| #canvas-container canvas { | |
| width: 100% !important; | |
| height: 100% !important; | |
| display: block; | |
| } | |
| .view-hint { | |
| position: absolute; | |
| bottom: 16px; | |
| right: 16px; | |
| font-size: 11px; | |
| color: var(--text2); | |
| background: rgba(26,26,30,0.8); | |
| padding: 6px 12px; | |
| border-radius: 6px; | |
| pointer-events: none; | |
| backdrop-filter: blur(8px); | |
| border: 1px solid var(--border); | |
| } | |
| .badge { | |
| position: absolute; | |
| top: 16px; | |
| right: 16px; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| color: var(--accent); | |
| background: var(--accent-dim); | |
| padding: 4px 10px; | |
| border-radius: 4px; | |
| border: 1px solid rgba(240,168,86,0.2); | |
| pointer-events: none; | |
| } | |
| @media (max-width: 700px) { | |
| body { flex-direction: column; } | |
| #controls { width: 100%; min-width: 0; max-height: 45vh; border-right: none; border-bottom: 1px solid var(--border); } | |
| #canvas-container { min-height: 300px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <div class="controls-header"> | |
| <h1><span>☽</span> Cookie Cutter</h1> | |
| <p>Parametric crescent moon cookie cutter. Adjust parameters below and export STL for 3D printing.</p> | |
| </div> | |
| <div class="section"> | |
| <div class="section-title">Crescent Shape</div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Moon Size</label><span class="value" id="v-size">80mm</span></div> | |
| <input type="range" id="p-size" min="40" max="150" value="80" step="1"> | |
| </div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Curvature</label><span class="value" id="v-curve">0.60</span></div> | |
| <input type="range" id="p-curve" min="25" max="85" value="60" step="1"> | |
| </div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Crescent Width</label><span class="value" id="v-cwidth">18mm</span></div> | |
| <input type="range" id="p-cwidth" min="6" max="50" value="18" step="1"> | |
| </div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Tip Roundness</label><span class="value" id="v-round">0.35</span></div> | |
| <input type="range" id="p-round" min="5" max="100" value="35" step="1"> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-title">Cutter Profile</div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Total Height</label><span class="value" id="v-wheight">18mm</span></div> | |
| <input type="range" id="p-wheight" min="8" max="35" value="18" step="1"> | |
| </div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Wall Thickness</label><span class="value" id="v-wthick">1.2mm</span></div> | |
| <input type="range" id="p-wthick" min="6" max="30" value="12" step="1"> | |
| </div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Base Height</label><span class="value" id="v-bheight">4mm</span></div> | |
| <input type="range" id="p-bheight" min="1" max="10" value="4" step="1"> | |
| </div> | |
| <div class="param-row"> | |
| <div class="param-label"><label>Base Thickness</label><span class="value" id="v-bthick">3.0mm</span></div> | |
| <input type="range" id="p-bthick" min="15" max="60" value="30" step="1"> | |
| </div> | |
| </div> | |
| <button id="export-btn"> | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1v9M4.5 7L8 10.5 11.5 7M3 13h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> | |
| Export STL | |
| </button> | |
| <div class="dims-display" id="dims-info"></div> | |
| </div> | |
| <div id="canvas-container"> | |
| <canvas id="viewport"></canvas> | |
| <div class="view-hint">Drag to rotate · Scroll to zoom</div> | |
| <div class="badge" id="tri-count"></div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script> | |
| (function() { | |
| // ─── PARAMETERS ─── | |
| function getParams() { | |
| return { | |
| size: +document.getElementById('p-size').value, | |
| curve: +document.getElementById('p-curve').value / 100, | |
| cwidth: +document.getElementById('p-cwidth').value, | |
| roundness: +document.getElementById('p-round').value / 100, | |
| wallHeight: +document.getElementById('p-wheight').value, | |
| wallThick: +document.getElementById('p-wthick').value / 10, | |
| baseHeight: +document.getElementById('p-bheight').value, | |
| baseThick: +document.getElementById('p-bthick').value / 10, | |
| }; | |
| } | |
| function updateLabels() { | |
| const p = getParams(); | |
| document.getElementById('v-size').textContent = p.size + 'mm'; | |
| document.getElementById('v-curve').textContent = p.curve.toFixed(2); | |
| document.getElementById('v-cwidth').textContent = p.cwidth + 'mm'; | |
| document.getElementById('v-round').textContent = p.roundness.toFixed(2); | |
| document.getElementById('v-wheight').textContent = p.wallHeight + 'mm'; | |
| document.getElementById('v-wthick').textContent = p.wallThick.toFixed(1) + 'mm'; | |
| document.getElementById('v-bheight').textContent = p.baseHeight + 'mm'; | |
| document.getElementById('v-bthick').textContent = p.baseThick.toFixed(1) + 'mm'; | |
| } | |
| // ─── CRESCENT PATH ─── | |
| function generateCrescentPath(params) { | |
| const { size, curve, cwidth, roundness } = params; | |
| const arcSpan = Math.PI * (0.4 + curve * 1.2); // range ~72° to ~288° | |
| const spineRadius = size / (2 * Math.sin(arcSpan / 2)); | |
| const maxWidth = cwidth; | |
| const tipWidthRatio = 0.05 + roundness * 0.35; | |
| const N = 120; // points per segment | |
| const points = []; | |
| const startAngle = Math.PI - arcSpan / 2; | |
| const endAngle = Math.PI + arcSpan / 2; | |
| const cx = spineRadius; // spine circle center | |
| function widthAt(t) { | |
| // t in [0,1] along the spine, 0 and 1 are tips | |
| const s = Math.sin(Math.PI * t); | |
| return maxWidth * (tipWidthRatio + (1 - tipWidthRatio) * s); | |
| } | |
| // Outer edge: from startAngle to endAngle | |
| for (let i = 0; i <= N; i++) { | |
| const t = i / N; | |
| const angle = startAngle + t * (endAngle - startAngle); | |
| const w = widthAt(t); | |
| const r = spineRadius + w / 2; | |
| points.push({ x: cx + r * Math.cos(angle), y: r * Math.sin(angle) }); | |
| } | |
| // End cap (at endAngle) | |
| const tipW_end = widthAt(1); | |
| const capR_end = tipW_end / 2; | |
| const capCenter_end = { | |
| x: cx + spineRadius * Math.cos(endAngle), | |
| y: spineRadius * Math.sin(endAngle) | |
| }; | |
| // The cap semicircle goes from outer to inner, curving "forward" (past the end of the arc) | |
| const radDir_end = { x: Math.cos(endAngle), y: Math.sin(endAngle) }; | |
| const tanDir_end = { x: -Math.sin(endAngle), y: Math.cos(endAngle) }; // CCW tangent | |
| const capSteps = 24; | |
| for (let i = 1; i < capSteps; i++) { | |
| const a = (i / capSteps) * Math.PI; | |
| const lx = Math.cos(a); // goes from 1 (outer radial) to -1 (inner radial) | |
| const ly = Math.sin(a); // bulges in tangent direction | |
| points.push({ | |
| x: capCenter_end.x + capR_end * (lx * radDir_end.x + ly * tanDir_end.x), | |
| y: capCenter_end.y + capR_end * (lx * radDir_end.y + ly * tanDir_end.y) | |
| }); | |
| } | |
| // Inner edge: from endAngle back to startAngle | |
| for (let i = N; i >= 0; i--) { | |
| const t = i / N; | |
| const angle = startAngle + t * (endAngle - startAngle); | |
| const w = widthAt(t); | |
| const r = spineRadius - w / 2; | |
| points.push({ x: cx + r * Math.cos(angle), y: r * Math.sin(angle) }); | |
| } | |
| // Start cap (at startAngle) | |
| const tipW_start = widthAt(0); | |
| const capR_start = tipW_start / 2; | |
| const capCenter_start = { | |
| x: cx + spineRadius * Math.cos(startAngle), | |
| y: spineRadius * Math.sin(startAngle) | |
| }; | |
| const radDir_start = { x: Math.cos(startAngle), y: Math.sin(startAngle) }; | |
| const tanDir_start = { x: -Math.sin(startAngle), y: Math.cos(startAngle) }; | |
| // Cap goes from inner to outer, curving "backward" (before the start of the arc) | |
| for (let i = 1; i < capSteps; i++) { | |
| const a = (i / capSteps) * Math.PI; | |
| const lx = -Math.cos(a); // from -1 (inner) to 1 (outer) | |
| const ly = -Math.sin(a); // bulges opposite to tangent | |
| points.push({ | |
| x: capCenter_start.x + capR_start * (lx * radDir_start.x + ly * tanDir_start.x), | |
| y: capCenter_start.y + capR_start * (lx * radDir_start.y + ly * tanDir_start.y) | |
| }); | |
| } | |
| // Center the path | |
| let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; | |
| for (const p of points) { | |
| if (p.x < minX) minX = p.x; | |
| if (p.x > maxX) maxX = p.x; | |
| if (p.y < minY) minY = p.y; | |
| if (p.y > maxY) maxY = p.y; | |
| } | |
| const offsetX = (minX + maxX) / 2; | |
| const offsetY = (minY + maxY) / 2; | |
| for (const p of points) { | |
| p.x -= offsetX; | |
| p.y -= offsetY; | |
| } | |
| return { points, bbox: { w: maxX - minX, h: maxY - minY } }; | |
| } | |
| // ─── COOKIE CUTTER GEOMETRY ─── | |
| function buildGeometry(params) { | |
| const { wallHeight, wallThick, baseHeight, baseThick } = params; | |
| const { points, bbox } = generateCrescentPath(params); | |
| const N = points.length; | |
| // Cross-section profile (local X = outward normal, local Y = up/Z) | |
| const wt2 = wallThick / 2; | |
| const bt2 = baseThick / 2; | |
| const bh = Math.min(baseHeight, wallHeight * 0.8); | |
| const wh = wallHeight; | |
| const profile = [ | |
| { x: -wt2, y: wh }, // 0: inner wall top | |
| { x: wt2, y: wh }, // 1: outer wall top | |
| { x: wt2, y: bh }, // 2: outer wall-base junction | |
| { x: bt2, y: bh }, // 3: outer base top | |
| { x: bt2, y: 0 }, // 4: outer base bottom | |
| { x: -bt2, y: 0 }, // 5: inner base bottom | |
| { x: -bt2, y: bh }, // 6: inner base top | |
| { x: -wt2, y: bh }, // 7: inner wall-base junction | |
| ]; | |
| const P = profile.length; | |
| // Compute tangents & normals for the path | |
| const tangents = []; | |
| const normals = []; | |
| for (let i = 0; i < N; i++) { | |
| const prev = points[(i - 1 + N) % N]; | |
| const next = points[(i + 1) % N]; | |
| const tx = next.x - prev.x; | |
| const ty = next.y - prev.y; | |
| const len = Math.sqrt(tx * tx + ty * ty) || 1; | |
| tangents.push({ x: tx / len, y: ty / len }); | |
| // Outward normal: for CCW path, outward = (ty, -tx) / len | |
| normals.push({ x: ty / len, y: -tx / len }); | |
| } | |
| // Check normal direction: the normal at the midpoint of the outer edge should point | |
| // away from the center. If not, flip all normals. | |
| // The outer edge midpoint is roughly at index N/4 (quarter of the full path which is | |
| // outer + cap + inner + cap). But let's use a heuristic: compute centroid and check. | |
| let cx = 0, cy = 0; | |
| for (const p of points) { cx += p.x; cy += p.y; } | |
| cx /= N; cy /= N; | |
| // Check first point's normal | |
| const testDot = (points[0].x - cx) * normals[0].x + (points[0].y - cy) * normals[0].y; | |
| const flipNormals = testDot < 0; | |
| if (flipNormals) { | |
| for (let i = 0; i < N; i++) { | |
| normals[i].x = -normals[i].x; | |
| normals[i].y = -normals[i].y; | |
| } | |
| } | |
| // Build vertices: N path points × P profile points | |
| const vertices = new Float32Array(N * P * 3); | |
| for (let i = 0; i < N; i++) { | |
| const px = points[i].x; | |
| const py = points[i].y; | |
| const nx = normals[i].x; | |
| const ny = normals[i].y; | |
| for (let j = 0; j < P; j++) { | |
| const lx = profile[j].x; // along normal | |
| const ly = profile[j].y; // along Z (up) | |
| const vi = (i * P + j) * 3; | |
| vertices[vi] = px + lx * nx; | |
| vertices[vi + 1] = py + lx * ny; | |
| vertices[vi + 2] = ly; | |
| } | |
| } | |
| // Build indices: connect adjacent cross-sections | |
| const indices = []; | |
| for (let i = 0; i < N; i++) { | |
| const i2 = (i + 1) % N; | |
| for (let j = 0; j < P; j++) { | |
| const j2 = (j + 1) % P; | |
| const a = i * P + j; | |
| const b = i2 * P + j; | |
| const c = i2 * P + j2; | |
| const d = i * P + j2; | |
| indices.push(a, b, c); | |
| indices.push(a, c, d); | |
| } | |
| } | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); | |
| geo.setIndex(indices); | |
| geo.computeVertexNormals(); | |
| return { geometry: geo, bbox, triCount: indices.length / 3 }; | |
| } | |
| // ─── STL EXPORT ─── | |
| function exportSTL(geometry) { | |
| const pos = geometry.getAttribute('position'); | |
| const idx = geometry.getIndex(); | |
| const triCount = idx ? idx.count / 3 : pos.count / 3; | |
| const bufLen = 80 + 4 + triCount * 50; | |
| const buf = new ArrayBuffer(bufLen); | |
| const dv = new DataView(buf); | |
| // Header | |
| const hdr = "Crescent Moon Cookie Cutter - Generated with Three.js"; | |
| for (let i = 0; i < 80; i++) dv.setUint8(i, i < hdr.length ? hdr.charCodeAt(i) : 0); | |
| dv.setUint32(80, triCount, true); | |
| let off = 84; | |
| const v = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()]; | |
| const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), n = new THREE.Vector3(); | |
| for (let t = 0; t < triCount; t++) { | |
| const i0 = idx ? idx.getX(t*3) : t*3; | |
| const i1 = idx ? idx.getX(t*3+1) : t*3+1; | |
| const i2 = idx ? idx.getX(t*3+2) : t*3+2; | |
| v[0].set(pos.getX(i0), pos.getY(i0), pos.getZ(i0)); | |
| v[1].set(pos.getX(i1), pos.getY(i1), pos.getZ(i1)); | |
| v[2].set(pos.getX(i2), pos.getY(i2), pos.getZ(i2)); | |
| e1.subVectors(v[1], v[0]); | |
| e2.subVectors(v[2], v[0]); | |
| n.crossVectors(e1, e2).normalize(); | |
| dv.setFloat32(off, n.x, true); off += 4; | |
| dv.setFloat32(off, n.y, true); off += 4; | |
| dv.setFloat32(off, n.z, true); off += 4; | |
| for (let vi = 0; vi < 3; vi++) { | |
| dv.setFloat32(off, v[vi].x, true); off += 4; | |
| dv.setFloat32(off, v[vi].y, true); off += 4; | |
| dv.setFloat32(off, v[vi].z, true); off += 4; | |
| } | |
| dv.setUint16(off, 0, true); off += 2; | |
| } | |
| const blob = new Blob([buf], { type: 'application/octet-stream' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'crescent_cookie_cutter.stl'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| // ─── THREE.JS SETUP ─── | |
| const container = document.getElementById('canvas-container'); | |
| const canvas = document.getElementById('viewport'); | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setClearColor(0x1a1a1e); | |
| renderer.shadowMap.enabled = true; | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 2000); | |
| camera.position.set(0, -120, 100); | |
| camera.up.set(0, 0, 1); | |
| // Lights | |
| const ambLight = new THREE.AmbientLight(0xffffff, 0.4); | |
| scene.add(ambLight); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| dirLight.position.set(60, -80, 120); | |
| scene.add(dirLight); | |
| const dirLight2 = new THREE.DirectionalLight(0x88aaff, 0.3); | |
| dirLight2.position.set(-40, 60, 80); | |
| scene.add(dirLight2); | |
| // Grid | |
| const gridHelper = new THREE.GridHelper(200, 40, 0x333340, 0x28282f); | |
| gridHelper.rotation.x = Math.PI / 2; | |
| gridHelper.position.z = -0.01; | |
| scene.add(gridHelper); | |
| // Material | |
| const material = new THREE.MeshPhysicalMaterial({ | |
| color: 0xd4d4d8, | |
| metalness: 0.5, | |
| roughness: 0.3, | |
| clearcoat: 0.2, | |
| side: THREE.DoubleSide, | |
| }); | |
| // Wireframe overlay | |
| const wireMat = new THREE.MeshBasicMaterial({ | |
| color: 0xf0a856, | |
| wireframe: true, | |
| transparent: true, | |
| opacity: 0.06 | |
| }); | |
| let mesh, wireMesh; | |
| let currentGeo; | |
| function rebuild() { | |
| updateLabels(); | |
| const params = getParams(); | |
| const { geometry, bbox, triCount } = buildGeometry(params); | |
| if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); } | |
| if (wireMesh) { scene.remove(wireMesh); wireMesh.geometry.dispose(); } | |
| mesh = new THREE.Mesh(geometry, material); | |
| wireMesh = new THREE.Mesh(geometry, wireMat); | |
| scene.add(mesh); | |
| scene.add(wireMesh); | |
| currentGeo = geometry; | |
| document.getElementById('tri-count').textContent = triCount.toLocaleString() + ' tris'; | |
| document.getElementById('dims-info').innerHTML = | |
| `<strong>Bounding box:</strong> ${bbox.w.toFixed(1)} × ${bbox.h.toFixed(1)} × ${params.wallHeight.toFixed(1)} mm`; | |
| } | |
| // ─── ORBIT CONTROLS (manual) ─── | |
| let isDragging = false; | |
| let prevMouse = { x: 0, y: 0 }; | |
| let spherical = { theta: Math.PI / 2, phi: Math.PI / 4, radius: 180 }; | |
| function updateCamera() { | |
| const { theta, phi, radius } = spherical; | |
| camera.position.set( | |
| radius * Math.sin(phi) * Math.cos(theta), | |
| radius * Math.sin(phi) * Math.sin(theta), | |
| radius * Math.cos(phi) | |
| ); | |
| camera.lookAt(0, 0, 8); | |
| } | |
| canvas.addEventListener('pointerdown', (e) => { | |
| isDragging = true; | |
| prevMouse = { x: e.clientX, y: e.clientY }; | |
| canvas.setPointerCapture(e.pointerId); | |
| }); | |
| canvas.addEventListener('pointermove', (e) => { | |
| if (!isDragging) return; | |
| const dx = e.clientX - prevMouse.x; | |
| const dy = e.clientY - prevMouse.y; | |
| prevMouse = { x: e.clientX, y: e.clientY }; | |
| spherical.theta -= dx * 0.008; | |
| spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi - dy * 0.008)); | |
| updateCamera(); | |
| }); | |
| canvas.addEventListener('pointerup', () => isDragging = false); | |
| canvas.addEventListener('pointercancel', () => isDragging = false); | |
| canvas.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| spherical.radius = Math.max(40, Math.min(600, spherical.radius + e.deltaY * 0.3)); | |
| updateCamera(); | |
| }, { passive: false }); | |
| // Touch zoom | |
| let lastTouchDist = 0; | |
| canvas.addEventListener('touchstart', (e) => { | |
| if (e.touches.length === 2) { | |
| const dx = e.touches[0].clientX - e.touches[1].clientX; | |
| const dy = e.touches[0].clientY - e.touches[1].clientY; | |
| lastTouchDist = Math.sqrt(dx*dx + dy*dy); | |
| } | |
| }); | |
| canvas.addEventListener('touchmove', (e) => { | |
| if (e.touches.length === 2) { | |
| const dx = e.touches[0].clientX - e.touches[1].clientX; | |
| const dy = e.touches[0].clientY - e.touches[1].clientY; | |
| const dist = Math.sqrt(dx*dx + dy*dy); | |
| spherical.radius = Math.max(40, Math.min(600, spherical.radius - (dist - lastTouchDist) * 0.5)); | |
| lastTouchDist = dist; | |
| updateCamera(); | |
| } | |
| }); | |
| // ─── RESIZE ─── | |
| function onResize() { | |
| const w = container.clientWidth; | |
| const h = container.clientHeight; | |
| renderer.setSize(w, h); | |
| camera.aspect = w / h; | |
| camera.updateProjectionMatrix(); | |
| } | |
| window.addEventListener('resize', onResize); | |
| // ─── RENDER LOOP ─── | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| renderer.render(scene, camera); | |
| } | |
| // ─── EVENT WIRING ─── | |
| document.querySelectorAll('input[type="range"]').forEach(el => { | |
| el.addEventListener('input', rebuild); | |
| }); | |
| document.getElementById('export-btn').addEventListener('click', () => { | |
| if (currentGeo) exportSTL(currentGeo); | |
| }); | |
| // ─── INIT ─── | |
| onResize(); | |
| updateCamera(); | |
| rebuild(); | |
| animate(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment