@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?
optionalattributes?:Record<string,unknown>
behaviors?
optionalbehaviors?:BlockBehaviors
layer?
optionallayer?:string
material?
optionalmaterial?:string
name?
optionalname?: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?
optionalposition?:Point
rotation?
optionalrotation?:Vector
scale?
optionalscale?:Vector
visible?
optionalvisible?: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?
optionalenabled?:string
Expression, same spelling as Group.
instances?
optionalinstances?:NodeInstance[]
Absent === one identity placement.
key
key:
string
label?
optionallabel?:string
properties?
optionalproperties?: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?
optionalargs?:Argument[]
enabled?
optionalenabled?:string
Optional enable expression — falsy = this step writes nothing.
key?
optionalkey?:string
label?
optionallabel?: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.