Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
2,015 changes: 2,015 additions & 0 deletions examples/gallery/make-no-mistakes-keycap/2.0u_blank_costar.stp

Large diffs are not rendered by default.

Binary file not shown.
122 changes: 122 additions & 0 deletions examples/gallery/make-no-mistakes-keycap/model.kcad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Purpose: add an editable, centered spherical finger dish to the original
// 2.0u Costar keycap while preserving its source geometry and coordinate frame.
// Source STEP bounds are centered on X/Y; its exterior touch face is at Z = 0.

const dishDepth = param('dishDepth', 1.0, {
min: 0.4,
max: 1.5,
description: 'Maximum depth of the centered spherical top dish (mm)',
});

const outerTopZ = 0.0;
// The source is 37 × 18 mm. A sphere with this radius gives a 1 mm sag
// from the four footprint corners to the center:
// R = (halfDiagonal² + sag²) / (2 × sag) = 212.125 mm.
const dishRadius = 212.125;
const sphereCenterZ = dishDepth.add(outerTopZ - dishRadius);

const originalKeycap = await lib.fromSTEP('./2.0u_blank_costar.stp');
const sphericalDishCutter = sphere(dishRadius).translate(0, 0, sphereCenterZ);

// Single returned body: the imported keycap with its centered dish removed.
const dishedKeycap = originalKeycap.subtract(sphericalDishCutter);

// Engraving cutters begin outside the negative-Z exterior and end about
// 0.45 mm inside the dished center surface.
const legendCutStartZ = 0.4;
const legendCutHeight = 1.05;

const firstLine = sketch
.text('MAKE NO', {
size: 6.4,
align: 'center',
position: [0, 1.6],
})
.extrude(legendCutHeight)
.reflect('yz')
.translate(0, 0, legendCutStartZ);

const secondLine = sketch
.text('MISTAKES', {
size: 6.0,
align: 'center',
position: [0, -3.5],
})
.extrude(legendCutHeight)
.reflect('yz')
.translate(0, 0, legendCutStartZ);

const engravedKeycap = dishedKeycap
.subtract(firstLine, secondLine)
.material({
baseColor: '#252830',
metalness: 0,
roughness: 0.42,
});

// White inlays sit at the bottoms of the engraved grooves. Their exterior
// faces remain recessed below the spherical touch surface.
const inlayStartZ = 1.3;
const inlayHeight = 0.2;

const firstLineInlay = sketch
.text('MAKE NO', {
size: 6.4,
align: 'center',
position: [0, 1.6],
})
.extrude(inlayHeight)
.reflect('yz')
.translate(0, 0, inlayStartZ)
.material({
baseColor: '#ffffff',
metalness: 0,
roughness: 0.3,
});

const secondLineInlay = sketch
.text('MISTAKES', {
size: 6.0,
align: 'center',
position: [0, -3.5],
})
.extrude(inlayHeight)
.reflect('yz')
.translate(0, 0, inlayStartZ)
.material({
baseColor: '#ffffff',
metalness: 0,
roughness: 0.3,
});

// Return the manufacturing body and both recessed inlays as named parts. This
// keeps the dark printable keycap body intact while making the white lettering
// visible in Studio, GLB, and multi-material 3MF workflows.
const finishedKeycap = assembly('MAKE NO MISTAKES 2.0u Costar keycap');
const keycapBodyPart = finishedKeycap.part('keycap-body', engravedKeycap);
const makeNoInlayPart = finishedKeycap.part('make-no-inlay', firstLineInlay);
const mistakesInlayPart = finishedKeycap.part('mistakes-inlay', secondLineInlay);

const fixedFrame = {
type: 'frame',
origin: { kind: 'vec3', value: [0, 0, 0] },
};
keycapBodyPart
.connector('make-no-seat', fixedFrame)
.connector('mistakes-seat', fixedFrame);
makeNoInlayPart.connector('seat', fixedFrame);
mistakesInlayPart.connector('seat', fixedFrame);
finishedKeycap.mate(
'make-no-fixed-inlay',
'keycap-body.make-no-seat',
'make-no-inlay.seat',
'fastened',
);
finishedKeycap.mate(
'mistakes-fixed-inlay',
'keycap-body.mistakes-seat',
'mistakes-inlay.seat',
'fastened',
);

return finishedKeycap.model();
1 change: 1 addition & 0 deletions scripts/lib/galleryEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const galleryEntryBaseSchema = z.object({
version: z.string().regex(/^v\d+\.\d+(\.\d+)?$/),
prompt: z.string().min(1),
video: z.string().min(1),
modelLocal: z.string().min(1).nullable().default(null),
code: z.string().url(),
tags: z.array(z.string()).default([]),
featured: z.boolean().default(false),
Expand Down
28 changes: 28 additions & 0 deletions site/gallery/entries.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
{
"entries": [
{
"slug": "make-no-mistakes-keycap",
"title": "MAKE NO MISTAKES — 2.0u Keycap",
"author": {
"handle": "w1ne",
"url": "https://github.com/w1ne"
},
"version": "v0.15.0",
"prompt": "Modify the original 2.0u Costar STEP keycap without replacing its source geometry: give the full touch surface a 1 mm spherical concave dish, engrave MAKE NO / MISTAKES deeply into the cap with large tightly spaced lettering, and add recessed white inlays while preserving a printable manufacturing body.",
"source": "studio",
"video": "../../docs/demos/gallery/make-no-mistakes-keycap/demo.mp4",
"modelLocal": "../../examples/gallery/make-no-mistakes-keycap/model.glb",
"codeLocal": "../../examples/gallery/make-no-mistakes-keycap/model.kcad.ts",
"code": "https://app.kernelcad.com/p/N2yYiZxy?version=3",
"tags": [
"keycap",
"step-import",
"engraving",
"text",
"concave",
"costar",
"3d-printing",
"words-to-geometry"
],
"featured": true,
"createdAt": "2026-07-27",
"appUrl": "https://app.kernelcad.com/p/N2yYiZxy?version=3"
},
{
"slug": "royal-pop-pocket-watch",
"title": "Royal Pop Pocket Watch",
Expand Down
42 changes: 42 additions & 0 deletions site/scripts/build-gallery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,48 @@ describe('buildGallery', () => {
expect(glb.subarray(0, 4).toString('utf8')).toBe('glTF');
});

it('publishes a prebuilt project GLB without evaluating its STEP-backed source', { timeout: 60000 }, async () => {
tmp = mkdtempSync(path.join(tmpdir(), 'gallery-build-'));
const entriesDir = path.join(tmp, 'gallery');
const publicDir = path.join(tmp, 'public');
mkdirSync(entriesDir, { recursive: true });

const fixturesRoot = path.resolve(__dirname, '../../tests/fixtures/gallery');
const keycapRoot = path.resolve(__dirname, '../../examples/gallery/make-no-mistakes-keycap');
copyFileSync(path.join(fixturesRoot, 'short-clip.mp4'), path.join(entriesDir, 'short-clip.mp4'));
copyFileSync(path.join(keycapRoot, 'model.glb'), path.join(entriesDir, 'project-model.glb'));
writeFileSync(
path.join(entriesDir, 'step-backed.kcad.ts'),
"throw new Error('source evaluation must be skipped when modelLocal is supplied');\n",
);
writeFileSync(
path.join(entriesDir, 'entries.json'),
JSON.stringify({
entries: [{
slug: 'prebuilt-project', title: 'Prebuilt project',
author: { handle: 'k', url: 'https://x.com/k' },
version: 'v0.15.0', prompt: 'p', source: 'studio',
video: 'short-clip.mp4', codeLocal: 'step-backed.kcad.ts',
modelLocal: 'project-model.glb',
code: 'https://github.com/w1ne/kernelCAD-web',
tags: [], featured: false, createdAt: '2026-07-27',
appUrl: 'https://app.kernelcad.com/p/example?version=1',
}],
}),
);

await buildGallery({
entriesPath: path.join(entriesDir, 'entries.json'),
publicDir,
});

const glb = readFileSync(path.join(publicDir, 'gallery/prebuilt-project/model.glb'));
expect(glb.subarray(0, 4).toString('utf8')).toBe('glTF');
const out = JSON.parse(readFileSync(path.join(publicDir, 'gallery.json'), 'utf8'));
expect(out.entries[0].modelUrl).toBe('/gallery/prebuilt-project/model.glb');
expect(out.entries[0]).not.toHaveProperty('modelLocal');
});

it('rejects when an entry video is missing', async () => {
tmp = mkdtempSync(path.join(tmpdir(), 'gallery-build-'));
const entriesDir = path.join(tmp, 'gallery');
Expand Down
16 changes: 12 additions & 4 deletions site/scripts/build-gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export interface BuildGalleryOptions {
publicDir: string;
}

interface PublishedEntry extends Omit<GalleryEntry, 'video' | 'codeLocal'> {
interface PublishedEntry extends Omit<GalleryEntry, 'video' | 'modelLocal' | 'codeLocal'> {
videoUrl: string;
posterUrl: string;
modelUrl: string;
Expand Down Expand Up @@ -192,7 +192,15 @@ export async function buildGallery(opts: BuildGalleryOptions): Promise<void> {
// the catalog board pipeline (scripts/lib/optimizeGlb.ts) so both surfaces
// ship GLBs built the same way — the flags there are load-bearing.
const rawGlb = path.join(slugDir, 'model.raw.glb');
await exportGlb({ scriptPath: srcScript, outPath: rawGlb });
if (entry.modelLocal) {
const srcModel = path.resolve(entriesDir, entry.modelLocal);
if (!existsSync(srcModel)) {
throw new Error(`entry ${entry.slug}: modelLocal not found at ${srcModel}`);
}
copyFileSync(srcModel, rawGlb);
} else {
await exportGlb({ scriptPath: srcScript, outPath: rawGlb });
}
await optimizeGlb(rawGlb, dstModel, {
repoRoot: REPO_ROOT,
label: `entry ${entry.slug}`,
Expand Down Expand Up @@ -273,8 +281,8 @@ export async function buildGallery(opts: BuildGalleryOptions): Promise<void> {

writeFileSync(dstPrompt, entry.prompt + '\n');

const { video, codeLocal, mechanismReview, ...rest } = entry;
void video; void codeLocal; void mechanismReview;
const { video, modelLocal, codeLocal, mechanismReview, ...rest } = entry;
void video; void modelLocal; void codeLocal; void mechanismReview;
published.push({
...rest,
videoUrl: `/gallery/${entry.slug}/video.mp4`,
Expand Down
Loading