Skip to content

Faces/States/SubModels node numbers don't match the Custom grid / Wiring View when a node number is skipped #6862

Description

@heffneil

Summary

On a Custom model that skips a node number (the usual way to leave a null/unaddressed pixel mid-string), the Faces, States and SubModels editors interpret a typed node number differently from the Custom grid and Wiring View.

Wiring View and the grid treat the number as a channel slot (gap-aware). Faces / States / SubModels treat it as an ordinal position in the list of defined nodes (gap-blind). They agree on a gapless model and drift apart by one for every skipped number.

Net effect: on a model numbered 1…9, 11, 12, typing 11 into a Face, State or SubModel node range selects grid node 12.

Steps to reproduce

  1. Create a Custom model and number the cells 1,2,3,4,5,6,7,8,9,11,12 — deliberately skipping 10 so pixel 10 is a null pixel.
  2. Open Wiring View. The eleventh physical pixel is labelled 11, matching what you typed.
  3. Open Model Definitions → Faces, choose a Node Ranges face, and enter 11 for a face part.
  4. The highlighted/selected node is the one the grid calls 12, not 11.

Same behaviour in States, and in SubModels node ranges.

Why (with references, all against 2026.15.1 / current master)

The grid number is the channel slot — a skipped number keeps its channels.
src-core/models/CustomModel.cpp:718

Nodes.back()->ActChan = firstStartChan + idx * cpn;   // idx = (typed number - 1)

So 11 gets the 11th slot and the channels for the skipped 10 stay reserved. That is the desired null-pixel behaviour — the physical pixel still consumes its bytes.

But the node vector is compacted — it holds only the cells you filled:
src-core/models/CustomModel.cpp:712

nodemap[idx] = Nodes.size();

Wiring View deliberately works back from the channel, and the existing comment states exactly why:
src-ui-wx/model/WiringDialog.cpp:205-206

// because a custom mdoel can skip nodes we need to reverse engineer the node number
stringnode = 1 + (nodeList[i]->ActChan - nodeList[0]->ActChan) / nodeList[0]->GetChanCount();

Faces / States / SubModels index the compacted vector instead:

  • Model::ParseFaceNodes()src-core/models/Model.cpp:971 — decrements to a 0-based value, which is then used directly as a node index (ModelFacesPanel.cpp:1148, :1224Model::SetNodeColor()).
  • States — ModelStatesPanel.cpp:710, :834 → same SetNodeColor() path.
  • SubModels — SubModel::initRangeXY()src-core/models/SubModel.cpp:385, resolving via parent->Nodes[nn] at :436.

So with grid 1…9, 11, 12:

Nodes[] index 0 8 9 10
grid number 1 9 11 12

11Nodes[10] → grid node 12.

Which surfaces agree

Surface Number derived from Skipped numbers
Custom model grid the number is the channel slot reserves channels
Wiring View 1 + (ActChan − first) / chanCount aware
Node Layout, tooltips Model::GetNodeNumber() (Model.cpp:2429) aware
Faces (node ranges) Nodes[n − 1] blind
States Nodes[n − 1] blind
SubModels parent->Nodes[n − 1] blind

The three gap-blind surfaces are precisely the ones where a user types numbers read off the model, which is why this reads as a bug rather than an intentional convention.

The part that needs a decision before any fix

Making Faces / States / SubModels resolve by node number would be the intuitive behaviour, but it would silently relocate every existing definition on a gapped model — someone who worked around this by entering ordinals would have their faces move.

So it probably needs a migration rather than a straight change, e.g. version-stamp existing face/state/submodel definitions and convert old ordinal ranges to node numbers on load, leaving gapless models (the overwhelming majority) untouched either way.

Happy to put up a PR, but the migration strategy seems worth agreeing on first — and it's also possible the current behaviour is intentional for reasons I haven't found, in which case labelling the fields as positions would be the cheaper fix.

Also noticed nearby (not the main report)

Model::GetNodeNumber() at src-core/models/Model.cpp:2429 divides the channel offset by a hardcoded 3:

return (Nodes[nodenum]->ActChan - stringStartChan[sn]) / 3 + sn * NodesPerString() + 1;

That looks wrong for single-colour (1 channel/node) and RGBW (4) models. It also indexes stringStartChan with Nodes[nodenum]->StringNum, while CustomModel::InitCustomMatrix() sets StringNum = idx (the node index, CustomModel.cpp:715-ish) rather than a string number — which looks like it could read out of bounds on a single-string custom model. I have not confirmed either of these, so treat it as a lead rather than a claim; happy to split it into its own issue if it holds up.

Environment

  • xLights 2026.15.1 (current master)
  • macOS, but the code paths involved are platform-independent

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions