Reverse-engineering LX / Chromatik .lxm (and .lxf)
Reverse-engineered from real Chromatik 1.2.1 example files (~/Chromatik/Models/Examples/*.lxm, Fixtures/Examples/*.lxf). All three LX formats are JSON:
| ext | what | analogous to voxeled |
|---|---|---|
.lxp | Project — the whole session (model + patterns + mixer + UI) | (a show/session) |
.lxm | Model — a rig of fixtures with placements + output patch | a layout (.vxl rig) |
.lxf | Fixture type — geometry template for a JsonFixture (points/strips/params) | a fixture definition |
.lxm — the model
{
"version": "1.0.0-SNAPSHOT", // optional (Cubes.lxm omits version/timestamp)
"timestamp": 1708605946762,
"normalization": { ... }, // optional bounds normalization
"fixtures": [
{
"id": 47048, // optional
"class": "heronarts.lx.structure.GridFixture", // the fixture class (see below)
"internal": { ... }, // editor/UI state — ignore
"parameters": { /* transform + patch + geometry, see below */ },
"children": {}
},
...
]
}
Geometry is NOT stored in the .lxm. Each fixture is generated at load time by resolving its class (or referenced .lxf) with its parameters + transform. Two fixture flavors seen:
- Built-in class (
heronarts.lx.structure.GridFixture, and siblings:PointFixture,StripFixture,ArcFixture,PointsFixture, …) — geometry params inline. GridFixture:numRows,numColumns,rowSpacing,columnSpacing,positionMode,wiring,rowTags,columnTags. heronarts.lx.structure.JsonFixture— references a.lxftype viafixtureType(e.g."Examples/Cube"), plus any of that.lxf's custom params.
parameters common to every fixture
- Transform:
x, y, z(position),yaw, pitch, roll(degrees),scale. - Metadata:
label,tags,enabled,brightness,selected,mute,solo,identify. - Output patch (this is the important part — LX fixtures carry their wiring, like voxeled's
outputblock):protocol,byteOrder,transport,reverse,host,port,dmxChannel,artNetUniverse,artNetSequenceEnabled,sacnPriority,opcChannel,opcOffset,ddpDataOffset,kinetPort,kinetVersion. protocol/byteOrder/transportare integer ordinals from the LX enums (heronarts.lx.structure.*in thechromatik-*.jar/github.com/heronarts/LX); confirm the ordering there before mapping (e.g.protocol0 ≈ none, then Art-Net / sACN / DDP / OPC / KiNET).artNetUniverse→ universe,ddpDataOffset→ DDP offset,opcOffset→ OPC offset.
.lxf — the JsonFixture type (e.g. Examples/Cube)
{
"label": "Cube",
"tag": "cube",
"parameters": { // typed, user-facing, referenced as "$name"
"size": { "type": "float", "default": 100, "min": 0 },
"pointsPerEdge": { "type": "int", "default": 15, "min": 5, "max": 100 },
"padding": { "type": "float", "default": 3, "min": 0 },
"caps": { "type": "boolean", "default": false }
},
"transforms": [ // conditional transform ops; fields are EXPRESSIONS
{ "enabled": "$center", "x": "-0.5 * ($size + 2 * $padding)", "y": "...", "z": "..." }
],
"components": [ // geometry primitives
{ "type": "Square", "tag": "front", "x": "$padding", "y": "$padding", "z": 0,
"yaw": -90, "size": "$size", "pointsPerEdge": "$pointsPerEdge" },
...
]
}
- Components are primitives:
Point,Points,Strip,Arc,Square, and nested fixture refs. Each has a local transform (x/y/z/yaw/pitch/roll) and type-specific params. - Expressions: most numeric fields are strings evaluating arithmetic over
$parameters("$size + 2*$padding","-0.5 * (...)"). Generating points requires a small expression evaluator (numbers,$vars,+ - * /, parentheses, and LX's helper functions). - Points are generated by: resolve param defaults → evaluate
transformsand each component's expressions → lay out each primitive's points (e.g.Square= 4 edges ×pointsPerEdge) → apply component + fixture transforms.
How it maps to voxeled
Near 1:1 with voxeled's layout + patch:
LX .lxm | voxeled |
|---|---|
fixture (class/.lxf + params) | a fixture (its generated local points) |
x,y,z + yaw,pitch,roll + scale | an instance pos + rotDeg (+ scale baked into points) |
| output patch (protocol/host/universe/offset/byteOrder) | the instance's output block |
| — (LX points are positions only) | voxeled normal — estimate on import (outward-from-centroid / PCA) |
So a .lxm importer produces a voxeled scene: one voxeled instance per LX fixture, geometry from the class/.lxf, patch mapped to output, normals estimated.
Importer — src/io/lxm-import.mjs
node src/io/lxm-import.mjs <in.lxm> [out.vxl.json] [--fixtures <LX Fixtures dir>]
Tier 1 — built-in fixture classes (done). Parses the .lxm, generates geometry for GridFixture (the only structural built-in that appears in real Chromatik rigs), maps each fixture's transform + patch to a voxeled instance + output, and assigns a normal (local +Z) since LX stores none. Anything else (JsonFixture, …) is reported, never fabricated — the CLI tells you exactly what needs tier 2. Verified against ~/Chromatik/Models/Examples/Grid3D.lxm (10 grids → 4000 points) and covered by test/lxm-import.test.mjs (18 checks); a hand-authored example lives at examples/lx/grid.lxm.
Patch mapping: LX enum ordinals → voxeled (protocol 0 = none → unwired; 1 ≈ Art-Net → universe /channel; 3 ≈ DDP → offset; …). The ordering is best-effort — the raw ordinals are kept under output.raw so nothing is lost until confirmed against the LX source. scale is baked into local geometry; wiring (serpentine) only reorders indices and is a tier-2 refinement.
Tier 2 — JsonFixture (.lxf), done. Resolves the referenced .lxf (pass --fixtures / fixturesDir to point at the LX Fixtures/ root), tolerates JSONC (/* */, //, trailing commas), and generates its geometry:
- Expression evaluator (
src/io/lxf-expr.mjs) — a hand-written parser (noeval()) for the$paramarithmetic in.lxffields: numbers,$vars,+ - * / %, unary ±, parens, and functions. Trig is in degrees (the decisive case:Fan.lxfspans$degreesviacos()). - Components — the
stripprimitive (points along local +X),point, and recursive fixture refs (aCubeis sixSquares, each fourstrips);instancesrepeats a component with$instancebound; component/transformenabledis honored (e.g. a Cube'scaps); a fixture'stransformsapply last; matching fields pass params down to nested fixtures. - Normals — assigned as each fixture's local +Z carried through every transform. For a
Cubethat yields the four correct outward face normals LX never stored — the whole thesis in one import.
Verified against ~/Chromatik/Models/Examples/Cubes.lxm (73 JsonFixtures → 17 520 points) and each stock .lxf (Square, Cube, Fan, Winding, SpikySphere). Covered by test/lxf-expr.test.mjs (18) + the tier-2 half of test/lxm-import.test.mjs; self-contained examples live in examples/lx/ (cube.lxm + fixtures/{Square,Cube}.lxf).
Not yet: the arc primitive (absent from the stock fixtures — add when a sample needs it) and LX wiring/tags (index order + grouping, not positions).