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:
parent
75bb0d7661
commit
47bd6d1a3e
8 changed files with 349 additions and 12 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit b2af70c0001c52aaa8a408cc3c9ed63d3eeb4806
|
||||
Subproject commit 905063094caf183420f09d09e973132132481d2b
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue