From 889e8ee41954694bf1f3fb8bb8edb2e2dee265f8 Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:38:33 +0200 Subject: [PATCH 1/3] fix(studio): render pinned project revisions --- src/funnel/lib/apiClient.test.ts | 40 ++++++++++++++++++++ src/funnel/lib/apiClient.ts | 22 +++++++++++ src/studio/routes/-embedConfig.ts | 43 +++++++++++++++++++++ src/studio/routes/embed.$slug.test.ts | 44 +++++++++++++++++++++- src/studio/routes/embed.$slug.tsx | 54 ++++++++++++++++----------- 5 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 src/studio/routes/-embedConfig.ts diff --git a/src/funnel/lib/apiClient.test.ts b/src/funnel/lib/apiClient.test.ts index 0ab0d2180..2bfb8eee8 100644 --- a/src/funnel/lib/apiClient.test.ts +++ b/src/funnel/lib/apiClient.test.ts @@ -11,6 +11,7 @@ import { setProjectPrivacy, postProjectRender, PRIVATE_REQUIRES_PAID, + fetchProjectRevisionBySlug, listProjectRevisions, restoreProjectRevision, } from './apiClient'; @@ -165,6 +166,45 @@ describe('listProjectRevisions', () => { }); }); +describe('fetchProjectRevisionBySlug', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + getSessionMock.mockReset(); + getSupabaseMock.mockReturnValue({ auth: { getSession: getSessionMock } }); + getSessionMock.mockResolvedValue({ data: { session: { access_token: 'tok-1' } } }); + vi.stubEnv('VITE_API_BASE_URL', 'https://api.kernelcad.com'); + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('GETs the exact saved revision through the public project API', async () => { + const revision = { slug: 'slug-1', version: 7, code: 'cube(7);', parameters: [] }; + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => revision }); + + await expect(fetchProjectRevisionBySlug('slug-1', 7)).resolves.toEqual(revision); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.kernelcad.com/api/v1/projects/slug-1/revisions/7'); + expect(init.method).toBe('GET'); + expect(init.headers.Authorization).toBe('Bearer tok-1'); + }); + + it('url-encodes the slug before requesting the revision', async () => { + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ slug: 'a/b', version: 1, code: 'cube();', parameters: [] }) }); + + await fetchProjectRevisionBySlug('a/b', 1); + + expect(fetchMock.mock.calls[0]![0]).toBe('https://api.kernelcad.com/api/v1/projects/a%2Fb/revisions/1'); + }); +}); + describe('restoreProjectRevision', () => { const fetchMock = vi.fn(); diff --git a/src/funnel/lib/apiClient.ts b/src/funnel/lib/apiClient.ts index a3be6c0eb..43e3d75b7 100644 --- a/src/funnel/lib/apiClient.ts +++ b/src/funnel/lib/apiClient.ts @@ -157,6 +157,28 @@ export interface ProjectRevision { created_at: string; } +/** Saved source captured for one project version. */ +export interface ProjectRevisionBody { + slug: string; + version: number; + code: string; + parameters: Artifact['parameters']; +} + +/** + * Fetch an exact saved revision. Consumers that request a pinned revision must + * treat a failed read as unavailable rather than use the live project row. + */ +export async function fetchProjectRevisionBySlug( + slug: string, + version: number, +): Promise { + return authedFetch( + 'GET', + `/api/v1/projects/${encodeURIComponent(slug)}/revisions/${version}`, + ); +} + /** Newest-first list of saved server revisions for a slug-backed project. * Unwraps the `{ revisions: [...] }` envelope; returns `[]` when missing. */ export async function listProjectRevisions(slug: string): Promise { diff --git a/src/studio/routes/-embedConfig.ts b/src/studio/routes/-embedConfig.ts new file mode 100644 index 000000000..a0e979c63 --- /dev/null +++ b/src/studio/routes/-embedConfig.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors + +export type EmbedPresentation = 'viewer' | 'studio'; + +/** The plain embed stays the compatibility default; Studio is opt-in per host. */ +export function embedPresentationMode(value: unknown): EmbedPresentation { + return value === 'studio' ? 'studio' : 'viewer'; +} + +/** + * `undefined` means no requested revision (the compatible live model). `null` + * means a malformed requested revision, which the embed must fail closed. + */ +export function embedRevision(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + const raw = typeof value === 'number' ? String(value) : value; + if (typeof raw !== 'string' || !/^[1-9][0-9]*$/.test(raw)) return null; + const revision = Number(raw); + return Number.isSafeInteger(revision) ? revision : null; +} + +export interface EmbedCodeLoaders { + loadCurrent: () => Promise; + loadRevision: (revision: number) => Promise; +} + +/** + * Resolves the source code an embed is allowed to render. Explicit revisions + * never fall back to the mutable current project after a failed read. + */ +export async function loadEmbedCode( + revision: number | null | undefined, + loaders: EmbedCodeLoaders, +): Promise { + if (revision === null) return null; + if (revision === undefined) return loaders.loadCurrent(); + try { + return await loaders.loadRevision(revision); + } catch { + return null; + } +} diff --git a/src/studio/routes/embed.$slug.test.ts b/src/studio/routes/embed.$slug.test.ts index 7762d0fcd..780afea90 100644 --- a/src/studio/routes/embed.$slug.test.ts +++ b/src/studio/routes/embed.$slug.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors -import { describe, expect, it } from 'vitest'; -import { embedPresentationMode } from './embed.$slug'; +import { describe, expect, it, vi } from 'vitest'; +import { embedPresentationMode, embedRevision, loadEmbedCode } from './-embedConfig'; describe('embedPresentationMode', () => { it('keeps the default embed model-only', () => { @@ -12,4 +12,44 @@ describe('embedPresentationMode', () => { it('selects the read-only Studio shell only when requested', () => { expect(embedPresentationMode('studio')).toBe('studio'); }); + + it('distinguishes an absent revision from an invalid or pinned revision', () => { + expect(embedRevision(undefined)).toBeUndefined(); + expect(embedRevision('7')).toBe(7); + expect(embedRevision('07')).toBeNull(); + expect(embedRevision('0')).toBeNull(); + expect(embedRevision('7.5')).toBeNull(); + expect(embedRevision('latest')).toBeNull(); + }); + + it('keeps an absent revision on the compatible live-project loader', async () => { + const loadCurrent = vi.fn().mockResolvedValue('live-code'); + const loadRevision = vi.fn(); + + await expect(loadEmbedCode(undefined, { loadCurrent, loadRevision })).resolves.toBe('live-code'); + expect(loadCurrent).toHaveBeenCalledOnce(); + expect(loadRevision).not.toHaveBeenCalled(); + }); + + it('loads an explicit revision without consulting the live project', async () => { + const loadCurrent = vi.fn(); + const loadRevision = vi.fn().mockResolvedValue('pinned-code'); + + await expect(loadEmbedCode(7, { loadCurrent, loadRevision })).resolves.toBe('pinned-code'); + expect(loadRevision).toHaveBeenCalledWith(7); + expect(loadCurrent).not.toHaveBeenCalled(); + }); + + it('fails closed when an explicit revision is malformed or unavailable', async () => { + const loadCurrent = vi.fn().mockResolvedValue('live-code'); + const loadRevision = vi.fn().mockRejectedValue(new Error('not_found')); + + await expect(loadEmbedCode(null, { loadCurrent, loadRevision })).resolves.toBeNull(); + expect(loadCurrent).not.toHaveBeenCalled(); + expect(loadRevision).not.toHaveBeenCalled(); + + await expect(loadEmbedCode(7, { loadCurrent, loadRevision })).resolves.toBeNull(); + expect(loadCurrent).not.toHaveBeenCalled(); + expect(loadRevision).toHaveBeenCalledWith(7); + }); }); diff --git a/src/studio/routes/embed.$slug.tsx b/src/studio/routes/embed.$slug.tsx index c493e613a..475f391c7 100644 --- a/src/studio/routes/embed.$slug.tsx +++ b/src/studio/routes/embed.$slug.tsx @@ -19,28 +19,25 @@ import { createFileRoute } from '@tanstack/react-router'; import { useEffect, useState } from 'react'; import { FunnelViewer } from '../../funnel/components/FunnelViewer'; -import { fetchProjectBySlug, type ProjectRow } from '../../funnel/lib/apiClient'; +import { fetchProjectBySlug, fetchProjectRevisionBySlug } from '../../funnel/lib/apiClient'; import StudioApp from '../App'; import { StudioConfigProvider } from '../config/StudioConfigContext'; - -export type EmbedPresentation = 'viewer' | 'studio'; - -/** The plain embed stays the compatibility default; Studio is opt-in per host. */ -export function embedPresentationMode(value: unknown): EmbedPresentation { - return value === 'studio' ? 'studio' : 'viewer'; -} +import { embedPresentationMode, embedRevision, loadEmbedCode } from './-embedConfig'; export const Route = createFileRoute('/embed/$slug')({ validateSearch: (search: Record) => ({ mode: embedPresentationMode(search.mode), + revision: embedRevision(search.revision), }), component: EmbedPage, }); function EmbedPage() { const { slug } = Route.useParams(); - const { mode } = Route.useSearch(); - const [project, setProject] = useState(null); + const { mode, revision } = Route.useSearch(); + const sourceKey = `${slug}\u0000${revision === undefined ? 'current' : revision === null ? 'invalid' : revision}`; + const [code, setCode] = useState(null); + const [loadedSourceKey, setLoadedSourceKey] = useState(null); const [state, setState] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading'); const [err, setErr] = useState(null); @@ -48,34 +45,49 @@ function EmbedPage() { // `state` starts at 'loading' (initial useState); the fetch resolves it to // ready/missing/error. No synchronous setState in the effect body. let disposed = false; - fetchProjectBySlug(slug) - .then((p) => { + const source = loadEmbedCode(revision, { + loadCurrent: () => fetchProjectBySlug(slug).then((project) => project?.current_code ?? null), + loadRevision: (version) => fetchProjectRevisionBySlug(slug, version).then((saved) => saved.code), + }); + source + .then((sourceCode) => { if (disposed) return; - if (p) { setProject(p); setState('ready'); } - else { setState('missing'); } + if (sourceCode) { setCode(sourceCode); setLoadedSourceKey(sourceKey); setState('ready'); } + else { setLoadedSourceKey(sourceKey); setState('missing'); } }) - .catch((e) => { if (!disposed) { setErr(String(e)); setState('error'); } }); + .catch((e) => { + if (disposed) return; + // Requested release revisions fail closed: never substitute the live model + // when the revision endpoint is unavailable or refuses access. + if (revision !== undefined) { setLoadedSourceKey(sourceKey); setState('missing'); return; } + setLoadedSourceKey(sourceKey); + setErr(String(e)); + setState('error'); + }); return () => { disposed = true; }; - }, [slug]); + }, [slug, revision, sourceKey]); + + const sourceSettled = loadedSourceKey === sourceKey; - if (state === 'ready' && project) { + if (revision !== null && sourceSettled && state === 'ready' && code) { if (mode === 'studio') { return ( - + ); } return (
- +
); } const message = - state === 'error' ? `Failed to load: ${err}` - : state === 'missing' ? 'Model not available.' + revision === null || (sourceSettled && state === 'missing') ? 'Model not available.' + : !sourceSettled ? 'Loading…' + : state === 'error' ? `Failed to load: ${err}` : 'Loading…'; return (
From 3821891239e63c27be159100dec2773de60ca49c Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:04:40 +0200 Subject: [PATCH 2/3] feat(catalog): real Adafruit CAD models for 7 sensors/displays + BME280 upgrade Add colored AP214 STEP models (Adafruit_CAD_Parts, MIT) for parts that previously fell back to the generic procedural breakout box: vl53l1x-tof, veml7700-lux, sht31-humidity, scd40-co2, sgp40-voc, adxl343-accel, ili9341-tft-28 Upgrade bme280 from the authored placeholder boxes to Adafruit's real 2652 BME280 STEP (20 face colors). All measure datasheet-correct bboxes (STEMMA QT 25.4x17.78mm, ILI9341 50x69mm). Signed-off-by: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> --- scripts/electronics-parts.json | 80 +++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/scripts/electronics-parts.json b/scripts/electronics-parts.json index 7b23e4d32..8c7576248 100644 --- a/scripts/electronics-parts.json +++ b/scripts/electronics-parts.json @@ -260,13 +260,83 @@ }, { "id": "bme280", - "name": "BME280 environmental sensor breakout (19×18×3mm)", + "name": "BME280 environmental sensor breakout (Adafruit 2652)", "family": "Sensor", - "mpn": "BME280 breakout (Adafruit 2652 / SparkFun)", - "kcad_source": "scripts/parts/authored/bme280.kcad.ts", - "tags": ["sensor", "temperature", "humidity", "pressure", "bme280", "i2c", "spi"], + "mpn": "Adafruit 2652", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/2652%20Adafruit%20BME280/Adafruit%20BME280.step", + "tags": ["sensor", "temperature", "humidity", "pressure", "bme280", "i2c", "spi", "stemma"], "license": "MIT", - "attribution": "kernelCAD authored model (kernelCAD contributors)" + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT" + }, + { + "id": "vl53l1x-tof", + "name": "VL53L1X time-of-flight distance sensor (Adafruit 3967)", + "family": "Sensor", + "mpn": "Adafruit 3967", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/3967%20VL5SL1X%20TOF%20Sensor/3967%20VL5SL1X%20TOF%20Sensor.step", + "tags": ["sensor", "tof", "distance", "lidar", "vl53l1x", "i2c", "stemma"], + "license": "MIT", + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT" + }, + { + "id": "veml7700-lux", + "name": "VEML7700 ambient light / lux sensor (Adafruit 5378)", + "family": "Sensor", + "mpn": "Adafruit 5378", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/5378%20VEML7700%20Lux%20Sensor/5378%20VEML7700%20Lux%20Sensor.step", + "tags": ["sensor", "light", "lux", "ambient", "veml7700", "i2c", "stemma"], + "license": "MIT", + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT" + }, + { + "id": "sht31-humidity", + "name": "SHT31-D temperature / humidity sensor (Adafruit 2857)", + "family": "Sensor", + "mpn": "Adafruit 2857", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/2857%20SHT31-D/2857%20SHT31-D.step", + "tags": ["sensor", "temperature", "humidity", "sht31", "i2c", "stemma"], + "license": "MIT", + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT" + }, + { + "id": "scd40-co2", + "name": "SCD-40 CO2 / temp / humidity sensor (Adafruit 5187)", + "family": "Sensor", + "mpn": "Adafruit 5187", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/5187%20SCD-40%20C02%20Sensor/5187%20SCD-40%20C02%20Sensor.step", + "tags": ["sensor", "co2", "temperature", "humidity", "scd40", "scd41", "i2c", "stemma"], + "license": "MIT", + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT — SCD-40 board (same breakout as SCD41)" + }, + { + "id": "sgp40-voc", + "name": "SGP40 air-quality / VOC sensor (Adafruit 4829)", + "family": "Sensor", + "mpn": "Adafruit 4829", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/4829%20SGP40%20Sensor/4829%20SGP40%20Sensor.step", + "tags": ["sensor", "voc", "air-quality", "gas", "sgp40", "sgp41", "i2c", "stemma"], + "license": "MIT", + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT — SGP40 board (same breakout as SGP41)" + }, + { + "id": "adxl343-accel", + "name": "ADXL343 3-axis accelerometer (Adafruit 4097)", + "family": "Sensor", + "mpn": "Adafruit 4097", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/4097%20ADXL343%20Accelerometer/4097%20ADXL343.step", + "tags": ["sensor", "accelerometer", "imu", "adxl343", "adxl345", "i2c", "spi", "stemma"], + "license": "MIT", + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT — ADXL343 board (pin-compatible ADXL345 successor)" + }, + { + "id": "ili9341-tft-28", + "name": "2.8\" ILI9341 TFT touch display (Adafruit 2090)", + "family": "Display", + "mpn": "Adafruit 2090", + "url": "https://raw.githubusercontent.com/adafruit/Adafruit_CAD_Parts/main/2090%202.8in%20TFT%20Cap%20Touch/2090%202.8in%20TFT%20Cap%20Touch.step", + "tags": ["display", "tft", "lcd", "ili9341", "touch", "2.8inch", "spi"], + "license": "MIT", + "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT" }, { "id": "microsd-spi", From 94896f880cea4db64ffaf12abb254051a5433190 Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:24:52 +0200 Subject: [PATCH 3/3] feat(catalog): add real MPU-6050 (GY-521) IMU model (MIT) Real GY-521 breakout STEP from SRA-VJTI/sra-board-hardware-design (MIT), correct 15.6x20.3mm footprint. Only clean-licensed CAD found for the generic hobby-module sensors; MLX90614/PIR/HC-SR04/BH1750/NEO-6M exist on GitHub only in unlicensed repos, left as procedural fallback. Signed-off-by: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> --- scripts/electronics-parts.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/electronics-parts.json b/scripts/electronics-parts.json index 8c7576248..049c926fa 100644 --- a/scripts/electronics-parts.json +++ b/scripts/electronics-parts.json @@ -268,6 +268,16 @@ "license": "MIT", "attribution": "Adafruit_CAD_Parts (github.com/adafruit/Adafruit_CAD_Parts), MIT" }, + { + "id": "mpu6050-imu", + "name": "MPU-6050 6-axis IMU (GY-521 breakout)", + "family": "Sensor", + "mpn": "GY-521 (MPU-6050)", + "url": "https://raw.githubusercontent.com/SRA-VJTI/sra-board-hardware-design/master/sra_dev_board_2026/component_libraries/MPU/MPU6050/MPU6050%20v2.step", + "tags": ["sensor", "imu", "accelerometer", "gyroscope", "mpu6050", "gy-521", "i2c"], + "license": "MIT", + "attribution": "SRA-VJTI/sra-board-hardware-design (github.com/SRA-VJTI/sra-board-hardware-design), MIT" + }, { "id": "vl53l1x-tof", "name": "VL53L1X time-of-flight distance sensor (Adafruit 3967)",