Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ecs/packages/ecs-gpu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/ecs-gpu",
"version": "0.0.2",
"version": "0.0.3",
"description": "WebGPU devices, surfaces, render targets, views and frame passes for @adobe/ecs",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion ecs/packages/ecs-guide/src/compile/compile-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ it("compiles every chapter's files without a diagnostic, their kernels inlined,
if (module.text.includes("identify$(")) expect(module.text).toContain(`"${chapter.package}@`);
}
}
expect(kernels).toBe(25);
expect(kernels).toBe(27);
});

it("reports what the build rejects as a diagnostic located in the file, and gives no run", async () => {
Expand Down
4 changes: 4 additions & 0 deletions ecs/packages/ecs-persistence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ const spawned = await spawn({ world: other, snapshot: loaded });
snapshot's names it; `entities` maps the one to the other. Keys compare as stored, a number's or a vector's bytes and
a string's or an object's value. It fails for an index whose key, `with` or `not` names a component the snapshot's
trait doesn't cover, or two of the snapshot's entities one index holds with one key.
- **Updating:** with `update: true`, each entity `match` found takes the snapshot's components of its own, as
`commands.add` gives them: added where it lacks one, replaced and marked changed where it has it, its other
components left as they are. So an entity the application makes at startup, such as a camera, keeps a component that
is a saved setting: `spawn({ world, snapshot: settings, match: [byCamera], update: true })`.

Each reports why it didn't happen as `{ failure }`, rather than throwing, and `result.failure !== undefined` tells
which. A snapshot is opaque: only these functions read it.
Expand Down
2 changes: 1 addition & 1 deletion ecs/packages/ecs-persistence/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/ecs-persistence",
"version": "0.1.0",
"version": "0.1.1",
"description": "Saving, loading and copying the entities of @adobe/ecs worlds, as binary columns",
"license": "MIT",
"type": "module",
Expand Down
17 changes: 11 additions & 6 deletions ecs/packages/ecs-persistence/src/guide/04-settings.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
# Documents and settings

An application saves to more than one place, each with a trait of its own: [`file`](04-settings.ts#L5-L8) for what a
document holds, its voxels, their materials and its size, and `settings` for the user's own choices, the tool and
whether a panel is open, which never go into a document. A key carries the trait of each place it's saved in, under the
document holds, its voxels, their materials and its size, and `settings` for the user's own choices, the tool,
whether a panel is open and how far the view orbits, which never go into a document. A key carries the trait of each place it's saved in, under the
name it has there, and a name only has to be unique among the keys one trait covers.

- **Taking each:** [`snapshot({ world, by: file })`](04-settings.ts#L46) takes the document, and
[`snapshot({ world, by: settings })`](04-settings.ts#L48) the settings. The settings
[are resources alone](04-settings.ts#L26-L27), so their snapshot holds no entity.
- **Taking each:** [`snapshot({ world, by: file })`](04-settings.ts#L59) takes the document, and
[`snapshot({ world, by: settings })`](04-settings.ts#L61) the settings: the tool and the panel, which are resources,
and the view, an entity whose [`camera` and `orbit`](04-settings.ts#L26-L27) the settings cover.
- **Kept apart:** `decode` reads bytes by the trait it's given, and
[refuses a name](04-settings.ts#L53-L57) the trait doesn't cover, so settings can't be loaded as a document, nor a
[refuses a name](04-settings.ts#L67-L70) the trait doesn't cover, so settings can't be loaded as a document, nor a
document as settings.
- **Loading each:** `spawn` takes no trait: a snapshot holds the one it was taken or read by. A document opened in
another world leaves that world's settings as they are.
- **A setting on an entity the application makes:** the view is made at startup, by
[`startView`](04-settings.ts#L37), before the settings load, so loading them mustn't spawn a second one.
[`spawn` with `match: [byCamera]` and `update: true`](04-settings.ts#L86) finds the view by its camera, a unique
index's key, and writes the saved orbit onto it, as `commands.add` would; with `match` alone, the view would keep the
orbit it started with.
4 changes: 2 additions & 2 deletions ecs/packages/ecs-persistence/src/guide/04-settings.out
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
settings as a document: This snapshot holds resource "tool", which the trait doesn't cover.
settings as a document: This snapshot holds component "camera", which the trait doesn't cover.
a document as settings: This snapshot holds component "voxel", which the trait doesn't cover.
opened: 2 voxels, size 32, tool select, panel closed
restored: 0 entities, size 16, tool brush, panel open
restored: size 16, tool brush, panel open | views 25
23 changes: 19 additions & 4 deletions ecs/packages/ecs-persistence/src/guide/04-settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { System, World, type Vec3 } from "@adobe/ecs";
import { Index, System, World, type Vec3 } from "@adobe/ecs";
import { decode, encode, snapshot, spawn } from "../index.js";

/** Saved in the document's file, under its name there. */
Expand All @@ -11,6 +11,8 @@ const material = Symbol();
const size = Symbol();
const tool = Symbol();
const panelOpen = Symbol();
const camera = Symbol();
const orbit = Symbol();

declare module "@adobe/ecs" {
interface Traits<N> {
Expand All @@ -20,6 +22,9 @@ declare module "@adobe/ecs" {
interface Components {
[voxel]: { type: Vec3.I32; [file]: "voxel" };
[material]: { type: string; [file]: "material" };
// A view the application makes at startup, whose orbit is the user's own.
[camera]: { type: string; [settings]: "camera" };
[orbit]: { type: number; [settings]: "orbit" };
}
interface Resources {
[size]: { type: number; default: 16; [file]: "size" };
Expand All @@ -28,6 +33,10 @@ declare module "@adobe/ecs" {
}
}

const byCamera = Index.create({ key: [camera], unique: true });
const startView = System.create(({ commands }) => { commands.spawn([camera, orbit])({ [camera]: "main", [orbit]: 10 }); });
const zoomOut = System.create({ components: { with: [orbit] } }, row => { row[orbit] = 25; });

const edit = System.create(({ commands, resources }) => {
commands.spawn([voxel, material])({ [voxel]: [0, 0, 0], [material]: "stone" });
commands.spawn([voxel, material])({ [voxel]: [0, 1, 0], [material]: "grass" });
Expand All @@ -39,10 +48,14 @@ const edit = System.create(({ commands, resources }) => {
const report = System.create({ readonly: true }, ({ resources }) => `size ${resources[size]}, tool ${resources[tool]}, panel ${resources[panelOpen] ? "open" : "closed"}`);
let voxels = 0;
const countVoxels = System.create({ readonly: true, components: { with: [voxel] } }, () => { voxels++; });
const views: number[] = [];
const readViews = System.create({ readonly: true, components: { with: [camera, orbit] } }, row => { views.push(row[orbit]); });

export const main = async (): Promise<void> => {
await using world = World.create();
await world.run(startView);
await world.run(edit);
await world.run(zoomOut);
const drawing = await snapshot({ world, by: file });
if (drawing.failure !== undefined) return console.log(drawing.failure);
const preferences = await snapshot({ world, by: settings });
Expand All @@ -65,11 +78,13 @@ export const main = async (): Promise<void> => {
await opened.run(countVoxels);
console.log("opened:", voxels, "voxels,", await opened.run(report));

// The settings alone, in a world of their own: resources, no entities.
// The settings alone, in a world of their own, which makes its view at startup: the saved orbit updates it.
await using restored = World.create();
await restored.run(startView);
const own = decode(settingsBytes, { by: settings });
if (own.failure !== undefined) return console.log(own.failure);
const applied = await spawn({ world: restored, snapshot: own });
const applied = await spawn({ world: restored, snapshot: own, match: [byCamera], update: true });
if (applied.failure !== undefined) return console.log(applied.failure);
console.log("restored:", applied.entities.size, "entities,", await restored.run(report));
await restored.run(readViews);
console.log("restored:", await restored.run(report), "| views", views.join(", "));
};
3 changes: 2 additions & 1 deletion ecs/packages/ecs-persistence/src/guide/guide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const teaches: { readonly [chapter: number]: readonly string[] } = {
1: ["[file]: \"position\"", "snapshot({ world, by: file })", "encode(", "decode(bytes, { by: file })", "spawn({ world", "entities", "failure"],
2: ["snapshot({ world, by: file, root", "dropped", "entities: false", "entities", "match: [byLabel]"],
3: ["shared: true", "where: { [chunk]: [2, 0] }", "despawnWhere", "match: [byTree]"],
4: ["snapshot({ world, by: file })", "snapshot({ world, by: settings })", "decode", "spawn"],
4: ["snapshot({ world, by: file })", "snapshot({ world, by: settings })", "decode", "spawn", "match: [byCamera]", "update: true"],
};

/**
Expand All @@ -21,6 +21,7 @@ const mutations: { readonly [chapter: number]: readonly (readonly [string, strin
["[panelOpen]: { type: boolean; default: false; [settings]: \"panel open\" }", "[panelOpen]: { type: boolean; default: false; [file]: \"panel open\" }"],
["const settingsAsDocument = decode(settingsBytes, { by: file });", "const settingsAsDocument = decode(settingsBytes, { by: settings });"],
["[tool]: { type: string; default: \"select\"; [settings]: \"tool\" }", "[tool]: { type: string; default: \"select\"; [settings]: \"tool\"; [file]: \"tool\" }"],
[", update: true", ""],
],
};

Expand Down
17 changes: 17 additions & 0 deletions ecs/packages/ecs-persistence/src/match/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,23 @@ describe("spawn with match", () => {
expect(spawned.entities.get(savedSteel)).toBe(existingSteel);
});

it("writes the snapshot's values onto the entities an index matches with update, through bytes too, leaving their other components", async () => {
await using from = inline();
await from.run(build, { materials: [{ name: "steel", friction: 0.5 }, { name: "wood", friction: 0.3 }], bodies: [{ label: "box", material: "steel" }] });
await using into = inline();
await into.run(build, { materials: [{ name: "steel", friction: 0.9 }], bodies: [] });
const existingSteel = (await read(into)).find(item => item.material === "steel")?.entity ?? Entity.none;
await into.run(System.create(({ commands }) => { commands.add(existingSteel, tag, "kept"); }));
const decoded = decode(encode(await taken(from)), { by: saved });
if (decoded.failure !== undefined) throw new Error(decoded.failure);
const spawned = await spawn({ world: into, snapshot: decoded, match: [materialByName], update: true });
if (spawned.failure !== undefined) throw new Error(spawned.failure);
expect(await described(into)).toEqual({ materials: { steel: 0.5, wood: 0.3 }, bodies: { box: "steel 0.5" } });
const tags: string[] = [];
await into.run(System.create({ readonly: true, components: { with: [tag] } }, row => { tags.push(row[tag]); }));
expect([tags, (await read(into)).filter(item => item.material === "steel").map(item => item.entity)]).toEqual([["kept"], [existingSteel]]);
});

it("merges through bytes, as a snapshot encoded and decoded", async () => {
await using from = inline();
await from.run(build, { materials: [{ name: "steel", friction: 0.5 }], bodies: [{ label: "box", material: "steel" }] });
Expand Down
22 changes: 16 additions & 6 deletions ecs/packages/ecs-persistence/src/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ const isEntities = (value: unknown): value is ReadonlyMap<Entity, Entity> => {
return true;
};

/** Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities, those `existing` maps standing for theirs. */
const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown; readonly existing: unknown }) => {
/**
* Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities, those `existing` maps
* standing for theirs, onto which `update` writes their rows.
*/
const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown; readonly existing: unknown; readonly update: boolean }) => {
if (!isTables(input.tables) || !isKeyed(input.resources) || !isEntities(input.existing)) throw new Error("A snapshot holds tables and resources.");
return commands.spawnTables(input.tables, { resources: input.resources, existing: input.existing });
return commands.spawnTables(input.tables, { resources: input.resources, existing: input.existing, update: input.update });
});

/**
Expand All @@ -42,6 +45,10 @@ const spawnTables = System.create({}, ({ commands }, input: { readonly tables: u
* with the key of one of the snapshot's it holds, stands for it, keeping its own values, and isn't spawned again. It
* fails for an index whose key, `with` or `not` names a component the snapshot's trait doesn't cover, or two of the
* snapshot's entities with one key.
* - `update: true` writes the saved components of each entity `match` found onto it, as `commands.add` would: adding
* those it lacks, replacing and marking changed those it has, and leaving its other components as they are, its
* entity values rewritten as a spawned entity's are, but a reference to an entity that isn't there, which is left
* out. So an entity the application makes at startup takes the values it was saved with.
* - `entities: false` leaves out the result's map of entities, which costs about 85 ms and 50 MB at a million.
*
* Fails, as data, for a column or resource that doesn't fit, spawning nothing.
Expand All @@ -55,14 +62,17 @@ export async function spawn(input: Spawning & { readonly entities?: boolean }):
for (const { persisted, value } of resources) values[persisted.key] = value;
const existing = input.match === undefined || input.match.length === 0 ? new Map<number, number>() : await existingFor(input.world, from, input.match);
if ("failure" in existing) return existing;
const spawned = await input.world.run(spawnTables, { tables, resources: values, existing }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
const spawned = await input.world.run(spawnTables, { tables, resources: values, existing, update: input.update === true }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
if ("failure" in spawned) return spawned;
if (input.entities === false) return { dropped: spawned.dropped };
return { dropped: spawned.dropped, entities: entitiesOf(tables, spawned.entities) };
}

/** What `spawn` takes: the world spawned into, the snapshot, and the indexes saying which of its entities are there already. */
type Spawning = { readonly world: World; readonly snapshot: Snapshot; readonly match?: readonly Matching[] };
/**
* What `spawn` takes: the world spawned into, the snapshot, the indexes saying which of its entities are there already,
* and whether to write their saved components onto them.
*/
type Spawning = { readonly world: World; readonly snapshot: Snapshot; readonly match?: readonly Matching[]; readonly update?: boolean };

/** Each of the tables' entities to the one spawned for it, the one existing for it, or null for one that wasn't. */
const entitiesOf = (tables: readonly World.Table[], spawned: readonly Float64Array[]): ReadonlyMap<Entity, Entity | null> => {
Expand Down
Loading
Loading