@jgengine/assets 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @jgengine/assets
2
+
3
+ A self-generating, license-verified index of thousands of CC0 3D models — hosted at **zero cost**. No GLB bytes ship in the npm tarball; every byte comes from infrastructure you don't pay for:
4
+
5
+ | What | Where it lives | Whose bandwidth |
6
+ |------|----------------|-----------------|
7
+ | Pack GLBs (Kenney, Quaternius, KayKit…) | Fetched at `pull` time from the provider's CDN | Provider's |
8
+ | The generated index (JSON) | Inside this npm package | npm (KB) |
9
+ | Your one-off models | `packages/assets/local/`, served via jsDelivr-over-GitHub | GitHub + jsDelivr |
10
+ | A consumer's downloaded bytes | Their `public/models/` (gitignored) | The consumer's |
11
+
12
+ ## Layers
13
+
14
+ 1. **Sources** (`src/sources/*.ts`) — one entry per downloadable pack. Every entry carries required `license` + `author` and a `download` that is either a pinned `{ url, sha256? }` or a `{ scrape }` marker.
15
+ 2. **Generated index** (`src/generated/*.json`) — machine-produced from the real `.glb` filenames after a pack is extracted. Never hand-typed. Committed JSON; bytes are not.
16
+ 3. **Aliases** (`src/aliases.ts`) — hand-authored semantic keys (`nature/tree_pine → kenney-nature/tree_pineDefaultA`).
17
+ 4. **Singles** (`src/singles.json`) — long-tail one-offs (per-model `url` + `license`).
18
+
19
+ Everything collapses into the core `AssetCatalog` via `buildCatalog({ basePath })`.
20
+
21
+ ## CLI
22
+
23
+ ```
24
+ assets list [--category <c>] [--source <s>] # browse the generated index
25
+ assets search <term> # grep the index
26
+ assets pull <source-id> [--dir public] # download + extract GLBs into <dir>/models/<source>/
27
+ assets add <path|url> --category <c> --license <l> [--author <a>]
28
+ assets reindex [public/models] # regenerate generated/*.json from pulled packs
29
+ assets verify # license + alias-integrity gate
30
+ ```
31
+
32
+ Run in-repo with `bun run --cwd packages/assets src/cli/pull.ts <verb> …`.
33
+
34
+ ## Adding assets
35
+
36
+ **A whole new pack** — add one entry to the matching `src/sources/*.ts` (this is the layer contributors PR). No filenames are hand-typed; `reindex` reads the real `.glb` names out of the extracted pack, so entries can't silently 404.
37
+
38
+ ```ts
39
+ // src/sources/kenney.ts → KENNEY_PACKS
40
+ { id: "kenney-food", slug: "food-kit", title: "Food Kit", categories: ["food", "prop"] },
41
+ ```
42
+
43
+ ```bash
44
+ bun src/cli/pull.ts pull kenney-food --dir ../../apps/dev/public # fetch + extract GLBs
45
+ bun src/cli/pull.ts reindex ../../apps/dev/public/models # regenerate generated/*.json + barrel
46
+ bun src/cli/pull.ts verify # license + alias gate
47
+ ```
48
+
49
+ A **new provider** is just a new `src/sources/<provider>.ts` added to the `sources` array in `src/sources/index.ts`. Pinned providers use `download: { url, sha256? }`; providers that rotate URLs (Kenney) use `download: { scrape: <page> }`.
50
+
51
+ **A single one-off** — no code edit, zero bytes stored (URL) or copied into `local/` (path):
52
+
53
+ ```bash
54
+ assets add "https://poly.pizza/…/model.glb" --category prop --license CC0-1.0 --author "Some Author"
55
+ ```
56
+
57
+ ## Importing in code
58
+
59
+ Build the catalog once, then address ids (or aliases) — resolve returns `{ url }`:
60
+
61
+ ```ts
62
+ import { buildCatalog } from "@jgengine/assets";
63
+
64
+ const catalog = buildCatalog({ basePath: "/models" });
65
+
66
+ catalog.resolve("kenney-nature/tree_pineDefaultA"); // { url: "/models/kenney-nature/tree_pineDefaultA.glb" }
67
+ catalog.resolve("nature/tree_pine"); // alias → same url
68
+
69
+ // wire ids straight into a game's model seams:
70
+ export const game: PlayableGame = {
71
+ // …
72
+ objectModels: {
73
+ "kenney-nature/tree_pineDefaultA": { url: catalog.resolve("kenney-nature/tree_pineDefaultA")!.url, scale: 1.4 },
74
+ },
75
+ entityModels: {
76
+ hero: { url: catalog.resolve("kenney-space/astronautA")!.url, scale: 1.1 },
77
+ },
78
+ };
79
+ ```
80
+
81
+ `buildCatalog({ sources: ["kenney-nature"] })` restricts to chosen packs; `includeAliases` / `includeSingles` default true. Discover ids with `assets search <term>` / `assets list --category <c>` instead of memorizing them. See `packages/games/asset-showcase` for a full working example.
82
+
83
+ ### Serving the bytes
84
+
85
+ A resolved id only yields a **URL** — the GLB must be somewhere your app serves. For a game, `pull` the packs into your app's `public/`, and line `basePath` up with it:
86
+
87
+ ```bash
88
+ bun src/cli/pull.ts pull kenney-nature --dir ../../apps/dev/public # → apps/dev/public/models/kenney-nature/*.glb
89
+ ```
90
+
91
+ `buildCatalog({ basePath: "/models" })` then resolves to `/models/kenney-nature/…`, which the dev server serves from `public/models/` (gitignored — the bytes are the consumer's, fetched once from the provider's CDN).
92
+
93
+ ## Notes
94
+
95
+ - Kenney rotates download URLs, so its sources `scrape` the asset page at pull time.
96
+ - Quaternius / KayKit pages gate downloads behind JS; automated `pull` falls back to a clear error when no archive link is found — download those manually into the staging dir, then `reindex`.
@@ -0,0 +1,2 @@
1
+ import type { AssetAlias } from "./manifest.js";
2
+ export declare const aliases: readonly AssetAlias[];
@@ -0,0 +1,11 @@
1
+ export const aliases = [
2
+ { key: "nature/tree_pine", target: "kenney-nature/tree_pineDefaultA" },
3
+ { key: "nature/tree", target: "kenney-nature/tree_blocks" },
4
+ { key: "nature/rock", target: "kenney-nature/cliff_block_rock" },
5
+ { key: "nature/mushroom", target: "kenney-nature/mushroom_red" },
6
+ { key: "nature/flower", target: "kenney-nature/flower_purpleA" },
7
+ { key: "weapon/blaster", target: "kenney-blaster/blaster-a" },
8
+ { key: "space/astronaut", target: "kenney-space/astronautA" },
9
+ { key: "space/ship", target: "kenney-space/craft_racer" },
10
+ { key: "space/rocket", target: "kenney-space/rocket_baseA" },
11
+ ];
@@ -0,0 +1,12 @@
1
+ import { type AssetCatalog } from "@jgengine/core/scene/assetCatalog";
2
+ import type { IndexEntry } from "../manifest.js";
3
+ export interface BuildCatalogOptions {
4
+ /** URL prefix where pulled pack GLBs live (consumer's `public/models`). */
5
+ basePath?: string;
6
+ /** Restrict pack entries to these source ids; omit for all. */
7
+ sources?: readonly string[];
8
+ includeAliases?: boolean;
9
+ includeSingles?: boolean;
10
+ }
11
+ export declare function entryUrl(basePath: string, entry: IndexEntry): string;
12
+ export declare function buildCatalog(options?: BuildCatalogOptions): AssetCatalog;
@@ -0,0 +1,37 @@
1
+ import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog";
2
+ import { aliases } from "../aliases.js";
3
+ import { generatedIndex } from "../generated/index.js";
4
+ import { singles } from "../singles.js";
5
+ export function entryUrl(basePath, entry) {
6
+ return `${basePath.replace(/\/+$/, "")}/${entry.source}/${entry.file}`;
7
+ }
8
+ export function buildCatalog(options = {}) {
9
+ const basePath = options.basePath ?? "/models";
10
+ const includeAliases = options.includeAliases ?? true;
11
+ const includeSingles = options.includeSingles ?? true;
12
+ const sourceFilter = options.sources === undefined ? null : new Set(options.sources);
13
+ const catalog = createAssetCatalog();
14
+ const urlById = new Map();
15
+ for (const entry of generatedIndex) {
16
+ if (sourceFilter !== null && !sourceFilter.has(entry.source))
17
+ continue;
18
+ const url = entryUrl(basePath, entry);
19
+ urlById.set(entry.id, url);
20
+ catalog.register(entry.id, { url });
21
+ }
22
+ if (includeSingles) {
23
+ for (const single of singles) {
24
+ urlById.set(single.id, single.url);
25
+ catalog.register(single.id, { url: single.url });
26
+ }
27
+ }
28
+ if (includeAliases) {
29
+ for (const alias of aliases) {
30
+ const url = urlById.get(alias.target);
31
+ if (url === undefined)
32
+ continue;
33
+ catalog.register(alias.key, { url });
34
+ }
35
+ }
36
+ return catalog;
37
+ }
@@ -0,0 +1,2 @@
1
+ import { type BuildCatalogOptions } from "./build.js";
2
+ export declare function createStarterCatalog(options?: BuildCatalogOptions): import("@jgengine/core/scene/assetCatalog").AssetCatalog<import("@jgengine/core/scene/assetCatalog").ModelAssetRef>;
@@ -0,0 +1,4 @@
1
+ import { buildCatalog } from "./build.js";
2
+ export function createStarterCatalog(options = {}) {
3
+ return buildCatalog(options);
4
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { downloadArchive, extractGlbs, resolveArchiveUrl, sha256Hex } from "../download.js";
6
+ import { generatedIndex } from "../generated/index.js";
7
+ import { reindex } from "../indexGen.js";
8
+ import { isScrapeDownload } from "../manifest.js";
9
+ import { sourceById } from "../sources/index.js";
10
+ import { verifyManifest } from "../verify.js";
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const pkgRoot = resolve(here, "..", "..");
13
+ const srcDir = join(pkgRoot, "src");
14
+ const generatedDir = join(srcDir, "generated");
15
+ const singlesJson = join(srcDir, "singles.json");
16
+ const localDir = join(pkgRoot, "local");
17
+ const CDN_BASE = "https://cdn.jsdelivr.net/gh/Noisemaker111/jgengine@main/packages/assets/local";
18
+ function flag(argv, name) {
19
+ const index = argv.indexOf(`--${name}`);
20
+ return index >= 0 ? argv[index + 1] : undefined;
21
+ }
22
+ function fail(message) {
23
+ console.error(`error: ${message}`);
24
+ process.exit(1);
25
+ }
26
+ function cmdList(argv) {
27
+ const category = flag(argv, "category");
28
+ const source = flag(argv, "source");
29
+ const limit = Number(flag(argv, "limit") ?? "50");
30
+ const rows = generatedIndex.filter((entry) => (category === undefined || entry.categories.includes(category)) &&
31
+ (source === undefined || entry.source === source));
32
+ for (const entry of rows.slice(0, limit)) {
33
+ console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.file}`);
34
+ }
35
+ console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} entries`);
36
+ }
37
+ function cmdSearch(argv) {
38
+ const term = argv[0];
39
+ if (term === undefined)
40
+ fail("usage: search <term>");
41
+ const needle = term.toLowerCase();
42
+ const limit = Number(flag(argv, "limit") ?? "50");
43
+ const rows = generatedIndex.filter((entry) => entry.id.toLowerCase().includes(needle) || entry.file.toLowerCase().includes(needle));
44
+ for (const entry of rows.slice(0, limit)) {
45
+ console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.file}`);
46
+ }
47
+ console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} matches for "${term}"`);
48
+ }
49
+ async function cmdPull(argv) {
50
+ const sourceId = argv[0];
51
+ if (sourceId === undefined)
52
+ fail("usage: pull <source-id> [--dir <dir>]");
53
+ const source = sourceById.get(sourceId);
54
+ if (source === undefined)
55
+ fail(`unknown source: ${sourceId}`);
56
+ const outRoot = resolve(flag(argv, "dir") ?? "public");
57
+ const outDir = join(outRoot, "models", sourceId);
58
+ console.log(`resolving ${sourceId}${isScrapeDownload(source.download) ? " (scrape)" : ""}…`);
59
+ const url = await resolveArchiveUrl(source);
60
+ console.log(`downloading ${url}`);
61
+ const archive = await downloadArchive(url);
62
+ if (!isScrapeDownload(source.download) && source.download.sha256 !== undefined) {
63
+ const actual = await sha256Hex(archive);
64
+ if (actual !== source.download.sha256) {
65
+ fail(`sha256 mismatch for ${sourceId}: expected ${source.download.sha256}, got ${actual}`);
66
+ }
67
+ }
68
+ const glbs = extractGlbs(archive);
69
+ if (glbs.length === 0)
70
+ fail(`no .glb files found in ${sourceId} archive`);
71
+ mkdirSync(outDir, { recursive: true });
72
+ for (const glb of glbs)
73
+ writeFileSync(join(outDir, glb.file), glb.bytes);
74
+ console.log(`pulled ${glbs.length} models -> ${outDir}`);
75
+ }
76
+ function readSingles() {
77
+ if (!existsSync(singlesJson))
78
+ return [];
79
+ return JSON.parse(readFileSync(singlesJson, "utf8"));
80
+ }
81
+ function writeSingles(list) {
82
+ writeFileSync(singlesJson, `${JSON.stringify(list, null, 2)}\n`);
83
+ }
84
+ function cmdAdd(argv) {
85
+ const target = argv[0];
86
+ if (target === undefined)
87
+ fail("usage: add <path|url> --category <c> --license <l> [--author <a>] [--id <id>]");
88
+ const license = flag(argv, "license");
89
+ if (license === undefined)
90
+ fail("add requires --license");
91
+ const author = flag(argv, "author") ?? "Unknown";
92
+ const categories = (flag(argv, "categories") ?? flag(argv, "category") ?? "prop")
93
+ .split(",")
94
+ .map((value) => value.trim())
95
+ .filter((value) => value.length > 0);
96
+ const isUrl = /^https?:\/\//i.test(target);
97
+ let url;
98
+ let id;
99
+ if (isUrl) {
100
+ url = target;
101
+ id = flag(argv, "id") ?? `single/${basename(new URL(target).pathname).replace(/\.glb$/i, "")}`;
102
+ }
103
+ else {
104
+ const abs = resolve(target);
105
+ if (!existsSync(abs))
106
+ fail(`local file not found: ${abs}`);
107
+ mkdirSync(localDir, { recursive: true });
108
+ const file = basename(abs);
109
+ copyFileSync(abs, join(localDir, file));
110
+ url = `${CDN_BASE}/${file}`;
111
+ id = flag(argv, "id") ?? `single/${file.replace(/\.glb$/i, "")}`;
112
+ }
113
+ const singles = readSingles();
114
+ if (singles.some((single) => single.id === id))
115
+ fail(`single already exists: ${id}`);
116
+ singles.push({ id, url, license, author, categories });
117
+ writeSingles(singles);
118
+ console.log(`added single ${id} -> ${url}${isUrl ? " (0 bytes stored)" : " (copied to local/)"}`);
119
+ }
120
+ function cmdReindex(argv) {
121
+ const modelsDir = resolve(argv[0] ?? join("public", "models"));
122
+ if (!existsSync(modelsDir))
123
+ fail(`models dir not found: ${modelsDir}`);
124
+ const result = reindex(modelsDir, generatedDir);
125
+ for (const row of result.perSource)
126
+ console.log(` ${row.source}: ${row.count}`);
127
+ console.log(`reindexed ${result.total} entries -> ${generatedDir}`);
128
+ }
129
+ function cmdVerify() {
130
+ const result = verifyManifest();
131
+ if (result.ok) {
132
+ console.log("verify: ok");
133
+ return;
134
+ }
135
+ for (const error of result.errors)
136
+ console.error(` ✗ ${error}`);
137
+ fail(`verify failed with ${result.errors.length} problem(s)`);
138
+ }
139
+ const [command, ...rest] = process.argv.slice(2);
140
+ switch (command) {
141
+ case "list":
142
+ cmdList(rest);
143
+ break;
144
+ case "search":
145
+ cmdSearch(rest);
146
+ break;
147
+ case "pull":
148
+ await cmdPull(rest);
149
+ break;
150
+ case "add":
151
+ cmdAdd(rest);
152
+ break;
153
+ case "reindex":
154
+ cmdReindex(rest);
155
+ break;
156
+ case "verify":
157
+ cmdVerify();
158
+ break;
159
+ default:
160
+ console.log("usage: assets <list|search|pull|add|reindex|verify> [...args]");
161
+ if (command !== undefined && command !== "help")
162
+ process.exit(1);
163
+ }
@@ -0,0 +1,10 @@
1
+ import { type AssetSource } from "./manifest.js";
2
+ export interface ExtractedGlb {
3
+ file: string;
4
+ bytes: Uint8Array;
5
+ }
6
+ export declare function findArchiveUrl(html: string, pageUrl: string): string | null;
7
+ export declare function resolveArchiveUrl(source: AssetSource): Promise<string>;
8
+ export declare function downloadArchive(url: string): Promise<Uint8Array>;
9
+ export declare function extractGlbs(archive: Uint8Array): ExtractedGlb[];
10
+ export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
@@ -0,0 +1,68 @@
1
+ import { unzipSync } from "fflate";
2
+ import { isScrapeDownload } from "./manifest.js";
3
+ function resolveRelative(path, pageUrl) {
4
+ try {
5
+ return new URL(path, pageUrl).toString();
6
+ }
7
+ catch {
8
+ return path;
9
+ }
10
+ }
11
+ export function findArchiveUrl(html, pageUrl) {
12
+ const matches = html.match(/[^\s"'()<>]+\.zip/gi);
13
+ if (matches === null)
14
+ return null;
15
+ const ranked = matches.sort((a, b) => score(b) - score(a));
16
+ const best = ranked[0];
17
+ return best === undefined ? null : resolveRelative(best, pageUrl);
18
+ }
19
+ function score(candidate) {
20
+ let value = 0;
21
+ if (candidate.includes("/media/"))
22
+ value += 2;
23
+ if (candidate.includes("download"))
24
+ value += 1;
25
+ if (candidate.startsWith("http") || candidate.startsWith("/"))
26
+ value += 1;
27
+ return value;
28
+ }
29
+ export async function resolveArchiveUrl(source) {
30
+ const download = source.download;
31
+ if (!isScrapeDownload(download))
32
+ return download.url;
33
+ const response = await fetch(download.scrape, { redirect: "follow" });
34
+ if (!response.ok)
35
+ throw new Error(`scrape ${download.scrape} -> HTTP ${response.status}`);
36
+ const html = await response.text();
37
+ const url = findArchiveUrl(html, download.scrape);
38
+ if (url === null) {
39
+ throw new Error(`no downloadable .zip found at ${download.scrape} (provider may require manual download)`);
40
+ }
41
+ return url;
42
+ }
43
+ export async function downloadArchive(url) {
44
+ const response = await fetch(url, { redirect: "follow" });
45
+ if (!response.ok)
46
+ throw new Error(`download ${url} -> HTTP ${response.status}`);
47
+ return new Uint8Array(await response.arrayBuffer());
48
+ }
49
+ export function extractGlbs(archive) {
50
+ const entries = unzipSync(archive, {
51
+ filter: (file) => /\.glb$/i.test(file.name),
52
+ });
53
+ const byName = new Map();
54
+ for (const [path, bytes] of Object.entries(entries)) {
55
+ const base = path.split("/").pop();
56
+ if (base === undefined || base.length === 0)
57
+ continue;
58
+ if (!byName.has(base))
59
+ byName.set(base, bytes);
60
+ }
61
+ return Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
62
+ }
63
+ export async function sha256Hex(bytes) {
64
+ const buffer = new ArrayBuffer(bytes.byteLength);
65
+ new Uint8Array(buffer).set(bytes);
66
+ const digest = await crypto.subtle.digest("SHA-256", buffer);
67
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
68
+ }
@@ -0,0 +1,3 @@
1
+ import type { IndexEntry } from "../manifest.js";
2
+ export declare const generatedBySource: Record<string, IndexEntry[]>;
3
+ export declare const generatedIndex: IndexEntry[];
@@ -0,0 +1,9 @@
1
+ import s_kenney_blaster from "./kenney-blaster.json" with { type: "json" };
2
+ import s_kenney_nature from "./kenney-nature.json" with { type: "json" };
3
+ import s_kenney_space from "./kenney-space.json" with { type: "json" };
4
+ export const generatedBySource = {
5
+ "kenney-blaster": s_kenney_blaster,
6
+ "kenney-nature": s_kenney_nature,
7
+ "kenney-space": s_kenney_space,
8
+ };
9
+ export const generatedIndex = Object.values(generatedBySource).flat();