Model - Lane edit
API reference for src/commandRegistry/laneEdit on @huukhanhnguyen/model, reachable from ..
See the Guide for the ModelJSON / evaluate pipeline.
API reference
Signatures are generated from the live package .d.ts - not hand-written.
applyLaneEdit
function
Replay one lane edit (undo/redo, and D2's command log). Returns the inverse of the applied edit, or null when the helper was a no-op (blocked remove, no-op move, refused rename).
applyLaneEdit(target: (Model | ModelScope), call: LaneEditCall): LaneEditCall | nullEDITABLE_LANES
const
Lane-entry edit API — the ONE door for editing a record/chain lane (materials, layers, styles, views, sheets, components, tables, textures, cameras, skeletons, animations).
The node-graph half of the mutation surface has been one door for a long time (setInput/setKey/addOperation/NodeCollection add-remove-move). The lane half had none: every panel and chat action reached into scope.<lane>.push/splice, assigned whole arrays, and then remembered — or did not remember — to fire the collection's "change" event by hand. This module closes that half. Every function here
- validates the lane, the entry key, and the field/arg it is asked to write,
- applies the edit,
- OWNS the notification (
laneTouch— the same "change" eventtouch(owner)fires in packages/ui, which is what render sync, the lane nodes' hasChanged pass, and autosave all hang off), and - returns a SERIALIZABLE inverse (
LaneEditCall) thatapplyLaneEditcan execute — the undo unit S2's command log (D2) is built from.
Identity is per lane: most lanes key entries by key; materials/layers/ styles carry identity in a quoted literal name ARG instead (they are material/layer/style nodes — see materials.ts/layers.ts/styles.ts), so key in these signatures means "that lane's identity value".
NOT in scope here (deliberate): args of a step NESTED inside a chain entry's operations. Addressing one needs a node PATH (RigSection's joints are a tree, not a flat list), and those editors already funnel through their own commit door. They share this module's setNodeArg mechanics; the path-addressed command lands with D2.
const EDITABLE_LANES: readonly ["materials", "layers", "styles", "textStyles", "annotationStyles", "cameras", "skeletons", "animations", "components", "textures", "tables", "views", "sheets"]laneEntry
function
The entry itself, or undefined — the read half every section needs after an insert (laneInsert already returns it) or before an edit.
laneEntry(target: (Model | ModelScope), lane: LaneName, key: string): LaneEntry | undefinedlaneEntryKey
function
A lane entry's identity value — its key, or the literal its name arg evaluates to. Undefined when the lane keys by name and the name is a real expression (not statically addressable, same limit the validator accepts).
laneEntryKey(lane: LaneName, entry: LaneEntry): string | undefinedlaneInsert
function
Insert an entry, minting/uniquing its identity. index defaults to the end.
laneInsert(target: (Model | ModelScope), lane: LaneName, entry: LaneEntry, index?: number): { entry: LaneEntry; key: string; inverse: LaneEditCall; }laneKeys
function
Every entry's identity in lane order.
laneKeys(target: (Model | ModelScope), lane: LaneName): string[]laneMove
function
Move an entry to index (lane order is meaningful — CollectionNode.keys() dirties its readers on a reorder).
laneMove(target: (Model | ModelScope), lane: LaneName, key: string, index: number): { moved: boolean; inverse: LaneEditCall | null; }laneReferencePaths
function
Paths of every arg statically referencing name — the delete-block report.
laneReferencePaths(target: (Model | ModelScope), lane: LaneName, name: string): string[]laneRemove
function
Remove an entry — REFUSED (with the referencing paths) while a by-name consumer still points at it, on every lane that has by-name consumers. Unlike the other doors this is TOLERANT of an unknown key (a no-op): the callers are ✕ glyphs holding a captured entry, and a double click must not throw out of a click handler.
laneRemove(target: (Model | ModelScope), lane: LaneName, key: string): { removed: boolean; refs: string[]; inverse: LaneEditCall | null; }laneReplace
function
Replace a lane entry WHOLE, keeping its position and its identity.
The door an entry-level diff needs, and the one this module was missing: swap an entry outright rather than reaching inside it for a minimal edit (root CLAUDE.md, "The object is the unit of change").
Remove-then-insert cannot do this and must not be used for it. laneRemove REFUSES to delete an entry another node references by name — a material named "Oak" that some applyMaterial('Oak') still points at stays put — and laneInsert uniques the identity it mints, so the pair would silently leave the old entry in place beside a renamed copy. Editing in place sidesteps both: nothing is ever unreferenced, and the identity never moves.
The incoming entry's identity must match key; a rename is a different operation with different consequences (it rewrites every reference).
laneReplace(target: (Model | ModelScope), lane: LaneName, key: string, entry: LaneEntry): { inverse: LaneEditCall; }laneSetArg
function
Write one arg on the lane ENTRY's own node (materials/layers/styles/ cameras are single nodes; a chain entry's own args, where it has them). input is an expression STRING — never evaluated here; undefined REMOVES the arg (the old clearMaterialArg/clearLayerArg/ clearStyleArg door, and what an inverse restores to when there was no arg).
laneSetArg(target: (Model | ModelScope), lane: LaneName, key: string, argKey: string, input: string | undefined): { changed: boolean; inverse: LaneEditCall; }laneSetField
function
Write one header field (key/title/label/enabled/flat). A key rename routes through the lane's rename semantics: a by-name lane rewrites every reference doc-wide, and a renamed view carries settings.activeView with it. Returns changed=false on an empty or colliding key (the caller reverts the field, matching what every section already does).
laneSetField(target: (Model | ModelScope), lane: LaneName, key: string, field: string, value: unknown): { changed: boolean; inverse: LaneEditCall | null; }laneSetOps
function
Replace a chain entry's whole body (the views/components/ sheets subtree editors' write-back). field selects which body — default operations; a components[] entry's opening is the other one.
laneSetOps(target: (Model | ModelScope), lane: LaneName, key: string, operations: (Operation | Container)[], field?: LaneEditField): { inverse: LaneEditCall; }laneTouch
function
Fire the root collection's "change" — the SAME signal touch(owner) fires in packages/ui, which render sync, the lane nodes' hasChanged pass and the builder's reactive blocks all subscribe to. Owned here so a lane edit can no longer forget it.
laneTouch(target: (Model | ModelScope)): voidsettingsSet
function
The one settings door. patch merges; a key set to undefined is removed. replace swaps the whole block (how the inverse restores it).
settingsSet(target: (Model | ModelScope), patch: ModelSettings | undefined, replace?: boolean): { inverse: LaneEditCall; }