feat(3d): live models CDN — 10.0.3 publish wiring + wrl→step fallback

- kicad-packages3D is STEP-only from the 10.x tags: ensureModelInMemfs
  falls back from a missing .wrl to the same-stem .step, written and
  answered under the .step path (the returned path's extension picks
  the parsing plugin — no C++ awareness). Verified against the live
  CDN with the gallery demo board (4/4 wrl refs served as step).
- upload-models-r2.sh: rclone/S3 bulk upload of a local publish layout
  (wrangler-per-object can't move ~14k blobs); published 10.0.3 —
  105 libs / 7,238 models, 3.4GB raw → 500MB brotli — to pcbjam-cdn.
- deploy-demo.yml MODELS_TAG=10.0.3 → build-demo --models-tag →
  VITE_MODELS_MANIFEST_URL (matches LIB_TAG: model refs come from the
  footprints at that release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AT7gVHRktDYoQ68S4x6A4
This commit is contained in:
Gergő Törcsvári 2026-07-02 11:28:52 +02:00
commit 4330c9344e
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 177 additions and 8 deletions

View file

@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { normalizeModelRef, scanModelRefs } from "./models-bridge";
import {
ensureModelInMemfs,
installModel3dHandler,
normalizeModelRef,
scanModelRefs,
} from "./models-bridge";
import type { Model3dSource } from "./models-source";
describe("normalizeModelRef", () => {
it("strips any vintage of the model-dir var", () => {
@ -32,6 +38,49 @@ describe("normalizeModelRef", () => {
});
});
describe("ensureModelInMemfs format fallback", () => {
function installFakes(available: (ref: string) => boolean) {
const files = new Map<string, Uint8Array>();
const fs = {
mkdirTree: () => {},
writeFile: (p: string, b: Uint8Array) => void files.set(p, b),
analyzePath: (p: string) => ({ exists: files.has(p) }),
};
(globalThis as unknown as { window: unknown }).window ??= globalThis;
(globalThis as unknown as { FS: unknown }).FS = fs;
const source: Model3dSource = {
getModelBody: async (ref) =>
available(ref) ? new TextEncoder().encode(`body:${ref}`) : null,
hasModel: async (ref) => available(ref),
};
installModel3dHandler(source, () => {});
return files;
}
it("serves a .wrl ask from the same-stem .step, under the .step path", async () => {
// kicad-packages3D is STEP-only from 10.x — old boards still ask for .wrl.
const files = installFakes((r) => r.endsWith(".step"));
const dest = await ensureModelInMemfs("FallbackLibA.3dshapes/M1.wrl");
// Returned (and written) under the SUBSTITUTED extension: the path picks
// the parsing plugin, so the .step body must dispatch to oce, not vrml.
expect(dest).toBe("/pcbjam/3dmodels/FallbackLibA.3dshapes/M1.step");
expect(files.has("/pcbjam/3dmodels/FallbackLibA.3dshapes/M1.step")).toBe(true);
expect(files.has("/pcbjam/3dmodels/FallbackLibA.3dshapes/M1.wrl")).toBe(false);
});
it("prefers the exact ref when it exists", async () => {
const files = installFakes(() => true);
const dest = await ensureModelInMemfs("FallbackLibB.3dshapes/M2.wrl");
expect(dest).toBe("/pcbjam/3dmodels/FallbackLibB.3dshapes/M2.wrl");
expect(files.has("/pcbjam/3dmodels/FallbackLibB.3dshapes/M2.wrl")).toBe(true);
});
it("resolves null when no format of the model exists", async () => {
installFakes(() => false);
expect(await ensureModelInMemfs("FallbackLibC.3dshapes/M3.wrl")).toBeNull();
});
});
describe("scanModelRefs", () => {
it("finds, normalizes and dedupes board model refs", () => {
const board = `

View file

@ -102,6 +102,29 @@ export async function ensureModelInMemfs(ref: string): Promise<string | null> {
return p;
}
/**
* Format fallback: kicad-packages3D dropped `.wrl` at the 10.x generation
* (STEP-only), but boards authored with KiCad ≤9 still reference `.wrl`. Try
* the exact ref, then the same stem in the surviving formats. The substituted
* file is written (and returned) under ITS OWN extension — the returned path's
* extension is what picks the parsing plugin, so a `.wrl` ask served by a
* `.step` body dispatches to oce, not vrml. No C++ involvement.
*/
const FALLBACK_EXTS: Record<string, string[]> = {
".wrl": [".step", ".stp"],
".wrz": [".step", ".stp"],
".step": [".wrl"],
".stp": [".wrl"],
};
function refCandidates(ref: string): string[] {
const dot = ref.lastIndexOf(".");
if (dot < 0) return [ref];
const ext = ref.slice(dot).toLowerCase();
const stem = ref.slice(0, dot);
return [ref, ...(FALLBACK_EXTS[ext] ?? []).map((e) => `${stem}${e}`)];
}
async function doEnsure(ref: string, dest: string): Promise<string | null> {
const source = installedSource;
const fs = toolFS();
@ -110,13 +133,19 @@ async function doEnsure(ref: string, dest: string): Promise<string | null> {
written.add(ref);
return dest;
}
const body = await source.getModelBody(ref);
if (!body) return null;
fs.mkdirTree(dest.slice(0, dest.lastIndexOf("/")));
fs.writeFile(dest, body);
written.add(ref);
installedLog(`[3d] materialized ${ref} (${body.length} bytes)`);
return dest;
for (const candidate of refCandidates(ref)) {
const body = await source.getModelBody(candidate);
if (!body) continue;
const target = `${MODELS_3D_ROOT}/${candidate}`;
fs.mkdirTree(target.slice(0, target.lastIndexOf("/")));
fs.writeFile(target, body);
written.add(ref);
installedLog(
`[3d] materialized ${candidate}${candidate === ref ? "" : ` (for ${ref})`} (${body.length} bytes)`,
);
return target;
}
return null;
}
/**