Fix occluded nodes after triangulation become unplaceable in new instances#146
Conversation
Preview Deployment
This preview will be removed when the PR is closed. |
test-null-node.js isn't registered in test-runner.html, so this copy of the SLP-export occluded-node (nulledNodes) round-trip tests never ran. The live copy lives in the registered tests/test-occlusion-node-availability.js. Replace the dead block with a pointer note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real headless-Chromium regression guard for the eric/occlusion-skeleton-issue bug: build a project with a grouped user instance whose node is occluded (nulledNodes), save it to .slp via the real save path, assemble a session folder (project.slp + calibration.toml + videos/), then reopen it through handleLoadSessionFolderSingleSlp and assert the occlusion, InstanceGroup grouping, and 3D points all survive. Verified: fails against the pre-fix session-folder loader (groupCount:0, nulledNodes:null — the flat rebuild dropped everything) and passes against the shared restoreGroupingAndUnlink path (commit 8cfaca2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Occluding a node on an unlinked (ungrouped) user label — e.g. a prediction converted to a user label that was never grouped — then saving/reopening the project dropped the occlusion. buildSlpLabelsAllViews writes the nulledNodes FLAG only in per-group instanceMeta, so an ungrouped instance's flag was never persisted. The occlusion IS in the file (the point saves as finite-xy + visible:false via _buildSioPoints) — the load side just never turned that back into nulledNodes. Fix: nulledNodesFromOcclusion() rebuilds the flag from the finite-but-invisible signal for user instances, applied in the pass-1 raw build of both handleLoadSlpFile and handleLoadSessionFolderSingleSlp. Grouped instances are unaffected (their pass-1 copy is replaced by the metadata-driven reconstruction). The e2e round-trip test now covers both grouped AND unlinked occlusion; the unlinked scenario fails pre-fix (nulledNodes:null) and passes with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move nulledNodesFromOcclusion from slp-import.js (which pulls app.js and can't be bridged into test-runner.html) to the dependency-free import-track-resolve.js so it's unit-testable in the in-browser runner. Pure relocation — identical logic, no runtime behavior change; slp-import.js and session-loader.js import it from its new home. Add tests/test-occlusion-derive.js (8 assertions) covering user occlusion derivation, missing-point exclusion, predicted-instance exclusion, and the no-op cases. Registered in test-runner.html: unit suite 1096 -> 1104, all pass. e2e occlusion round-trip still passes (grouped + unlinked). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix: occlusions on unlinked user labels now survive the project
|
Fix occluded nodes after triangulation become unplaceable in new instances
Branch:
eric/occlusion-skeleton-issueSummary
After triangulating a frame where a node is visible in only one of two views,
that node can't be triangulated and comes back with no coordinate (
null). Fromthen on every new instance you add is also missing that node — and you can't
click to place / un-occlude it, so you can't build a full-keypoint instance. Saving
.slp+ reloading was the only workaround.This PR makes the "add new instance" path place every skeleton node — filling any
node the template left
nullwith a fallback position and flagging it occluded(
nulledNodes) — so it draws a clickable marker you can un-occlude, instead of asilent, unreachable
null.Root cause
A single-view node can't be triangulated → its reprojection is
null→ the converteduser instance keeps that node
null. Thatnullthen propagates through the smart-addmachinery:
nullreprojection staysnullin the user instance(
pose/initialization.jsonClonePredictedGroup).nullbecomes the smart-add template —recordUserPointscaches pointspreserving nulls, and
addNewInstanceSmartseeds each new instance from that cache(
state.lastUserPoints), so thenullnode propagates into every new instance._addNewInstancecopied a length-matched template verbatim (ui/interaction.js),keeping the
nullentries.nullnode draws no marker, so the un-occlude affordance(
ui/interaction.js, which togglesinstance.nulledNodeson a hit) has nothing tohit → no way to place it.
Why save+reload "fixed" it: reload clears the in-memory
state.lastUserPointscache,so smart-add finds no template and falls through to
_addNewInstance's full-topologylayout — which places every node. It discarded the poisoned template rather than fixing
the data.
Regression origin: the smart-add template arrived in #109; the #131 cross-view
tracker changed
pose/triangulation.jsthis weekend and re-exposed the pre-existingnull-propagation path through the #109 template.
The fix
_addNewInstance(ui/interaction.js) no longer copies a length-matched templateverbatim. When a template slot is
null, it now:nodes (spaced so multiple nulls don't overlap), and
Setthat becomesinstance.nulledNodes— the same treatment
_convertToUserInstancealready gives its null nodes. Anall-
nulltemplate falls through to the full-topology layout. Net invariant:So a node that was occluded/untriangulatable in the source instance is now added as an
occluded-but-positioned node you can click to un-occlude, instead of disappearing.
Crucially, filled-occluded nodes flagged in
nulledNodesare excluded from the 3Dsolve (triangulation already skips
nulledNodes), so the fanned-out placeholder neverpollutes triangulation.
Files changed
ui/interaction.js_addNewInstancefillsnulltemplate slots (centroid fan-out) and flags themnulledNodes; all-null template falls back to topology layout. (+43/-3)tests/test-occlusion-node-availability.jstests/test-runner.htmlTests
tests/test-occlusion-node-availability.jsbuilds anInteractionManager(liketest-assignment.js) plus a triangulation integration case:nulltemplate node must not become an unreachablenull— it's placed and flaggednulledNodes. (Failed before the fix.)topology-places every node (the save+reload path).
nullpoints, so it can't re-poison thenext smart-add template.
nulledNodesyieldspoints3d[k] == null(excluded from the 3D solve), while the realnode still triangulates.
Full suite: 1067/1067 passing.
Confirmed: the skeleton is never mutated
A full read of every save/reload path confirms no code removes a node from
session.skeleton.nodesin the occlusion/triangulation/add-instance/save/reload flow(the only true node-deletion is the info-panel "×" button). All export paths serialize
the full node list, reload rebuilds from it, and
nulledNodesround-trips. What persistedwas the null/nulled point in instances and the inherited smart-add template — both
addressed here.
Related latent hazards (noted, not fixed here)
propagateNodeAdded/propagateNodeRemoveddon't touchreprojectedInstances(
pose/pose-data.js), so after a skeleton node add/remove the reprojected instanceskeep a stale-length points array.
inst.points.length, not the skeleton(
pose/triangulation.js). Worth making the point-array-length ↔skeleton.nodes.lengthinvariant explicit in a follow-up.
This addresses issue #144