Skip to content
Shapemetry

@huukhanhnguyen/types / model/objectNode

model/objectNode

BlockNode

BlockNode = ObjectNodeBase & { objects: SceneNode[]; }

The only authored nest. Mixed content is a list of SceneNode, not a new geometry kind — a block may contain a block. instances place the block as a whole; children carry their own.


NodeInstance

NodeInstance = { attributes?: Record<string, unknown>; behaviors?: BlockBehaviors; layer?: string; material?: string; name?: string; position?: Point; rotation?: Vector; scale?: Vector; visible?: boolean; }

One authored placement. Translation / rotation / scale stay APART because a human authors "up by 100" and an animation track binds to a single channel — a matrix has no channels (Blender loc/rot/scale, DNA_object_types.h; three.js position/rotation/scale).

Rotation is XYZ Euler in RADIANS. Degrees exist only in user interfaces. No quaternion beside it, no matrix beside it, no delta layer (dloc / drot / dscale is the override-layer shape this repo has already rejected).

material and layer are this placement's DEFAULTS; an Entity's own value overrides them. Exactly two tiers — the Layer record holds no material to fall back to. A present material on a kind with no surfaces (light / skeleton / section) is rejected by validation, never silently ignored.

attributes?

optional attributes?: Record<string, unknown>

behaviors?

optional behaviors?: BlockBehaviors

layer?

optional layer?: string

material?

optional material?: string

name?

optional name?: string

What THIS placement is in the world, distinct from label on the node — a different TIER, not a synonym.

label describes the DEFINITION ("this is a Door") and is shared by every placement of it. name identifies the real, concrete thing this one placement IS — "Door-01" vs "Door-02" for two placements of one Door object — the way a schedule/BOM row needs a unique tag per physical unit, not per definition. Two placements of the same object legitimately want different names for the same reason they want different transformations: a name is a fact about the placement, not about the formula that produced it.

Optional the way label is: most placements never need one, and absent means the reader falls back to the definition's label. Not attributes: that bag is opaque and a reader must know a key to interpret it, whereas an identity is named on sight.

position?

optional position?: Point

rotation?

optional rotation?: Vector

scale?

optional scale?: Vector

visible?

optional visible?: boolean


ObjectNode

ObjectNode = ObjectNodeBase & { operations: (Operation | Group)[]; }

One authored object: a chain that will evaluate to one typed SceneObject, plus the placements that share that content.

key is required — the root addresses objects by key. instances is OPTIONAL here and required on the evaluated BaseObject: absent is the authoring spelling of one identity placement, and the evaluator fills it in. Authoring never writes an empty list.


ObjectNodeBase

ObjectNodeBase = { enabled?: string; instances?: NodeInstance[]; key: string; label?: string; properties?: PropertyNode[]; }

Fields every authored object/block node shares. Extracted once so ObjectNode and BlockNode do not redeclare the same shape (same reason BaseObject exists on the evaluated side).

enabled?

optional enabled?: string

Expression, same spelling as Group.

instances?

optional instances?: NodeInstance[]

Absent === one identity placement.

key

key: string

label?

optional label?: string

properties?

optional properties?: PropertyNode[]

Steps that WRITE ObjectNodeBase.instances — a sibling of the content arm (operations / objects), never a step inside it: the content arm computes what every placement shares, this list writes the placements themselves. A drag in the viewport is one instanceMove entry.

Here on the base rather than on ObjectNode because it writes instances, which is here: a block is placed the same way an object is, so it is dragged the same way too.


PropertyNode

PropertyNode = { args?: Argument[]; enabled?: string; key?: string; label?: string; method: string; }

One step that writes NodeInstance fields — the INSTANCE-tier counterpart of a chain's DataStep (ARCHITECTURE.md's Node section).

Structurally identical to DataStep and deliberately NOT an alias of it: the two mutate different tiers (a DataStep folds *Data, a PropertyNode writes instances[]), draw on disjoint method vocabularies, and are validated as mutually exclusive. One name per concept — the shapes coinciding today says nothing about the concepts.

THE BINDING RULE: method is always instance*, and the step's evaluated value MUST be an ARRAY as long as the object's instance list. Element i is SET (never accumulated onto) the one NodeInstance field the method is named for — instanceMove writes position, instanceLayer writes layer. A length that disagrees with the instance count is an error, not a truncation.

args?

optional args?: Argument[]

enabled?

optional enabled?: string

Optional enable expression — falsy = this step writes nothing.

key?

optional key?: string

label?

optional label?: string

method

method: string

Always an instance* method. Validity is registry-dependent, so the schema only checks the string shape (same law as DataStep.method).


SceneNode

SceneNode = ObjectNode | BlockNode

Authored counterpart of evaluated SceneObject. ModelJSON's root is a list of these with no instances of its own — the root is the scene, not a thing placed in it.

The type cannot prevent a cycle: a block containing itself recurses forever at evaluation. validate/objects.ts rejects a BlockNode whose objects tree re-enters the same key.

Last updated: 📖 5 min readEdit on GitHub