Skip to content
Shapemetry

@huukhanhnguyen/model / graph

graph

AngularDimension

AngularDimension = { arcPoint: Point; center: Point; references: Point[]; }

An angle measured at center, between the arms running out to references.

references is the same CHAIN as linear measure: N+1 arms give N measured angles, and every element has the same role.

arcPoint does two jobs that a bare radius number cannot: it sets the dimension arc's radius (its distance from center), and it picks WHICH of the four angles two crossing arms subtend is the one meant.

arcPoint

arcPoint: Point

center

center: Point

references

references: Point[]


AngularDimensionData

AngularDimensionData = { dimensions: AngularDimensionEntity[]; }

Object-tier angular-dimension datablock.

dimensions

dimensions: AngularDimensionEntity[]


AngularDimensionEntity

AngularDimensionEntity = AngularDimension & { attributes?: Record<string, unknown> | null; curves: PolyCurve[]; label?: Text; style: string; text?: string; textPoint?: Point; }

One angular dimension in a AngularDimensionData bag.


AngularDimensionInstance

AngularDimensionInstance = BaseInstance & { material?: string; }


AngularDimensionObject

AngularDimensionObject = BaseObject & { data: AngularDimensionData; instances: AngularDimensionInstance[]; type: "angularDimension"; }

An object whose data is angular dimensions and nothing else.

An angle has a vertex and two rays — the third arity.


AnimationClip

AnimationClip = { name: string; tracks: string[]; }

A named GROUP of tracks — the export/selection unit ("Walk", "Run", "Idle"). Every format that round-trips a real animation clip requires one: Blender's Action, Unity's AnimationClip, Unreal's AnimSequence, FBX's AnimStack, glTF/USD's named animation — measured 2026-08-14: a clip with no shared anchor for its member tracks is a real, filed interop bug (github.com/KhronosGroup/glTF#569 — mismatched per-track length with no clip-level tie, ~1.79s desync per loop between two implementations' guesses). Membership is BY REFERENCE (track keys), not containment — a track stays independently addressable (a parameter can point at one directly) whether or not it is also a clip member.

name

name: string

tracks

tracks: string[]

Member track keys, in authoring order.


Annotation

Annotation = LinearDimension | RadialDimension | AngularDimension | Text | Leader | Image

Union of the drafting geometries.


AnnotationStyle

AnnotationStyle = { arrowSize?: number; baselineSpacing?: number; centerMarkSize?: number; decimals?: number; extensionOffset?: number; extensionOvershoot?: number; lineOvershoot?: number; name: string; rounding?: number; scale?: number; textStyle: string; textSuffix?: string; tickSize?: number; }

Produced by collectAnnotationStyleNodes() from an authored annotationStyle node's expression args; consumed BY NAME.

arrowSize?

optional arrowSize?: number

Arrowhead length, model units (DXF DIMASZ).

baselineSpacing?

optional baselineSpacing?: number

Distance between stacked baseline dimensions (DXF DIMDLI).

centerMarkSize?

optional centerMarkSize?: number

Size of the cross drawn at a circle's centre. A centre mark MEASURES NOTHING, so it is this NUMBER and not a dimension type — DXF DIMCEN.

decimals?

optional decimals?: number

Decimal places in the measurement string (DXF DIMDEC).

extensionOffset?

optional extensionOffset?: number

Gap between the measured point and where its extension line starts (DXF DIMEXO).

extensionOvershoot?

optional extensionOvershoot?: number

How far an extension line runs PAST the dimension line (DXF DIMEXE).

lineOvershoot?

optional lineOvershoot?: number

How far the dimension line runs past the extension lines (DXF DIMDLE).

name

name: string

rounding?

optional rounding?: number

Round the measurement to this increment before formatting (DIMRND).

scale?

optional scale?: number

Multiplier applied to every length above, so one style set draws at a second paper scale (DXF DIMSCALE).

textStyle

textStyle: string

Name of the textStyles record the measurement string is drawn with.

textSuffix?

optional textSuffix?: string

Appended to the measurement string — a unit, "mm" (DIMPOST).

tickSize?

optional tickSize?: number

Size of the oblique tick drawn instead of an arrowhead when the architectural tick is used (DXF DIMTSZ; 0/absent = arrowheads).


AnnotationStyles

AnnotationStyles = { annotation: AnnotationStyle; text: TextStyle; }

The two style records a drafting mark needs at once: how the MEASUREMENT is drawn and how its TEXT is drawn. They travel together because a mark's drawn form needs both in one pass, and resolving them separately at each call site is where a mark ends up drawn with one style's arrows and another's text.

annotation

annotation: AnnotationStyle

text

text: TextStyle


AssetFile

AssetFile = { name: string; url: string; }

One entry of a scope's COMPUTED asset views (ModelScope.fonts / ModelScope.images): a url-method parameter, reduced to the pair every consumer of it needs. name is the parameter's KEY — what a reference names (a textCurve font arg, a sheet's imageEntity, a pattern chain's image source); url is the parameter's literal url (https:/data:), where the Store pre-loads the bytes from in the async phase. Neither view is a document lane: both are derived from the parameters lane by assetTypeOf, so both are always current.

One { name, url } type for both views. What distinguishes a font entry from an image entry is which view it came out of.

name

name: string

url

url: string


BaseInstance

BaseInstance = { attributes?: Record<string, unknown>; layer?: string; locked?: boolean; name?: string; transformation: Transformation; visible?: boolean; }

Placement fields every instance carries, whatever the object's kind.

Kind-specific extras (material, behaviors, …) live on that kind's *Instance, not here. behaviors in particular is a block instruction and must not appear on a mesh or a curve placement.

Its index is STABLE — instances mirror an authored list one-for-one, so an expression may address instances[2] and keep meaning the same placement across re-evaluation.

Coordinates are LOCAL to the object; whatever contains the object composes its own transform on top. An instance therefore never names its parent.

attributes?

optional attributes?: Record<string, unknown>

Free-form classification read by a consumer (schedule, IFC export, plugin), never by the engine. Per PLACEMENT, so one door out of forty can carry a fire rating the other thirty-nine do not.

layer?

optional layer?: string

Layer key.

locked?

optional locked?: boolean

Authoring-safety marker lifted from the chain at assembly. After assembly it shields the placement (draw3d refuses gesture edits). Absent === unlocked.

name?

optional name?: string

What THIS placement is in the world, distinct from BaseObject.label — 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. A name is a fact about the placement, not about the formula that produced it, which is exactly why it sits beside transformation and not on the object.

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.

transformation

transformation: Transformation

The placement, LOCAL to the object, as one composed matrix.

A matrix HERE and translation/rotation/scale on the authoring node — the two tiers want opposite things and this is where they part:

  • The node stores them apart because a human authors "move it up 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, Object3D.js.)
  • This value stores them composed because nobody ever asks it whether POSITION changed — the diff asks whether the placement changed at all, and a renderer wants the matrix anyway. (Rhino stores exactly this for a block reference: ON_InstanceRef::m_xform, one 4x4, no TRS.)

Required, not optional: this is COMPUTED, so the producer always knows it, and an absent-means-identity convention would put a branch in every consumer to buy back sixteen numbers.

visible?

optional visible?: boolean

Absent === visible.


BlockBehaviors

BlockBehaviors = { opening?: BrepData[]; }

System attributes of a block — things that automatically happen once the block is in the scene. Presence means enabled.

opening?

optional opening?: BrepData[]

Cut volume, in the nest's OWN local space (each placement transforms it by its own transformation).


BlockData

BlockData = { objects: SceneObject[]; sourceComponent?: { args?: Record<string, string>; key: string; }; }

Mixed nest of scene objects — the payload of BlockObject.

objects

objects: SceneObject[]

sourceComponent?

optional sourceComponent?: { args?: Record<string, string>; key: string; }

Set ONLY for a nest created by placeComponent — the components[] key + args signature this nest renders.

args?

optional args?: Record<string, string>

key

key: string


BlockInstance

BlockInstance = BaseInstance & { behaviors?: BlockBehaviors; material?: string; }


BlockObject

BlockObject = BaseObject & { data: BlockData; instances: BlockInstance[]; type: "block"; }

The only kind that nests. Mixed content is a list of scene objects, never a second geometry kind beside the others.


Bone

Bone = { head: Point; roll: number; tail: Point; }

One bone's GEOMETRY: a SEGMENT from head to tail, twisted by roll about its own axis. Blender's form (DNA_armature_types.h head[3] / tail[3] / roll). Length is |tail − head|.

head / tail / roll are the REST pose, relative to the parent.

head: Point

roll

roll: number

tail

tail: Point


BoneEntity

BoneEntity = Bone & { attributes?: Record<string, unknown> | null; name: string; parent?: string; positionTrack?: string; rotationTrack?: string; scaleTrack?: string; }

One bone as an entry of a SkeletonData: the segment plus what the DOCUMENT says about it — its identity, its place in the hierarchy, the animated channels it plays, and its opaque claims.

parent rides here and not on Bone because it names another bone's name: a reference cannot live on a tier that has no identity to reference.

positionTrack / rotationTrack / scaleTrack are optional Track.key references — this bone's OWN animated channel. Consumer-holds-the- reference.


BrepData

BrepData = { edges: BrepEdge[]; faces: BrepFace[]; loops: BrepLoop[]; vertices: BrepVertex[]; }

Shared topology tables of one body. The geometry may contain several topological shells' faces; the partition is derived (classifyShells). Same stem as the BrepData ops namespace.

edges

edges: BrepEdge[]

faces

faces: BrepFace[]

loops

loops: BrepLoop[]

vertices

vertices: BrepVertex[]


BrepEdge

BrepEdge = { attributes?: Record<string, unknown>; curve: Segment; end: number; start: number; tolerance?: number; }

A curve bounded by two vertices, given as indices into the document's vertex table. The parameter window is the carrier's own natural range.

attributes?

optional attributes?: Record<string, unknown>

curve

curve: Segment

end

end: number

start

start: number

tolerance?

optional tolerance?: number


BrepFace

BrepFace = { attributes?: Record<string, unknown>; innerLoops: number[]; layer?: string; material?: string; outerLoop: number; reversed: boolean; surface: Surface; tolerance?: number; uvCurves: BrepFaceUvCurve[]; uvMatrix?: Transformation; }

A bounded patch of a surface: one outer loop, plus inner loops that cut holes.

The geometry kernel derives the chart window from surface + outer trim (Store::natural_bounds).

reversed — whether the face's outward normal opposes its surface's natural normal. Surfaces are shared via Rc across faces, so orientation cannot be folded into the carrier.

uvCurves — one PolyCurve2d per boundary edge. Seam-unwrap anchors are baked into that chain on write.

A seam edge is walked TWICE by one loop, once per parameter column, and the second image is not stored: the kernel computes it by translating the first one period along the periodic direction (Store::uv_curve_on_use).

attributes?

optional attributes?: Record<string, unknown>

innerLoops

innerLoops: number[]

layer?

optional layer?: string

Layer key for THIS face, overriding its placement's Instance.layer. Absent === inherit from the instance.

Per-face for the reason material already is: an IMPORTED document can carry N different layers across its faces before it is ever wrapped into one Object — DXF especially, which assigns layer per ENTITY and never per block — and collapsing that to one Instance-level layer on arrival loses what the file actually said.

material?

optional material?: string

Material key for THIS face, overriding its placement's Instance.material. Absent === inherit from the instance.

The oldest per-face lane reference and the reason layer is one too: a boolean fragment, an OBJ usemtl run or a glTF primitive names a material per FACE, and one document holds many faces, so the claim cannot ride the placement.

outerLoop

outerLoop: number

reversed

reversed: boolean

surface

surface: Surface

tolerance?

optional tolerance?: number

Surface-fit tolerance; omitted when at the 1e-7 default.

uvCurves

uvCurves: BrepFaceUvCurve[]

uvMatrix?

optional uvMatrix?: Transformation

Authored row-major 4×4 plane→world UV frame for THIS face (world = uvMatrix · [u,v,0,1]). Absent ⇒ derived from the face's own surface on read. Per-face, never per-shell.


BrepFaceUvCurve

BrepFaceUvCurve = { edge: number; uvCurve: PolyCurve2d; }

A uv image recorded on a face for one of its boundary edges: one PolyCurve2d in the face's (u, v). Reverse, a partial window and a 3D-projected image are baked into that chain on write.

edge

edge: number

uvCurve

uvCurve: PolyCurve2d


BrepInstance

BrepInstance = BaseInstance & { material?: string; }


BrepLoop

BrepLoop = { orientedEdges: OrientedEdge[]; }

An ordered, closed chain of oriented edges bounding a region of a face.

No type parameter, no tolerance, no attributes: a loop carries no geometry, only the order its edges are walked in.

It does NOT say whether it is the outer boundary or a hole — the FACE declares which of its loops is outer. OpenNURBS carries the claim on both, where they can disagree; one source cannot.

orientedEdges

orientedEdges: OrientedEdge[]


BrepObject

BrepObject = BaseObject & { data: BrepData; instances: BrepInstance[]; type: "brep"; }

An object whose data is one kernel brep — one body.

A boolean that returns a body with cavities stays one object; the shell partition is derived inside the kernel.


BrepVertex

BrepVertex = { attributes?: Record<string, unknown>; position: Point; tolerance?: number; }

A point in the topology, plus the ball inside which two coincident vertices are the same vertex.

tolerance ABSENT means 0 means EXACT — the convention every optional tolerance in this package keeps, so the lean document is also the exact one.

KNOWN GAP: no producer in this repo satisfies that convention yet. Measured 2026-08-09 across brep_cylinder, brep_sphere, brep_box and box − cylinder: every vertex and every edge carries the same stamped constant 1e-7, and nothing carries 0. So "exact" is an empty category today, and tolerance cannot be used to tell an exact element from a fitted one — a rule of the form "do X only when tolerance > 0" fires on everything. The convention above states the INTENT; treat a 1e-7 you read as "unset", not as a measured deviation. This matters beyond bookkeeping: BrepVertex.tolerance is read both as an identity ball and as a closure band, and a stamped constant makes those bands identical regardless of how the point was produced.

attributes?

optional attributes?: Record<string, unknown>

position

position: Point

tolerance?

optional tolerance?: number


CameraEntity

CameraEntity = CameraData & { gizmoSize?: number; }

A camera's Entity tier: its projection (CameraData) plus what only a VIEWPORT needs to draw it.

There is NO target here, and its absence is a decision rather than an omission. A camera's pose needs 3 DOF — including roll — so a single aim point cannot round-trip one: a look-at gesture computes the Euler and writes it to the instance's transformation (see the note above), and the aim point is not kept. LightEntity DOES store a target for exactly the reason this one cannot: a light aims with a bare Vector that carries no roll, so a point expresses it losslessly.


CameraInstance

CameraInstance = BaseInstance & { }

A camera has no surfaces; material is not on this kind.


CameraObject

CameraObject = BaseObject & { data: CameraEntity; instances: CameraInstance[]; type: "camera"; }

A camera in the scene. Projection is CameraData; placement is instances[i].transformation.


Color

Color = [number, number, number]

RGB triple in 0..255. Channels outside the range are not clamped here — a consumer interprets as it sees fit. Not a spatial Point: it shares the layout and nothing else, which is why it is its own name.


ColumnMeta

ColumnMeta = { computed?: boolean; format?: string; key: string; label?: string; type?: string; }

Table — what evaluateTableChain produces. The authoring tables[] lane (TableChain) stays in the model document schema.

computed?

optional computed?: boolean

True when header value expression is set (computed column).

format?

optional format?: string

key

key: string

label?

optional label?: string

type?

optional type?: string


ComponentBody

ComponentBody = { data: ObjectData; flat: boolean; isFlat: boolean; opening: BrepData[]; }

data

data: ObjectData

flat

flat: boolean

DECLARED flatness — Component.flat. Wins over isFlat, and when set the WHOLE body (data) is the 2D representation.

isFlat

isFlat: boolean

INFERRED flatness — the resolved body's bbox has no z extent at the current parameter values. A fallback: it can flip under a slider.

opening

opening: BrepData[]


CurveData

CurveData = { edges: CurveEdge[]; }

Object-tier curve datablock — N chains, each with its own claims.

Distinct from the kernel Curve document (geometry/curve.ts), which may carry transformation for geometry ops; object placement lives on instances, so CurveData does not have that field.

Array-of-structs, matching Blender's curve datablock: a Blender Curve holds a ListBase of Nurb splines and each spline carries its own mat_nr / resolu / flags directly (DNA_curve_types.h), rather than parallel per-spline arrays hanging off the datablock.

edges

edges: CurveEdge[]


CurveEdge

CurveEdge = { attributes?: Record<string, unknown> | null; color?: string; curve: PolyCurve; layer?: string; lineWidth?: number; material?: string; }

One authored chain in a curve document, carrying its own claims.

A CurveEdge is a lone unshared chain — no vertices, no loops, no sharing with a neighbour. The {Kind}{ElementKind} prefix is what lets "edge" mean that here and a shared topology edge on BrepData without collision (ARCHITECTURE.md Entity). Blender's matching type is Nurb/Spline in a Curve datablock; that spelling is not the type name here.

attributes?

optional attributes?: Record<string, unknown> | null

Opaque claims for this chain — absent or null = no claims. Written by object assembly or curve* ops, never hand-authored.

color?

optional color?: string

Stroke colour for THIS chain, as a CSS colour string, overriding the placement's material. Absent === inherit.

Colour, width and dash are what a line HAS — the drafting trio every linework format states its own way (SVG stroke / stroke-width / stroke-dasharray, DXF layer colour / lineweight / linetype) — so they sit on the chain rather than in attributes.

curve

curve: PolyCurve

layer?

optional layer?: string

Layer key for THIS chain, overriding its placement's Instance.layer. Absent === inherit from the instance.

Per-chain for the reason material already is, and a curve document is the case that forces it: DXF assigns layer per ENTITY and never per block, so one imported drawing legitimately carries N layers across its chains before it is ever wrapped into one Object.

lineWidth?

optional lineWidth?: number

Stroke width in millimetres, world units and not pixels. Absent === the renderer's hairline.

material?

optional material?: string

Material key for THIS chain, overriding its placement's Instance.material. Absent === inherit from the instance.

Per-chain for the same reason layer is: one document holds many chains — curveFromFace gives each ring the material of the face it came off — so the claim cannot ride the placement.


CurveInstance

CurveInstance = BaseInstance & { material?: string; }


CurveObject

CurveObject = BaseObject & { data: CurveData; instances: CurveInstance[]; type: "curve"; }

An object whose data is one curve document — N PolyCurve chains.


EdgeMark

EdgeMark = { attributes?: Record<string, unknown> | null; sharp?: boolean; v1: number; v2: number; }

One marked mesh edge — vertex-index pair plus its own claims. Named EdgeMark, not BrepEdge (BrepData's shared TOPOLOGY edge, other entries reference it by index; this is a discrete vertex-index pair with no sharing — the mesh analogue of CurveData's CurveEdge) and not MeshEdge (already taken: geometry/triangleMesh.ts's MeshEdge is the DISPLAY tessellation's rendered edge polyline, a different tier).

attributes?

optional attributes?: Record<string, unknown> | null

Opaque claims for this edge — absent or null = no claims. Written by object assembly or edge* ops, never hand-authored.

sharp?

optional sharp?: boolean

Hard edge (split normal) — Blender sharp_edge, the FBX smoothing-group boundary. Absent === smooth.

v1

v1: number

Vertex index into vertices.

v2

v2: number

Vertex index into vertices.


Environment

Environment = { groundColor?: string; hdri?: EnvironmentHdri; moment?: SunMoment; site?: Site; skyColor?: string; skyLux?: number; sunKelvin?: number; sunLux?: number; }

settings.environment — the surroundings the model is lit by. Every field optional: a document with no environment is lit by its own emitting objects and nothing else.

groundColor?

optional groundColor?: string

Colour bounced from below (−Z) — the ground half. Equal to skyColor gives the flat uniform fill a renderer calls "ambient".

hdri?

optional hdri?: EnvironmentHdri

moment?

optional moment?: SunMoment

Which moment — the sun's second input. Without both, there is no sun.

site?

optional site?: Site

Where on Earth — the sun's first input.

skyColor?

optional skyColor?: string

Colour arriving from above (+Z) — the sky half of the outdoor bounce.

skyLux?

optional skyLux?: number

Illuminance (lux) of that sky/ground fill.

sunKelvin?

optional sunKelvin?: number

Sun colour temperature (K).

sunLux?

optional sunLux?: number

Direct-sun illuminance (lux) at the model. Absent = a bright clear midday (the host's documented default).


FlatView

FlatView = ViewBase & { source: string; }

Space = a flat component; its content is already 2D.


GeometryPlacement

GeometryPlacement = { attributes?: Record<string, unknown>; layer?: string; material?: string; name?: string; transformation: Transformation; visible?: boolean; }

Placement matrix + props for one block placement in a render frame.

attributes?

optional attributes?: Record<string, unknown>

layer?

optional layer?: string

material?

optional material?: string

name?

optional name?: string

transformation

transformation: Transformation

visible?

optional visible?: boolean


HatchData

HatchData = { hatches: HatchEntity[]; }

Object-tier hatch datablock — the bag of hatches.

hatches

hatches: HatchEntity[]


HatchEntity

HatchEntity = { angle?: number; attributes?: Record<string, unknown> | null; color?: string; fill?: HatchFill; origin?: [number, number]; pattern: string; region: Curve2d; scale?: number; }

One hatch: a region, the line layers filling it, and where those layers sit.

HATCH SPACE is the 2D frame the lines are laid out in — millimetres on the region's own plane, NEVER a surface's uv. A parameterization is not arc-length, so a uv-laid pattern stretches on a revolved face, and two neighbouring faces have unrelated uv so their hatching breaks at the shared edge. Every surveyed system lays out on a plane in drawing units instead.

angle?

optional angle?: number

Rotation of every layer about HatchEntity.origin, in degrees. Absent === 0.

attributes?

optional attributes?: Record<string, unknown> | null

Opaque per-hatch claims. Absent or null === no claims.

color?

optional color?: string

One colour for every layer, overriding each layer's own. Absent === each layer keeps the colour its pattern gave it.

A drawing standard asks for the same pattern in another colour far more often than it asks for a different pattern, and duplicating the record to change a colour is how one pattern becomes six. The override is a single colour on purpose: a per-layer override would be the pattern again, written twice.

fill?

optional fill?: HatchFill

Paint behind every layer, over the whole region. Absent === the region is not painted and only the lines are drawn.

origin?

optional origin?: [number, number]

Where the pattern is anchored, in hatch space. Absent === [0, 0] of hatch space — NOT the region's own extent, and that difference is the whole point.

Two regions sharing a plane share hatch space, so an anchor of [0, 0] is the same world point for both and their hatching lines up across the edge they share; anchoring to each region's own extent would give them two different anchors and break exactly that. It is also why the pattern stays put when the region is edited. Rhino calls this m_basepoint; Archicad exposes the same choice as linking to the project origin versus linking to the fill's own origin.

pattern

pattern: string

The patterns[] record whose line families fill this region, BY NAME.

By name and never inline: two hatches drawn with concrete must be the same concrete, one edit must reach both, and every interchange format references a pattern by name rather than embedding it. A pattern that draws nothing is how a region gets HatchEntity.fill and no linework over it — the empty case lives in the RECORD, so there is still exactly one way to say it.

region

region: Curve2d

The region being filled: closed rings, with transformation placing their plane in the world. Rings that nest punch holes by the even-odd rule — no ring declares itself an island, the same way BrepLoop does not, because two ends of one claim can disagree.

Rings, not a BrepData: a hatch region is planar by definition, and carrying a trimmed-surface record for it stored the plane twice (once as Hatch.plane, once inside the face's surface) and let the two disagree.

scale?

optional scale?: number

Uniform scale about HatchEntity.origin, multiplying each layer's shift, spacing, dashes and origin. Absent === 1.


HatchFill

HatchFill = { color: string; type: "solid"; } | { end: [number, number]; repeat?: number; start: [number, number]; stops: ColorStop[]; type: "linearGradient"; } | { end: [number, number]; repeat?: number; start: [number, number]; stops: ColorStop[]; type: "radialGradient"; }

Paint laid over the whole region, behind the lines. Archicad carries the same thing as a fill's background pen; DXF as its solid-fill flag.

PDF is the system that got the shape right — one object discriminated by a type tag. Rhino instead put an enum on the pattern and had to abandon its gradient arm (the enum value is commented out in opennurbs_hatch.h), then bolt gradient back on as four loose fields of ON_Hatch.

ONE tag, never a tag inside a tag: the two gradient arms are spelled out in full and named after SVG's own elements, linearGradient and radialGradient.

Union Members

Type Literal

{ color: string; type: "solid"; }

color

color: string

CSS colour string.

type

type: "solid"


Type Literal

{ end: [number, number]; repeat?: number; start: [number, number]; stops: ColorStop[]; type: "linearGradient"; }

end

end: [number, number]

repeat?

optional repeat?: number

Ramp repetitions across start→end. Absent === 1.

start

start: [number, number]

Ramp endpoints in hatch space.

stops

stops: ColorStop[]

Ramp stops, the shape the textures lane already normalizes to.

type

type: "linearGradient"


Type Literal

{ end: [number, number]; repeat?: number; start: [number, number]; stops: ColorStop[]; type: "radialGradient"; }

end

end: [number, number]

repeat?

optional repeat?: number

Ramp repetitions across start→end. Absent === 1.

start

start: [number, number]

Centre, and a point on the outer ring, in hatch space.

stops

stops: ColorStop[]

Ramp stops, the shape the textures lane already normalizes to.

type

type: "radialGradient"


HatchInstance

HatchInstance = BaseInstance & { material?: string; }


HatchLine

HatchLine = { angle: number; dashes?: number[]; origin: [number, number]; shift: number; spacing: number; }

One family of parallel infinite lines carrying a repeating pen cycle — the whole mathematics of a hatch, and nothing else. One of these is exactly one data line of an AutoCAD .pat file, so a pattern definition copies straight in and straight back out.

Measured 2026-08-22 against the AutoCAD 2023 acad.pat shipped on this machine (72 patterns, 266 data lines): the format has EXACTLY this one mechanism. There is no finite-shape primitive. *AR-CONC's stones are three families of short dashes at three angles whose marks cross — the triangle is a visual effect, not an object, and the three cycles (9.0, 7.2, 7.6488) are deliberately incommensurate so the result never repeats.

A closed repeating figure IS constructible with this mechanism: give the families a common lattice and the dashes meet at their endpoints. What the mechanism cannot do is FILL such a figure — lines bound nothing on their own, which is what HatchEntity.fill is for.

angle

angle: number

Direction of the family, in degrees.

dashes?

optional dashes?: number[]

Repeating pen cycle measured along the line, in the SVG spelling: all values POSITIVE, alternating, EVEN indices draw and ODD indices lift. An odd-length list repeats once to make it even, as SVG's stroke-dasharray does.

.pat instead signs the values (negative = lift). The signs are the one thing that makes that format hard to read, and they are recovered on export by alternating them back.

A 0 in a DRAW slot is a dot, painted at HatchLineEntity.dotSize. Absent === a solid, unbroken line.

origin

origin: [number, number]

A point the first line passes through, in hatch space.

shift

shift: number

Slide ALONG the line direction between one line and the next.

Sliding a solid line along itself returns the same line, so this only becomes visible once HatchLine.dashes breaks it up: it staggers the marks of successive lines. A value that does not divide the dash cycle never comes back into phase, which is how a pattern reads as random while staying fully determined.

spacing

spacing: number

Distance ACROSS, between one line and the next.


HatchLineEntity

HatchLineEntity = { color?: string; dotSize?: number; line: HatchLine; lineWidth?: number; }

One drawn layer of a hatch: how the line looks, plus where the lines are.

The split is the point. line is pure mathematics and is exactly a .pat data line, so it round-trips to DXF untouched and an author (or a generator) can paste one in. Everything beside it is appearance, which .pat has no room for and which is simply dropped on export to that format.

Layers paint in array order, so a later layer draws over an earlier one.

color?

optional color?: string

CSS colour string, the spelling Material.baseColor already uses. Absent === inherit from the placement's material.

dotSize?

optional dotSize?: number

Diameter painted for a zero-length dash. Absent === HatchLineEntity.lineWidth.

line

line: HatchLine

Where the lines are.

lineWidth?

optional lineWidth?: number

Stroke width in millimetres. Absent === the renderer's hairline.


HatchObject

HatchObject = BaseObject & { data: HatchData; instances: HatchInstance[]; type: "hatch"; }

An object whose data is hatches and nothing else.


Image

Image = { anchors: Point[]; image: string; }

Raster image rectangle: a url string plus its placement anchors.

anchors

anchors: Point[]

image

image: string


ImageData

ImageData = { images: ImageEntity[]; }

Object-tier image datablock.

images

images: ImageEntity[]


ImageEntity

ImageEntity = Image & { attributes?: Record<string, unknown> | null; }

One image of an image object.


ImageInstance

ImageInstance = BaseInstance & { material?: string; }


ImageObject

ImageObject = BaseObject & { data: ImageData; instances: ImageInstance[]; type: "image"; }

An object whose data is images and nothing else.

A raster placed in the scene is not a mesh (no topology) and not a hatch (not a UV pattern of drawn content).


Instance

Instance = CurveInstance | BrepInstance | MeshInstance | PointCloudInstance | BlockInstance | LightInstance | SectionInstance | HatchInstance | SkeletonInstance | LinearDimensionInstance | RadialDimensionInstance | AngularDimensionInstance | LeaderInstance | TextInstance | ImageInstance | ViewInstance | CameraInstance

Evaluated placement union. Generic walks (fold, applyAuthoredInstances) use this; a kind's own instances field is the matching member.


Layer

Layer = { color?: Color; lineStyle?: LineStyle; lineWidth?: number; name: string; }

Resolved linework-appearance record. Layer is pure CLASSIFICATION, never a rendering fallback: color, lineWidth and lineStyle live on the Entity directly and take NO Layer-level default (layer/material resolve exactly two tiers, Entity override → Instance default). Materials stay the SURFACE record — a face's fill is its material's baseColor. Renderers honor what they can and ignore the rest (three ignores all three today). The three fields are the portable trio every linework format states its own way — SVG as stroke/stroke-width/stroke-dasharray, DXF as layer color/lineweight/linetype.

color?

optional color?: Color

Linework color, RGB 0–1.

lineStyle?

optional lineStyle?: LineStyle

lineWidth?

optional lineWidth?: number

Linework width in WORLD units (mm) — not pixels.

name

name: string


LeaderData

LeaderData = { leaders: LeaderEntity[]; }

Object-tier leader datablock.

leaders

leaders: LeaderEntity[]


LeaderEntity

LeaderEntity = Leader & { attributes?: Record<string, unknown> | null; curves: PolyCurve[]; label?: Text; style: string; }

One leader in a LeaderData document. The text it points WITH is label, never a sibling field on the object.


LeaderInstance

LeaderInstance = BaseInstance & { material?: string; }


LeaderObject

LeaderObject = BaseObject & { data: LeaderData; instances: LeaderInstance[]; type: "leader"; }

An object whose data is leaders and nothing else.

A pointer is not a dimension (no measured value) and not a text (the string lives on label inside each LeaderEntity).


LightData

LightData = LightShape & { color?: string; coneAngle?: number; direction?: Vector; ies?: string; kelvin?: number; lumens: number; penumbra?: number; position: Point; }

Object-tier light datablock: ONE emitter — where it is, what shape emits, and what it radiates. There is no separate Light beside it, because there is only one shape and the law allows it only one name.

A light has no INTERNAL plurality, so it carries no Data-tier array (being an INDEPENDENT kind is a different question — point is independent yet its entries are a list). N is instances[]'s job — a rig of 12 identical bulbs is one LightData placed 12 times, never 12 entries in a list.

Photometric units, so an author states what a manufacturer prints on the box and a renderer converts to its own.


LightEntity

LightEntity = LightData & { gizmoSize?: number; target?: Point; }

A light's Entity tier: its emitting core (LightData) plus what only a VIEWPORT needs to draw and aim it. A light has no internal plurality, so this is exactly ONE Entity and not an array (unlike PointEntity, which is a point of a cloud); N lights is instances[].

The two fields here are exactly what the functional core cannot say: lumens does not become a gizmo by living on the same document, and a photometric field is not made display data by being read by a renderer.


LightInstance

LightInstance = BaseInstance & { }

A light has no surfaces; material is rejected on this kind.


LightObject

LightObject = BaseObject & { data: LightEntity; instances: LightInstance[]; type: "light"; }

An object whose data is lights and nothing else.

A light is an emitter, not a tessellatable body. Grouping emitters as their own kind gives a lighting consumer a DOMAIN instead of sniffing the stream for lumens. The housing is ordinary solid geometry on a different object.


LightShape

LightShape = PointLight | SphereLight | DiskLight | RectangleLight | CylinderLight

The emitting shape. A tagged union because these are genuinely different geometry — a disk has a radius and a rectangle has two sides — not because a renderer happens to have a class per kind.

There is no spot arm, and its absence is the point: a spot is any shape with a coneAngle set, so narrowing stays ORTHOGONAL to what emits, and a disk-with-a-cone (a real downlight) is expressible. three.js's SpotLight class fuses the two and cannot say it.


LinearDimension

LinearDimension = { direction?: Vector; linePoint: Point; references: Point[]; }

A distance measured between marks, drawn on a dimension line.

references is a CHAIN, not a bag: N+1 marks give N measured segments, so a single dimension is a chain of one and needs no separate type, and a running / chained dimension needs no second type either. Every element has the SAME role.

linePoint is a point the dimension LINE passes through: the offset from the references is geometry, not a number to guess a sign for.

direction absent means measure ALONG the references (the ordinary case). Present, it measures the projection onto that direction.

direction?

optional direction?: Vector

linePoint

linePoint: Point

references

references: Point[]


LinearDimensionData

LinearDimensionData = { dimensions: LinearDimensionEntity[]; }

Object-tier linear-dimension datablock.

dimensions

dimensions: LinearDimensionEntity[]


LinearDimensionEntity

LinearDimensionEntity = LinearDimension & { attributes?: Record<string, unknown> | null; curves: PolyCurve[]; label?: Text; style: string; text?: string; textPoint?: Point; }

One linear dimension in a LinearDimensionData bag: the measured geometry plus the style it is drawn with, the drawn form, and the two overrides a drafter reaches for. Never a stored value — the measurement is a function of the geometry's own points.


LinearDimensionInstance

LinearDimensionInstance = BaseInstance & { material?: string; }


LinearDimensionObject

LinearDimensionObject = BaseObject & { data: LinearDimensionData; instances: LinearDimensionInstance[]; type: "linearDimension"; }

An object whose data is linear dimensions and nothing else.

Linear measure has two anchors and a direction. The three dimension kinds are different mathematical arities and cannot share one object kind.


Material

Material = { alphaMode?: "OPAQUE" | "MASK" | "BLEND"; alphaTest?: number; aoTexture?: string; attenuationColor?: string; attenuationDistance?: number; attributes?: Record<string, unknown>; baseColor?: string; clearcoat?: number; clearcoatRoughness?: number; colorFactor?: number; cutPattern?: string; displacementScale?: number; displacementTexture?: string; doubleSided?: boolean; emissive?: string; emissiveIntensity?: number; emissiveTexture?: string; ior?: number; metallic?: number; metallicTexture?: string; name: string; normalTexture?: string; opacity?: number; roughness?: number; roughnessTexture?: string; sheenColor?: string; sheenRoughness?: number; surfacePattern?: string; texture?: string; thickness?: number; tileHeight?: number; tileWidth?: number; transmission?: number; }

Runtime value contract — what evaluation produces (data stream, resolved records).

THE VOCABULARY ITSELF LIVES IN @huukhanhnguyen/types. This file re-exports ObjectData + the geometry-shape guards, the resolved asset records (material/layer/font/image), skeleton/animation, emission/camera, style description, and the declarative sheets/layouts/saved-views vocabulary so every consumer keeps importing through the @huukhanhnguyen/model barrel (src/index.ts export * from "./graph/index.js").

What stays in this package: the authoring node lane, the expression/evaluate engines, and the evaluated LANE shapes in the sibling modules re-exported at the bottom of this file (table/texture/view/sheet/ component/frame/spaces).

Import law: nothing outside graph/ — never product packages or apps/ (any other npm package is fine, per the graph-isolation boundary rule).

alphaMode?

optional alphaMode?: "OPAQUE" | "MASK" | "BLEND"

glTF alphaMode — the EXPLICIT statement, which wins over the inference when present: "MASK" reads alphaTest (default threshold 0.5), "BLEND" reads opacity, "OPAQUE" ignores both. Absent = inferred from the two fields above (alphaTest set → cutout; opacity < 1 → blend).

alphaTest?

optional alphaTest?: number

Alpha-cutout threshold (0..1) for a billboard/foliage-style texture — pixels below this alpha are fully discarded (glTF alphaMode "MASK"), not blended. Unset = no cutout (opaque, or opacity-blended).

aoTexture?

optional aoTexture?: string

Ambient-occlusion map (URI or texture key) — grayscale multiplier on ambient/indirect light; shares tileWidth/tileHeight.

attenuationColor?

optional attenuationColor?: string

KHR_materials_volume attenuation color, hex.

attenuationDistance?

optional attenuationDistance?: number

KHR_materials_volume attenuation distance (mm).

attributes?

optional attributes?: Record<string, unknown>

Domain attribute bag (classify-by-data). Documented keys:

  • kFactor?: number — sheet-metal bend K-factor (ANSI)
  • density?: number — kg/mm³ material mass density Core never grows domain fields as first-class Material props.

baseColor?

optional baseColor?: string

clearcoat?

optional clearcoat?: number

KHR_materials_clearcoat — varnish / gloss paint, 0..1.

clearcoatRoughness?

optional clearcoatRoughness?: number

colorFactor?

optional colorFactor?: number

0 = white tint (texture shows through fully), 1 = solid baseColor. Default 1.

cutPattern?

optional cutPattern?: string

The patterns[] record drawn where a SECTION CUTS this material, BY NAME. The region comes from the cut itself, at stamp time.

A name and not a hatch-shaped object: everything a hatch carries beside the name says WHERE the pattern sits, and a material knows no region to sit in. Carrying the placement knobs here made a second, half-usable spelling of a hatch that nothing could author.

displacementScale?

optional displacementScale?: number

Displacement amplitude in mm (three displacementScale). Default 1.

displacementTexture?

optional displacementTexture?: string

Displacement/height map (URI or texture key) — grayscale, displaces vertices along their normals. Only visible on tessellated/dense geometry (a 2-triangle face has no vertices to move — use the displace geometry op for real relief there).

doubleSided?

optional doubleSided?: boolean

Render both faces of a surface (glTF doubleSided) — thin imported geometry (a leaf, a curtain) has no solid thickness to close it.

emissive?

optional emissive?: string

Emissive (self-lit) color, hex — glTF emissiveFactor.

emissiveIntensity?

optional emissiveIntensity?: number

Emissive strength multiplier (glTF KHR_materials_emissive_strength). Default 1.

emissiveTexture?

optional emissiveTexture?: string

Emissive map (URI or texture key, same convention as texture).

ior?

optional ior?: number

KHR_materials_ior — glass ≈ 1.5.

metallic?

optional metallic?: number

metallicTexture?

optional metallicTexture?: string

Metallic map (URI or texture key) — grayscale, blue channel read per glTF convention; shares tileWidth/tileHeight.

name

name: string

normalTexture?

optional normalTexture?: string

Normal map (URI or texture key) — real light-responsive relief. Sampled linear (never sRGB); shares tileWidth/tileHeight.

opacity?

optional opacity?: number

roughness?

optional roughness?: number

roughnessTexture?

optional roughnessTexture?: string

Roughness map (URI or texture key) — grayscale, green channel read per glTF convention; shares tileWidth/tileHeight.

sheenColor?

optional sheenColor?: string

KHR_materials_sheen — fabric/upholstery color, hex.

sheenRoughness?

optional sheenRoughness?: number

surfacePattern?

optional surfacePattern?: string

The patterns[] record drawn where a face is seen UNCUT — the elevation hatch. Same rule as Material.cutPattern.

texture?

optional texture?: string

Texture image as a URI (https:// or data:) or a processors key — images have no lane of their own; the string itself is the reference.

thickness?

optional thickness?: number

KHR_materials_volume thickness in mm (optical path length).

tileHeight?

optional tileHeight?: number

tileWidth?

optional tileWidth?: number

Physical size (mm) of ONE seamless texture tile — face UVs are plane millimetres, so the renderer shows the texture at REAL size regardless of geometry dimensions (repeat = 1/tile). FALLBACK only: a textures[] chain declares its physical size via its builtin lengthX/lengthY parameters (the tile's size is intrinsic to the tile), which ride TextureData and win over these; declare here for plain URI textures, which have no record of their own. Default 1000.

transmission?

optional transmission?: number

KHR_materials_transmission — 0..1. Glass walls need this (opacity alone is frosted plastic).


MaterialData

MaterialData = Material & { aoTextureData?: TextureData; displacementTextureData?: TextureData; emissiveTextureData?: TextureData; metallicTextureData?: TextureData; normalTextureData?: TextureData; roughnessTextureData?: TextureData; textureData?: TextureData; textureFlipY?: boolean; }

A Material with its texture chains RESOLVED to pixels — what an evaluation hands a consumer that needs pixels. The *TextureData fields are attached at evaluate time when the matching texture / normalTexture / roughnessTexture / … names a texture key.


MeshData

MeshData = { edges?: EdgeMark[]; faces: MeshFace[]; morphs?: MorphTarget[]; skin?: SkinBinding; vertices: MeshVertex[]; weightsTrack?: string; }

Opaque n-gon polygon mesh — design topology stored as faces, triangles only at the door (render tessellation). Per-vertex claims live on each MeshVertex (NOTE the difference from shell tables: here vertex i's uv is vertices[i].uv, its normal vertices[i].normal, its skin influences vertices[i].skin). The object's kind is explicit in the evaluated value (MeshObject.type); no reader sniffs a geometry field to recognise it. Closed BY TYPE (the boundary law): only the mesh→mesh op family consumes it; boolean/BOM/tableSource/nesting/watertight filter it out by type.

Kept DISTINCT from the tessellation TriangleMesh wire type (see triangleMesh.ts) on purpose: the stream shape carries per-vertex MeshVertex.skin (indices/weights) while the tessellation expands it to skinIndices/skinWeights arrays (+ skeleton), and the tessellation adds the groups bake-time field a stream document never has. Tessellation always carries triangle indices.

edges?

optional edges?: EdgeMark[]

Sparse marked edges for interaction/shading — one entry per marked edge, array-of-structs — the same shape CurveData.edges keeps: an array of CurveEdge entries instead of a parallel curveAttributes. Runtime builds the FULL edge table from faces; this array only ever lists edges that carry a mark.

faces

faces: MeshFace[]

N-gon faces, array-of-structs — see MeshFace.

morphs?

optional morphs?: MorphTarget[]

Morph targets — see MorphTarget (offsets are per-VERTEX position deltas, flat and parallel to vertices by index — already the tessellation layout, no remap at render).

skin?

optional skin?: SkinBinding

This mesh's skeleton binding — see SkinBinding. Absent === not skinned. The per-vertex influences live on MeshVertex.skin; this holds only what is true of the whole document (which skeleton, and the bind pose it was attached at), which is why a writer reads the name and the inverse-bind matrices here and the weights per vertex.

vertices

vertices: MeshVertex[]

Vertices — see MeshVertex.

weightsTrack?

optional weightsTrack?: string

Track.key reference (2026-08-14) — this mesh's OWN weights channel. One track covers every entry in morphs at once: its values[i].length must equal morphs.length, in the SAME order — glTF packs one node's whole weights array into a single channel (confirmed against the spec: node.weights count MUST match the referenced mesh's morph target count), so one track already covers it — no per-target track to re-merge at export. Consumer-holds-the- reference, same as BoneEntity's *Track fields. Absent = this mesh's morph weights never animate (still usable as a static pose via each target's own authored weight elsewhere, out of scope here).


MeshFace

MeshFace = { attributes?: Record<string, unknown> | null; indices: number[]; layer?: string; material?: string; sharp?: boolean; }

One n-gon face — vertex-index loop plus its own claims. Array-of-structs: faces, faceAttributes and faceSharp used to be three arrays a caller had to keep index-locked by hand — the same burden edges/edgeAttributes carried before folding into EdgeMark, for the identical reason: three parallel arrays cost an index-sync bug surface and bought nothing.

attributes?

optional attributes?: Record<string, unknown> | null

Opaque claims for this face — absent or null = no claims. Written by object assembly (merge) or face* ops, never hand-authored.

indices

indices: number[]

CCW outer loop of vertex indices into vertices, length ≥ 3. Non-planar faces allowed. A triangle is a 3-gon.

layer?

optional layer?: string

Layer key for THIS face, overriding its placement's Instance.layer. Absent === inherit from the instance.

Per-face for the reason material already is: an IMPORTED document can carry N different layers across its faces before it is ever wrapped into one Object — DXF especially, which assigns layer per ENTITY and never per block — and collapsing that to one Instance-level layer on arrival loses what the file actually said.

material?

optional material?: string

Material key for THIS face — a materials[] record name — overriding its placement's Instance.material. Absent === inherit from the instance.

The oldest per-face lane reference and the reason layer is one too: a glTF primitive, an OBJ usemtl run or a boolean fragment names a material per FACE, and one document holds many faces, so the claim cannot ride the placement.

sharp?

optional sharp?: boolean

Flat/hard shading — absent or false means smooth shaded. Blender sharp_face.


MeshInstance

MeshInstance = BaseInstance & { material?: string; }


MeshObject

MeshObject = BaseObject & { data: MeshData; instances: MeshInstance[]; type: "mesh"; }

An object whose data is one mesh buffer.

One object IS one buffer. Disconnected pieces ("islands") stay derived. Turning those islands into separately named objects is looseToBlocks.


MeshVertex

MeshVertex = { attributes?: Record<string, unknown> | null; normal?: [number, number, number]; position: [number, number, number]; skin?: { indices: [number, number, number, number]; weights: [number, number, number, number]; }; uv?: [number, number]; uv2?: [number, number]; }

One mesh vertex — position plus its own per-vertex claims. Array-of- structs replacing MeshData's old positions/uvs/uvs2/normals/skin(top)/ vertexAttributes parallel arrays, for the same reason faces/edges were already folded: N parallel arrays cost an index-sync bug surface and buy nothing.

attributes?

optional attributes?: Record<string, unknown> | null

Opaque claims for this vertex — absent or null = no claims.

normal?

optional normal?: [number, number, number]

Absent = a renderer derives it (area-weighted vertex normal).

position

position: [number, number, number]

skin?

optional skin?: { indices: [number, number, number, number]; weights: [number, number, number, number]; }

Skeletal binding of THIS vertex — 4 entries, glTF SKIN semantics. The other half is MeshData.skin, which names the skeleton and the bind pose once for the whole document.

indices

indices: [number, number, number, number]

weights

weights: [number, number, number, number]

uv?

optional uv?: [number, number]

glTF TEXCOORD_0 equivalent.

uv2?

optional uv2?: [number, number]

glTF TEXCOORD_1 equivalent — AO/lightmaps.


MorphTarget

MorphTarget = { name?: string; offsets: number[]; }

One morph target: per-vertex POSITION DELTAS, flat [dx,dy,dz] per vertex, parallel to MeshData.vertices by index (deliberate: a morph is a shape DELTA the engine reads directly, not a per-vertex claim). Static shape data — never animated itself; MeshData.weightsTrack is what varies over time.

name?

optional name?: string

offsets

offsets: number[]


ObjectData

ObjectData = CurveData | BrepData | MeshData | PointCloudData | BlockData | LightData | SectionData | HatchData | SkeletonData | LinearDimensionData | RadialDimensionData | AngularDimensionData | LeaderData | TextData | ImageData | ViewData | CameraData

Mid-chain stream — the payload of a SceneObject, without identity or placement. A chain produces exactly one kind; the value is that kind's *Data. Object-level claims live on BaseObject / BaseInstance after assembly; per-region claims live on each entry (MeshFace.attributes, CurveEdge.attributes, BrepFace.attributes).


OrientedEdge

OrientedEdge = { edge: number; forward: boolean; }

An edge taken in a traversal direction. forward walks start → end.

edge

edge: number

forward

forward: boolean


Page

Page = { height: number; objects: SceneObject[]; title: string; width: number; }

One composed sheet: content inside the page that BOUNDS it.

A sheet is an OBJECT whose space is finite, so this is an object's content plus the bound, and nothing else.

Model.evaluateSheet returns it inside the shared envelope — Resolved<Page> (frame.ts).

The bound is a SIZE, not a box, and that is the one place a sheet differs from a view: a view's crop floats in the view plane (both corners carry information, so it is a BoundingBox2d), while the paper's corner IS the origin — every composed document lands in [0,width] × [0,height]. Storing minX/minY for paper would be storing two constant zeros.

height

height: number

objects

objects: SceneObject[]

Paper objects — the same SceneObject union the scene uses. A viewObject placement is a ViewObject; template furniture and ad-hoc drawing are the geometry kinds. No second envelope.

title

title: string

The evaluated title-block name, e.g. "GROUND FLOOR PLAN" — CONTENT printed on the paper (title-vs-label law), which is why it rides the VALUE and not the envelope's editor-chrome label. Sheet.title is an expression (titleValue law); this is what it evaluated to. "" when the sheet declares none.

width

width: number

The bound — paper millimetres.


Pattern

Pattern = { lengthSpace?: "model" | "paper"; lines: HatchLineEntity[]; name: string; }

Resolved pattern record — the drawing side of appearance, as Material is the surface side and Layer the classification side.

Produced by evaluatePatterns() in the model package from the authored pattern-method nodes; a resolved record is never written back to the document.

A pattern is the line families and NOTHING about where they are drawn. That split is what makes the record shareable at all: anything region-dependent would be a different pattern per region, leaving nothing to name. It is also what every interchange format requires — DXF names a pattern at group code 2, SVG references one by id from defs, PDF as a named resource — so a pattern that cannot be named cannot be written out at all.

lengthSpace?

optional lengthSpace?: "model" | "paper"

Which space this pattern's lengths are measured in. Absent === "model".

"model" — spacing, shift, dashes and lineWidth are millimetres of the MODEL, so the pattern shrinks with the drawing as the view scale falls. A material's own texture-like pattern wants this.

"paper" — the same numbers are millimetres of the SHEET, so a 2mm spacing prints as 2mm whether the view is at 1 or 1. Every drafting standard specifies its hatches this way, and getting it wrong is not a small error: at 1 a model-space 2mm hatch prints at 0.02mm and reads as solid black.

Precedent for the choice being a property of the PATTERN rather than of each hatch: Archicad carries it on the fill attribute (its help calls the two "scale with plan" and "scale independent"); AutoCAD reaches the same end through annotative scaling.

TODAY, "paper" is authorable and expandable but has no in-repo caller: renderLeaf takes a sheetScale and both of its callers fold MODEL space, where no sheet exists. Expanding a paper pattern without one THROWS, on purpose. A host that places a view on a sheet is the party that knows the number.

lines

lines: HatchLineEntity[]

The drawn layers, painted in order. EMPTY is legitimate: a pattern that draws nothing is how a region gets a solid fill and no linework over it.

name

name: string


Point

Point = [number, number, number]

3D point (default space). Same stem as geometry Point namespace.


PointCloudData

PointCloudData = { points: PointEntity[]; }

Geometry-tier point-cloud data document — the bag of points, a sibling of instances[] under the object.

points

points: PointEntity[]


PointCloudInstance

PointCloudInstance = BaseInstance & { material?: string; }


PointCloudObject

PointCloudObject = BaseObject & { data: PointCloudData; instances: PointCloudInstance[]; type: "pointCloud"; }

An object whose data is points and nothing else.

The name is the industry's (Blender OB_POINTCLOUD, Rhino ON_PointCloud). A point intersects nothing and cuts nothing, so a cloud never needs the boolean/offset/trim machinery the other content kinds do.


PointEntity

PointEntity = { attributes?: Record<string, unknown> | null; position: Point; size?: number; }

One point of a cloud. An IMPORTED cloud (LIDAR, photogrammetry) carries per-point claims straight from its source format, so the point is a record rather than a bare position.

attributes?

optional attributes?: Record<string, unknown> | null

Opaque per-point claims (colour, classification, intensity, …). Absent or null === no claims.

position

position: Point

size?

optional size?: number

Display size/radius for this point. Absent === renderer default. Named directly rather than buried in attributes because it is near-universal: USD UsdGeomPoints.widths, Houdini pscale, Blender's point-cloud radius.


PolyCurve

PolyCurve = { segments: Segment[]; }

Ordered chain of 3D curve segments (endpoint-welded).

An ordered chain only — segments meet end to start, never branch. Closest OCCT idea: CompositeCurve / a polyline of heterogeneous carriers. Wire: { segments: Segment[] }.

This is the geometry CurveData carries. Anything the user draws (one line, one arc, a polyline, a heterogeneous chain) is a PolyCurve — a single segment is a valid one-element chain. Bare NurbsCurve / bare LineCurve are kind carriers inside segments, not document payloads.

segments

segments: Segment[]


ProjectionView

ProjectionView = ViewBase & { camera: string; }

Axis is a free human choice, so it comes from a CameraObject. camera is that object's key — placement from its instance, projection from its camera field. A CameraData has no axis after the pose/projection split (objects/camera.ts). A drawing still takes its paper frame from the layout; fov/height are ignored there.


Quaternion

Quaternion = [number, number, number, number]

Unit rotation [x, y, z, w].


RadialDimension

RadialDimension = { angle: number; arc: ArcCurve; }

A radius (or diameter) measured on a circle or circular arc.

The measured subject is an ArcCurve because that type ALREADY carries centre, radius, plane normal and both axes — a centre plus a rim point cannot determine a circle in space.

angle (radians, in the arc's own xAxis/yAxis frame) locates the leader ON the arc.

angle

angle: number

arc

arc: ArcCurve


RadialDimensionData

RadialDimensionData = { dimensions: RadialDimensionEntity[]; }

Object-tier radial-dimension datablock.

dimensions

dimensions: RadialDimensionEntity[]


RadialDimensionEntity

RadialDimensionEntity = RadialDimension & { attributes?: Record<string, unknown> | null; curves: PolyCurve[]; label?: Text; style: string; text?: string; textPoint?: Point; }

One radial dimension in a RadialDimensionData bag.


RadialDimensionInstance

RadialDimensionInstance = BaseInstance & { material?: string; }


RadialDimensionObject

RadialDimensionObject = BaseObject & { data: RadialDimensionData; instances: RadialDimensionInstance[]; type: "radialDimension"; }

An object whose data is radial dimensions and nothing else.

Radius and diameter have one centre and one radius point — a different arity from linear.


RenderFrameBlock

RenderFrameBlock = { definition?: string; leaves: ObjectData[]; placement: GeometryPlacement; }

definition?

optional definition?: string

leaves

leaves: ObjectData[]

The placement's shared content, LOCAL to it — the consumer composes placement.transformation itself. N placements of one object hand back the SAME array by reference, so a diff host tessellates it once.

placement

placement: GeometryPlacement


RenderFrameSlice

RenderFrameSlice = { blocks: RenderFrameBlock[]; id: string; key: string; leaves: ObjectData[]; object?: SceneObject; }

blocks

blocks: RenderFrameBlock[]

id

id: string

key

key: string

leaves

leaves: ObjectData[]

World-space leaves, PRE-BAKE: nested block placements are exploded and their matrices baked into the geometry, but nothing is tessellated — a curve is still a CurveData, a solid still a BrepData. That is what an exact-B-rep consumer needs — a tessellation cannot be un-tessellated — and what keeps attributes readable by a table. A consumer that wants triangles calls render()/tessellateScene() itself.

EVERY kind rides here — curve, brep, mesh, point cloud, text, dimension, hatch, section, light, skeleton. Narrowing this to one kind silently drops the rest of the scene out of the frame.

object?

optional object?: SceneObject

Typed object for this slice when the model has an objects[] entry. Leaves stay the flat snapshot; this is the scene-stream handle.


Resolved

Resolved<T> = { key: string; label?: string; value: T; }

Common envelope for every Model.evaluate* result.

Type Parameters

T

T

key

key: string

label?

optional label?: string

value

value: T


SceneObject

SceneObject = CurveObject | BrepObject | MeshObject | PointCloudObject | BlockObject | LightObject | SectionObject | HatchObject | SkeletonObject | LinearDimensionObject | RadialDimensionObject | AngularDimensionObject | LeaderObject | TextObject | ImageObject | ViewObject | CameraObject

The evaluated-object union. One name, because a bare Object shadows the TypeScript global and reads ambiguously at every call site.

Seventeen kinds. Every member is { type, instances, data }.


ScenePull

ScenePull = { frame: RenderFrameSlice[]; slices: Map<string, ObjectData[]> | null; }

Fold slices + render frame — ONE SceneNode resolve produces both. A slice holds the root fold's own value, unbaked: frameFromSlices and modelCombinationFromSlices each derive their own tier from it.

frame

frame: RenderFrameSlice[]

slices

slices: Map<string, ObjectData[]> | null


Section

Section = { section: { normal: Vector; point: Point; }; size?: number; }

A cutting plane (point + normal). Discriminant: presence of section.

section

section: { normal: Vector; point: Point; }

Discriminant — presence of this field makes a payload a section plane.

normal

normal: Vector

point

point: Point

size?

optional size?: number

Display half-size of the plane gizmo (mm); cutting is unbounded.


SectionData

SectionData = { sections: SectionEntity[]; }

Object-tier section datablock — the bag of cutting planes.

sections

sections: SectionEntity[]


SectionEntity

SectionEntity = Section & { attributes?: Record<string, unknown> | null; }

One cutting plane of a section object.


SectionInstance

SectionInstance = BaseInstance & { }

A section has no surfaces; material is rejected on this kind.


SectionObject

SectionObject = BaseObject & { data: SectionData; instances: SectionInstance[]; type: "section"; }

An object whose data is section planes and nothing else.

A section is a cutting plane, not a body. The cut is an operation ON other content; this kind holds the plane that does the cutting.


SectionView

SectionView = ViewBase & { cut: Plane; }

Space is cut by a plane; the plane gives the axis. Section up is the first world axis not parallel to the normal, projected onto the plane, tried Z then X (puts world X up the sheet on a plan — the common convention is Y / north up).


SkeletonData

SkeletonData = { bones: BoneEntity[]; }

Object-tier skeleton datablock — the bone hierarchy itself. One object IS one skeleton; two skeletons are two objects. bones order is the skin index space — not an implementation detail. Authored as that order.

bones

bones: BoneEntity[]


SkeletonInstance

SkeletonInstance = BaseInstance & { }

A skeleton has no surfaces; material is rejected on this kind.


SkeletonObject

SkeletonObject = BaseObject & { data: SkeletonData; instances: SkeletonInstance[]; type: "skeleton"; }

An object whose data is one bone hierarchy and nothing else.

The skeleton's identity is this object's KEY — there is no name inside the data. faceSkeleton names that key; lookup has to fail loud when the key is not a skeleton.


SkinBinding

SkinBinding = { inverseBind: number[]; skeleton: string; }

A mesh's DOCUMENT-level skin facts (skeletal deformation) — glTF SKIN semantics. skeleton names the SkeletonObject's object key in the same model or component scope. Lookup of that key must resolve to a SkeletonObject or the engine throws. inverseBind is world_rest(B)⁻¹ at the moment THIS mesh was attached — a fact about the bind, not about a bone. Flat 16 × bones.length, column-major. Written by faceSkeleton — never hand-authored.

The per-POINT half (4 influences each) is MeshVertex.skin, not here: one document names one skeleton and one bind, while indices and weights vary per vertex. Holding both halves in one type would restate the per-vertex arrays the vertices already own.

inverseBind

inverseBind: number[]

skeleton

skeleton: string


Style

Style = { background?: string; exposure?: number; name: string; shading?: "shaded" | "wireframe" | "realistic" | "matcap"; }

Resolved styles[] record — the RESOLVED shape evaluateStyles() / collectStyleNodes() produce from an authored style node's expression args, consumed BY NAME via a view's viewStyle step.

background?

optional background?: string

Viewport clear color, any CSS hex ("#1a1a1a").

exposure?

optional exposure?: number

EV compensation on the 3D viewport's auto-exposure meter, realistic mode only — same as a camera's +/- exposure dial: 1 (default) trusts the meter as-is, 2 doubles the metered brightness, 0.5 halves it.

name

name: string

shading?

optional shading?: "shaded" | "wireframe" | "realistic" | "matcap"

Viewport display mode — SSOT applied by Viewport.applyStyle on every host (edit/embed/model). "realistic" switches to PBR tone mapping and is the ONLY mode that materializes emitting light data and the document's settings.environment into real renderer lights. "matcap" is self-lit form-reading (MeshMatcapMaterial). "shaded" is the CAD headlight rig. Absent = host may fill via applyStyle({ defaultShading }) only; never overridden when authored.


StyleCapabilities

StyleCapabilities = { exposure: boolean; lights: boolean; materials: boolean; }

What a style's shading STYLE can show — the SSOT both the model's validation (which style fields a shading permits — exposure requires "realistic") and packages/three's Viewport read, instead of scattered shading === "realistic" string comparisons. NOT a "view kind" concept — an orthographic (drawing) view referencing a "realistic" style is legal (a lit axonometric in the viewport, Revit precedent); the 2D paper pipeline structurally never reads a style at all, so plans stay immune with no extra rule needed.

exposure

exposure: boolean

lights

lights: boolean

Emitting LightData objects and the document's settings.environment materialize into real renderer lights.

materials

materials: boolean

MeshData materials render (their own color/texture) vs. flat line work only.


Sun

Sun = { direction: Vector; kelvin?: number; lux: number; }

The sun as a lit direction — COMPUTED from Environment.site + moment, never stored. Absent from a ResolvedEnvironment whenever either input is missing; present with direction[2] < 0 when the site's sun is below the horizon at that moment, which is a night study, not an error.

direction

direction: Vector

Unit vector TOWARD the sun, Z-up world space.

kelvin?

optional kelvin?: number

Colour temperature (K).

lux

lux: number

Direct illuminance (lux) at the model.


Surface

Surface = { type: "plane"; } & PlaneSurface | { type: "revolve"; } & RevolveSurface | { formAbsent?: boolean; type: "nurbs"; uPeriodic?: boolean; vPeriodic?: boolean; } & NurbsSurface

The document's surface carrier: the three kinds of the PlaneSurface / RevolveSurface / NurbsSurface union (geometry/surface.ts) wearing their type tags, plus carrier-only fields on the nurbs arm that are document concerns rather than geometry.


Table

Table = { columns: ColumnMeta[]; rows: Record<string, unknown>[]; }

columns

columns: ColumnMeta[]

rows

rows: Record<string, unknown>[]


Text

Text = { normal: Vector; point: Point; text: string; xAxis: Vector; }

A string placed and ORIENTED in space.

normal + xAxis make it self-determining in 3D: the plane it is written on and the reading direction inside that plane.

Font, height and alignment live on the named TextStyle.

normal

normal: Vector

point

point: Point

text

text: string

xAxis

xAxis: Vector


TextData

TextData = { texts: TextEntity[]; }

Object-tier text datablock.

texts

texts: TextEntity[]


TextEntity

TextEntity = Text & { attributes?: Record<string, unknown>; style: string; }

One string in a TextData bag. style names a textStyles record (font, height, width factor, oblique) — the text itself carries none of that.


TextInstance

TextInstance = BaseInstance & { material?: string; }


TextObject

TextObject = BaseObject & { data: TextData; instances: TextInstance[]; type: "text"; }

An object whose data is texts and nothing else.

A drawn string is not a dimension (no measured value) and not a leader (no pointer).


TextStyle

TextStyle = { font?: string; height: number; name: string; oblique?: number; widthFactor?: number; }

Produced by collectTextStyleNodes() from an authored textStyle node's expression args; consumed BY NAME (identity: "name", matching materials / layers / styles).

font?

optional font?: string

A url-method parameter key naming an attached font file (the asset mechanism assetTypeOf already resolves), OR a family name the reader looks up itself. Absent = the reader's own default face.

height

height: number

Cap height in MODEL units — the size a drafter states.

name

name: string

oblique?

optional oblique?: number

Slant in degrees, 0 = upright (DXF group code 50).

widthFactor?

optional widthFactor?: number

Horizontal stretch, 1 = natural (DXF group code 41).


TextureData

TextureData = { data: Uint8ClampedArray; filter?: "linear" | "nearest"; height: number; tileHeight?: number; tileWidth?: number; uvSet?: 0 | 1; width: number; wrap?: "repeat" | "clamp" | "mirror"; }

Resolved texture pixels + sampling metadata. The texture-chain ENGINE (which PRODUCES these) stays in the model package — this is the inert output bag a pixel consumer takes, and a Material alone cannot carry it (its texture / normalTexture / … fields are URI strings).

data

data: Uint8ClampedArray

filter?

optional filter?: "linear" | "nearest"

Sampler filter from the chain's declared filter builtin. Absent = linear.

height

height: number

tileHeight?

optional tileHeight?: number

tileWidth?

optional tileWidth?: number

Physical mm of one tile — renderers/exporters prefer this over material tile*.

uvSet?

optional uvSet?: 0 | 1

Which UV set this texture samples (0 = TEXCOORD_0, 1 = TEXCOORD_1). Declared on the CHAIN, not per material slot.

width

width: number

wrap?

optional wrap?: "repeat" | "clamp" | "mirror"

Sampler wrap mode from the chain's declared wrap builtin. Absent = repeat.


Track

Track = { interpolation?: "LINEAR" | "STEP"; key: string; times: number[]; values: number[][]; }

One keyframe track — pure value-over-time data, nothing else. key is unique across the WHOLE model (not just within its enclosing AnimationClip, if any) — a reference to a track never needs to know which clip (if any) it lives in.

values[i] is the value at times[i] — an array whose OWN length is however many numbers that keyframe carries (3 for a position, 4 for a rotation quaternion, N for a mesh's whole morph-weights array at once — glTF packs every morph target of one node into a single weights channel, so one track already covers all of them).

interpolation?

optional interpolation?: "LINEAR" | "STEP"

LINEAR when absent. STEP holds the previous key's value.

key

key: string

times

times: number[]

values

values: number[][]


Transformation

Transformation = number[]

A flat 4×4 affine matrix, 16 numbers.

Major order depends on the USE and the two are NOT interchangeable, so every field carrying this type states its own convention: placement transforms are COLUMN-major (Three.Matrix4 / SketchUp Geom::Transformation, translation at 12,13,14), while a face's uvMatrix is ROW-major (plane→world frame, origin at 3,7,11).


Vector

Vector = [number, number, number]

Free 3D vector (direction / axis). Same layout as Point; field role differs.


View

View = { key: string; label?: string; operations: Operation[]; }

A view is a CHAIN: optional typed HEAD + stackable filter/style steps. A fold of it is one of three shapes (FlatView / SectionView / ProjectionView), sniffed by the one extra field. Config-fold evaluation turns operations into that record; resolveView / projectView / drawView consume it.

Heads (must be first when present; at most one): viewPlan / viewSection / viewElevation / view3d / viewDetail Steps (any count, any order among themselves after the head): viewFilter (rules json) / viewStyle (name) Headless (filter/style only, or empty ops) = a ViewBase with no extra field.

key

key: string

label?

optional label?: string

operations

operations: Operation[]


ViewBase

ViewBase = { filter?: ViewFilterRule[]; key: string; label?: string; style?: string; }

Runtime value contract — what evaluation produces (data stream, resolved records).

THE VOCABULARY ITSELF LIVES IN @huukhanhnguyen/types. This file re-exports ObjectData + the geometry-shape guards, the resolved asset records (material/layer/font/image), skeleton/animation, emission/camera, style description, and the declarative sheets/layouts/saved-views vocabulary so every consumer keeps importing through the @huukhanhnguyen/model barrel (src/index.ts export * from "./graph/index.js").

What stays in this package: the authoring node lane, the expression/evaluate engines, and the evaluated LANE shapes in the sibling modules re-exported at the bottom of this file (table/texture/view/sheet/ component/frame/spaces).

Import law: nothing outside graph/ — never product packages or apps/ (any other npm package is fine, per the graph-isolation boundary rule).

filter?

optional filter?: ViewFilterRule[]

key

key: string

label?

optional label?: string

style?

optional style?: string


ViewData

ViewData = { crop?: BoundingBox2d; scale: number | "auto"; source: string; }

What a ViewObject places: which saved view, at what scale, optionally cropped. Not the drawing itself — that is re-derived on demand and never stored. Placement itself is instances.

crop?

optional crop?: BoundingBox2d

Fractions of the uncropped drawing. Omit = full drawing.

scale

scale: number | "auto"

Drawing scale — a number (0.02 = 1) or "auto".

source

source: string

Key of a views[] entry.


ViewInstance

ViewInstance = BaseInstance & { }

A view placement has no surfaces; material is not on this kind.


ViewObject

ViewObject = BaseObject & { data: ViewData; instances: ViewInstance[]; type: "view"; }

Placement of a saved view on paper — the object tier of ARCHITECTURE.md's View section. Two-tier: data is a REFERENCE (a views[] key), so there is no document tier. Still carries instances — no kind is structurally singular.


annotationPoints

Re-exports annotationPoints


asSheetSpaceData

Re-exports asSheetSpaceData


asViewSpaceData

Re-exports asViewSpaceData


asWorldSpaceData

Re-exports asWorldSpaceData


dataTypeOf

Re-exports dataTypeOf


draftingTextOf

Re-exports draftingTextOf


indexByName

Re-exports indexByName


isAngularDimension

Re-exports isAngularDimension


isAnnotation

Re-exports isAnnotation


isBlock

Re-exports isBlock


isCameraJSON

Re-exports isCameraJSON


isCameraPoseJSON

Re-exports isCameraPoseJSON


isCurve

Re-exports isCurve


isDimension

Re-exports isDimension


isFlatViewJSON

Re-exports isFlatViewJSON


isHatch

Re-exports isHatch


isImage

Re-exports isImage


isLeader

Re-exports isLeader


isLight

Re-exports isLight


isLinearDimension

Re-exports isLinearDimension


isMesh

Re-exports isMesh


isPoint

Re-exports isPoint


isPointCloud

Re-exports isPointCloud


isPolyCurve

Re-exports isPolyCurve


isProjectionViewJSON

Re-exports isProjectionViewJSON


isRadialDimension

Re-exports isRadialDimension


isSection

Re-exports isSection


isSectionViewJSON

Re-exports isSectionViewJSON


isShell

Re-exports isShell


isSkeleton

Re-exports isSkeleton


isText

Re-exports isText


isViewFilterOverride

Re-exports isViewFilterOverride


isViewJSON

Re-exports isViewJSON


kindOfData

Re-exports kindOfData


mapAnnotationGeometry

Re-exports mapAnnotationGeometry


SheetSpaceData

Re-exports SheetSpaceData


STYLE_CAPABILITIES

Re-exports STYLE_CAPABILITIES


ViewSpaceData

Re-exports ViewSpaceData


WorldSpaceData

Re-exports WorldSpaceData

Last updated: 📖 69 min readEdit on GitHub