feat: lib kind identity + chunked packages3D ingest + registry model serving

Standalone side of the collision fixes + registry 3D serving:
- remote list + boot preload map the backend's collision-safe mount
  nickname into LibInfo.name — one string everywhere KiCad-facing, so
  lib tables never mount duplicate names (first-match-wins shadowing)
- registryModelsSource beside cdnModelsSource: model3d origin libs as
  sparse sync layers (boot-preloaded index/stacks, IDB per version);
  VITE_MODELS_SOURCE=registry opts in, CDN stays default
- bumps web/pcbjam-shared (libSchema.nickname)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3C6837qtdFU84xWhFekxd
This commit is contained in:
Gergő Törcsvári 2026-08-20 09:23:46 +02:00
commit 47bd6d1a3e
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 349 additions and 12 deletions

@ -1 +1 @@
Subproject commit b2af70c0001c52aaa8a408cc3c9ed63d3eeb4806
Subproject commit 905063094caf183420f09d09e973132132481d2b

View file

@ -1909,9 +1909,13 @@ export function WasmTool({
onWasmInstantiated: () => markWasmDownloaded(meta.bundle, meta.ver),
libsSource: source,
enumerateGate,
// 3D models: lazy per-board source (null unless the CDN manifest is
// configured) — feeds the board prescan + the viewer's ensure fallback.
modelsSource: modelsSourceConfig(),
// 3D models: lazy per-board source (null unless a model backing is
// configured) — feeds the board prescan + the viewer's ensure
// fallback. Registry mode reuses the boot payload's lib listing +
// stack resolves (zero extra model requests on a preloaded boot).
modelsSource: modelsSourceConfig(
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
),
// footprint_editor/symbol_editor load the pcbnew/eeschema bundle; the
// frame token tells its single_top launcher which editor frame to open.
frame: TOOL_FRAME[tool],

View file

@ -136,7 +136,11 @@ import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import { sessionIdentity } from "@/lib/session-identity";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
import { cdnModelsSource, type Model3dSource } from "@/wasm/libs/models-source";
import {
cdnModelsSource,
registryModelsSource,
type Model3dSource,
} from "@/wasm/libs/models-source";
import { remoteLibsSource } from "@/wasm/libs/remote-source";
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
import type { LibsSource } from "@/wasm/libs/source";
@ -304,9 +308,30 @@ export const CDN_MODELS_MANIFEST_URL =
import.meta.env.VITE_MODELS_MANIFEST_URL || null;
/** The 3D model source for a tool boot (null models disabled). One instance
* per call WasmTool keeps a single instance per boot like the libs source. */
export function modelsSourceConfig(): Model3dSource | null {
return CDN_MODELS_MANIFEST_URL
* per call WasmTool keeps a single instance per boot like the libs source.
*
* `VITE_MODELS_SOURCE` picks the backing:
* - "cdn" (default whenever VITE_MODELS_MANIFEST_URL is set today's
* behavior): the published CDN blob layout.
* - "registry": the closed registry's kind='model3d' origin libs (chunked
* packages3D ingest), served as sparse sync layers; the boot payload's
* lib list + stack resolves feed it so a preloaded boot makes zero model
* listing/resolve requests.
*/
export function modelsSourceConfig(
preload?: LibsBootPreload,
): Model3dSource | null {
const mode =
import.meta.env.VITE_MODELS_SOURCE ??
(CDN_MODELS_MANIFEST_URL ? "cdn" : "off");
if (mode === "registry") {
return registryModelsSource({
apiBase: API_BASE_URL,
scope: currentScope(),
preloaded: preload,
});
}
return mode === "cdn" && CDN_MODELS_MANIFEST_URL
? cdnModelsSource(CDN_MODELS_MANIFEST_URL)
: null;
}

View file

@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { sha256Hex, type SyncManifest } from "@pcbjam/shared";
import {
sha256Hex,
type SyncManifest,
type SyncStackDescriptor,
} from "@pcbjam/shared";
import { memStore, type LayerStore } from "@pcbjam/sync-client";
import { cdnModelsSource } from "./models-source";
import { cdnModelsSource, registryModelsSource } from "./models-source";
const MANIFEST_URL = "https://cdn.test/libs/kicad-models/9.0.9/manifest.json";
const BASE = "https://cdn.test/libs/kicad-models/9.0.9";
@ -118,3 +122,128 @@ describe("cdnModelsSource", () => {
expect(cdn.counters.blobFetches).toBe(0);
});
});
const API = "https://api.test";
const LIB_ID = "lib-uuid-1";
const VERSION = "ver-uuid-1";
const ORIGIN_BASE = `${API}/api/scopes/s/libs/origins/${LIB_ID}/${VERSION}`;
/** The registry's serving shape: sparse origin layer over the public routes. */
async function fakeRegistry() {
const bodies: Record<string, Uint8Array> = {
"model3d/Clip.step": enc.encode("STEP Clip"),
"model3d/Clip.wrl": enc.encode("#VRML Clip"),
};
const entries: SyncManifest["entries"] = {};
for (const [path, body] of Object.entries(bodies)) {
entries[path] = { hash: await sha256Hex(body), size: body.length, mtime: 0 };
}
const manifest: SyncManifest = { version: 1, entries };
const descriptor: SyncStackDescriptor = {
lib: { id: LIB_ID, name: "Battery" },
layers: [
{
namespace: `origin:${LIB_ID}@${VERSION}`,
kind: "sparse",
url: ORIGIN_BASE,
bodyUrlTemplate: `${ORIGIN_BASE}/body/{path}`,
},
],
};
let listFetches = 0;
let resolveFetches = 0;
let bodyFetches = 0;
const json = (obj: unknown) => ({ ok: true, json: async () => obj });
const fetchImpl = (async (url: string, init?: RequestInit) => {
if (url === `${API}/api/scopes/s/libs?kind=model3d`) {
listFetches += 1;
return json([{ id: LIB_ID, name: "Battery" }]);
}
if (url === `${API}/api/scopes/s/libs/sync-stacks`) {
resolveFetches += 1;
const { libIds } = JSON.parse(String(init?.body)) as { libIds: string[] };
return json({
stacks: Object.fromEntries(
libIds.map((id) => [id, id === LIB_ID ? descriptor : null]),
),
});
}
if (url === `${ORIGIN_BASE}/manifest`) return json(manifest);
const m = url.match(new RegExp(`^${ORIGIN_BASE}/body/(.+)$`));
if (m) {
const body = bodies[decodeURIComponent(m[1]!)];
if (body) {
bodyFetches += 1;
return { ok: true, arrayBuffer: async () => body.buffer };
}
}
return { ok: false, status: 404 };
}) as unknown as typeof fetch;
return {
fetchImpl,
descriptor,
counters: {
get listFetches() {
return listFetches;
},
get resolveFetches() {
return resolveFetches;
},
get bodyFetches() {
return bodyFetches;
},
},
};
}
describe("registryModelsSource", () => {
it("preloaded boot: zero listing/resolve requests, bodies fetched sparsely", async () => {
const reg = await fakeRegistry();
const src = registryModelsSource(
{
apiBase: API,
scope: "s",
preloaded: {
libs: [
{ id: LIB_ID, name: "Battery", kindCounts: { model3d: 2 } },
// A footprint lib of the SAME name must be ignored by the model
// index (kind is part of origin identity — near-1:1 name overlap).
{ id: "lib-fp", name: "Battery", kindCounts: { footprint: 9 } },
],
stacks: { [LIB_ID]: reg.descriptor },
},
},
{ fetchImpl: reg.fetchImpl, storeFactory: storeMap() },
);
const body = await src.getModelBody("Battery.3dshapes/Clip.step");
expect(dec.decode(body!)).toBe("STEP Clip");
expect(reg.counters.listFetches).toBe(0);
expect(reg.counters.resolveFetches).toBe(0);
expect(reg.counters.bodyFetches).toBe(1); // only the asked-for model
await src.getModelBody("Battery.3dshapes/Clip.step");
expect(reg.counters.bodyFetches).toBe(1); // cached in the layer store
expect(await src.hasModel("Battery.3dshapes/Clip.wrl")).toBe(true);
expect(reg.counters.bodyFetches).toBe(1); // manifest-only answer
});
it("no preload: lists model3d libs and batch-resolves the stack, once each", async () => {
const reg = await fakeRegistry();
const src = registryModelsSource(
{ apiBase: API, scope: "s" },
{ fetchImpl: reg.fetchImpl, storeFactory: storeMap() },
);
const body = await src.getModelBody("Battery.3dshapes/Clip.wrl");
expect(dec.decode(body!)).toBe("#VRML Clip");
await src.getModelBody("Battery.3dshapes/Clip.step");
expect(reg.counters.listFetches).toBe(1);
expect(reg.counters.resolveFetches).toBe(1);
expect(await src.getModelBody("NoSuchLib.3dshapes/m.wrl")).toBeNull();
expect(await src.getModelBody("not-a-model-ref")).toBeNull();
});
});

View file

@ -1,4 +1,8 @@
import { SyncStack, type SyncStackOptions } from "@pcbjam/sync-client";
import {
fetchSyncStacks,
type SyncStackDescriptor,
} from "@pcbjam/shared";
/**
* Read-only source of 3D model bodies (`.wrl` / `.step`), addressed by the
@ -122,3 +126,131 @@ export function cdnModelsSource(
},
};
}
/** The subset of the boot payload registryModelsSource consumes. */
export interface RegistryModelsPreload {
libs: Array<{
id: string;
name: string;
kindCounts?: Record<string, number>;
}>;
stacks: Record<string, SyncStackDescriptor | null>;
}
/**
* Registry-backed `Model3dSource`: the official 3D models as first-class
* kind='model3d' origin libs served by the closed registry (chunked
* packages3D ingest). The backend's sync-stack for a model lib is a single
* read-only SPARSE layer over the public origin routes exactly the client
* machinery {@link cdnModelsSource} uses, so bodies fetch lazily per board
* reference and cache in IDB under `origin:<libId>@<versionId>` (per-version
* persistence for free).
*
* Model refs address libs by NAME (`(model "Battery.3dshapes/…")`): the index
* comes from the boot payload's lib list (zero extra requests) filtered to
* model3d, with a `GET /libs?kind=model3d` fallback for boots without the
* preload; stacks come from the preloaded batch resolve, falling back to the
* batch endpoint per lib.
*/
export function registryModelsSource(
cfg: {
apiBase: string;
scope: string;
preloaded?: RegistryModelsPreload;
},
opts?: Pick<SyncStackOptions, "fetchImpl" | "storeFactory">,
): Model3dSource {
const fetchImpl = opts?.fetchImpl ?? fetch;
const enc = encodeURIComponent;
let indexP: Promise<Map<string, string>> | null = null;
const loadIndex = (): Promise<Map<string, string>> => {
if (!indexP) {
// Never cache a rejection (cdn-source precedent): a transient failure
// must not poison every later model read.
indexP = (async () => {
if (cfg.preloaded) {
return new Map(
cfg.preloaded.libs
.filter((l) => (l.kindCounts?.["model3d"] ?? 0) > 0)
.map((l) => [l.name, l.id]),
);
}
const r = await fetchImpl(
`${cfg.apiBase}/api/scopes/${enc(cfg.scope)}/libs?kind=model3d`,
{ credentials: "include" } as RequestInit,
);
if (!r.ok) throw new Error(`model3d lib listing ${r.status}`);
const libs = (await r.json()) as Array<{ id: string; name: string }>;
return new Map(libs.map((l) => [l.name, l.id]));
})().catch((e) => {
indexP = null;
throw e;
});
}
return indexP;
};
const stackDescs = new Map<string, SyncStackDescriptor | null>(
Object.entries(cfg.preloaded?.stacks ?? {}),
);
const descFor = async (
libId: string,
): Promise<SyncStackDescriptor | null> => {
if (stackDescs.has(libId)) return stackDescs.get(libId) ?? null;
const resolved = await fetchSyncStacks({
url: `${cfg.apiBase}/api/scopes/${enc(cfg.scope)}/libs/sync-stacks`,
libIds: [libId],
fetchImpl,
});
const desc = resolved.get(libId) ?? null;
stackDescs.set(libId, desc);
return desc;
};
// One lazily-opened stack per lib NAME (what model refs address).
const stacks = new Map<string, Promise<SyncStack | null>>();
const openStack = (libName: string): Promise<SyncStack | null> => {
let p = stacks.get(libName);
if (!p) {
p = (async () => {
const libId = (await loadIndex()).get(libName);
if (!libId) return null; // unknown lib
const desc = await descFor(libId);
if (!desc || desc.layers.length === 0) return null;
const stack = new SyncStack({ layers: desc.layers, ...opts });
await stack.open();
return stack;
})().catch((e) => {
stacks.delete(libName); // a failed open must stay retryable
throw e;
});
stacks.set(libName, p);
}
return p;
};
return {
async getModelBody(ref: string): Promise<Uint8Array | null> {
const split = splitRef(ref);
if (!split) return null;
try {
const stack = await openStack(split.lib);
return stack ? await stack.read(split.path) : null;
} catch {
return null; // missing models render as absent, never break the viewer
}
},
async hasModel(ref: string): Promise<boolean> {
const split = splitRef(ref);
if (!split) return false;
try {
const stack = await openStack(split.lib);
if (!stack) return false;
return (await stack.list()).some((e) => e.path === split.path);
} catch {
return false;
}
},
};
}

View file

@ -44,7 +44,13 @@ export function remoteLibsSource(
if (res.status !== 200) return [];
return res.body.map((l) => ({
id: l.id,
name: l.name,
// The backend's collision-safe MOUNT nickname is the one string that
// flows everywhere KiCad-facing (lib tables, reload/add-entry calls,
// toasts): mapping it into `name` at this boundary keeps every
// consumer on the same identifier. Two visible same-name libs would
// otherwise emit duplicate lib-table rows, and KiCad resolves
// nicknames first-match-wins — the loser is silently shadowed.
name: l.nickname ?? l.name,
description: l.description ?? null,
type: l.type,
itemCount: l.itemCount,

View file

@ -379,6 +379,42 @@ describe("boot preload (load-path-rework 0001 §6)", () => {
expect(ok).toBe(true);
source.dispose?.();
});
it("prefers the backend's collision-safe mount nickname over the raw name", async () => {
const server = await fakeServer({ "symbol/R": "(r)" });
const source = syncedScopeLibsSource({} as unknown as LibsSource, {
apiBase: API,
scope: "s",
user: "u",
fetchImpl: server.fetchImpl,
storeFactory: () => memStore(),
channelFactory: () => server.channel,
preloaded: {
libs: [
// A pinned community lib colliding with a kicad origin: the backend
// deduped it; the client must mount the deduped nickname (KiCad
// resolves lib-table nicknames first-match-wins — a duplicate row
// would be silently shadowed).
{
id: "lib-pin",
name: "Device",
nickname: "Device--wurth-kicad",
type: "origin",
kindCounts: { symbol: 3 },
},
// No collision ⇒ nickname absent ⇒ raw name.
{ id: "lib-kicad", name: "Device", type: "origin", kindCounts: { symbol: 9 } },
],
stacks: {},
},
});
const libs = await source.listLibs!("symbol");
expect(libs.find((l) => l.id === "lib-pin")!.name).toBe(
"Device--wurth-kicad",
);
expect(libs.find((l) => l.id === "lib-kicad")!.name).toBe("Device");
source.dispose?.();
});
});
describe("syncedScopeLibsSource.syncState", () => {

View file

@ -341,6 +341,9 @@ function splitPath(path: string): LibItemInfo {
export interface PreloadedLibDto {
id: string;
name: string;
/** Collision-safe MOUNT nickname the backend assigned (see libSchema)
* preferred over `name` for everything KiCad-facing. */
nickname?: string;
description?: string | null;
type: string;
itemCount?: number;
@ -415,7 +418,9 @@ export function syncedScopeLibsSource(
)
.map((l) => ({
id: l.id,
name: l.name,
// Same boundary rule as remote-source.listLibs: the backend's
// collision-safe mount nickname IS the lib's name from here on.
name: l.nickname ?? l.name,
description: l.description ?? null,
type: l.type,
itemCount: l.itemCount,