Skip to content

Instantly share code, notes, and snippets.

@aschmelyun
Created April 5, 2026 15:21
Show Gist options
  • Select an option

  • Save aschmelyun/c50fba55778a310cd0728c3a96307c71 to your computer and use it in GitHub Desktop.

Select an option

Save aschmelyun/c50fba55778a310cd0728c3a96307c71 to your computer and use it in GitHub Desktop.
Crescent cookie cutter STL generator with Three.js
<!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>&#9789;</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