The model — Document, prim, path

The Document

A Document is the whole document, held as flat dictionaries keyed by path: the prims, and the connections between their properties. It is a value type — copyable, comparable, serializable — with no hidden state and no machinery bolted to the side. When you save, you save the Document. When you edit, you edit the Document. When an agent mutates the document, it mutates the Document. There is exactly one source of truth.

Because a Document is just data, two of them compare with ==, you can hold a before-and-after pair to implement undo, and you can hand a copy to a worker thread without fear. Much of what is awkward in app architecture — keeping a "model" and a "view-model" in sync, diffing for change notifications — simply does not arise.

Prim — a node in the tree

A prim is one node: a path, a type token, a map of typed properties, and a map of string metadata.

def object "ball" {
    float3 position = (0, 1, 0)
    float  radius   = 1.5
}

The type token (object, group, heading, rect, folder…) is a plain string — it tells a reader how to interpret the prim, but the core attaches no behaviour to it. That is deliberate: the same container holds a 3-D object, a document heading, and a directory entry, because nothing about the structure is specialised to any domain. Domains live in the vocabulary of type tokens and property names a reader agrees on, never in the core.

Path — the only identity

A path is a slash-separated address — /world/ball — with an optional trailing .slot naming a property: /world/ball.radius. There are no UUIDs and no inter-node pointers. A prim's identity is its location in the tree.

This one decision pays off everywhere:

Property — a typed, time-aware value

A property is a typed default value at a name, optionally carrying time samples. Ask for it at a moment in time and you get a concrete value; with samples present, floating-point properties interpolate between keyframes.

def object "ball" {
    float radius = 1.5                       # a constant
    float3 position = {                       # animated: keyframed samples
        0:  (0, 0, 0)
        24: (0, 5, 0)
    }
}

Animation is not a separate subsystem — it is this one axis, available on every numeric property of every prim, in every kind of document. An animated GIF, a moving camera, and a value that ramps over a slider drag are the same mechanism.

Value — the universal datum

A Value is a typed datum: a dtype (bool, the integer and float widths, str) plus a shape (a scalar, a vector, or a multi-dimensional array), over one flat buffer. Familiar things — a 2-D point, a 4×4 matrix, a colour, an image row — are all shapes over a dtype, not separate classes.

That uniformity is the quiet engine of the whole platform. One Value type is simultaneously:

Codecs lean on this directly: an EXR channel, a vertex buffer, and an SVG point list are all just typed arrays, so the canonical models the codecs target reduce to the same primitives rather than four unrelated schemas.

Tree and graph are the same prims

Because a connection joins two property paths, a Document of prims is also a directed graph: the tree you save and the dataflow you evaluate are not two structures kept in sync — they are the same prims, read two ways.

def object "ball" {
    float3 albedo = (1, 1, 1)
    albedo.connect = </world/mat.out>
}

An evaluator pulls values through those connections on demand; a dependency graph tracks what must recompute when an input changes. The document is the program.

Pure and portable

The model rests on a few deliberate choices — path is the only identity, every datum is a typed value, references are connections — and on one constraint that makes it embeddable anywhere: Prism Core depends on nothing but the C++ standard library. No GPU, no UI, no platform calls, no third-party packages. It compiles on anything with a C++20 compiler and is exhaustively tested. Everything else in the Foundation is built on this core; the core itself stays small enough to understand in an afternoon.