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
- 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.
- Open Wiring View. The eleventh physical pixel is labelled 11, matching what you typed.
- Open Model Definitions → Faces, choose a Node Ranges face, and enter
11 for a face part.
- 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, :1224 → Model::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 |
11 → Nodes[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
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, typing11into a Face, State or SubModel node range selects grid node 12.Steps to reproduce
1,2,3,4,5,6,7,8,9,11,12— deliberately skipping10so pixel 10 is a null pixel.11for a face part.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:718Nodes.back()->ActChan = firstStartChan + idx * cpn; // idx = (typed number - 1)So
11gets the 11th slot and the channels for the skipped10stay 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:712Wiring View deliberately works back from the channel, and the existing comment states exactly why:
src-ui-wx/model/WiringDialog.cpp:205-206Faces / 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,:1224→Model::SetNodeColor()).ModelStatesPanel.cpp:710,:834→ sameSetNodeColor()path.SubModel::initRangeXY()—src-core/models/SubModel.cpp:385, resolving viaparent->Nodes[nn]at:436.So with grid
1…9, 11, 12:Nodes[]index11→Nodes[10]→ grid node 12.Which surfaces agree
1 + (ActChan − first) / chanCountModel::GetNodeNumber()(Model.cpp:2429)Nodes[n − 1]Nodes[n − 1]parent->Nodes[n − 1]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()atsrc-core/models/Model.cpp:2429divides the channel offset by a hardcoded3:That looks wrong for single-colour (1 channel/node) and RGBW (4) models. It also indexes
stringStartChanwithNodes[nodenum]->StringNum, whileCustomModel::InitCustomMatrix()setsStringNum = 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