### Context
`vtkOpenGLImageMapper`, `vtkOpenGLImageResliceMapper` and `vtkOpenG…LImageCPRMapper` each own a `model.tris` helper, an instance of `vtkOpenGLHelper` that holds the image quad's vertex buffer (CABO) and VAO. None of the three ever calls `model.tris.releaseGraphicsResources()`. Every removed or deleted image slice mapper, and every mapper moved to a different render window, leaves its vertex buffer and VAO behind.
This surfaced while testing #3683, which fixes the same three mappers' default lookup texture leak. That is a different bug with a different cause, and the two PRs don't depend on each other.
Confirmed at master, `009a4b2a9be3164820803a8d76065b45bbae0be3`.
#### Root cause
Links are pinned to master at `009a4b2a9be3164820803a8d76065b45bbae0be3`.
1. `vtkOpenGLHelper.releaseGraphicsResources` already does the work: it releases the VAO and the CABO, which owns the vertex buffer ([Helper L34-L38](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/Helper/index.js#L34-L38)).
2. Each of the three mappers has an `unregisterGraphicsResources(renderWindow)` function that releases the mapper's own non-shared resources before unregistering the shared ones, and it never touches `model.tris`: [ImageMapper L57-L69](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageMapper/index.js#L57-L69) releases `model.openGLTexture` the same way; [ImageResliceMapper L122-L127](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageResliceMapper/index.js#L122-L127) and [ImageCPRMapper L37-L42](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageCPRMapper/index.js#L37-L42) only unregister shared resources, since neither owns a non-shared texture of its own.
3. That function runs in exactly the two places that matter. When the mapper moves to a different render window, it runs on the old window right before `model.tris.setOpenGLRenderWindow` repoints `tris` at the new one: [ImageMapper L79-L92](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageMapper/index.js#L79-L92), [ImageResliceMapper L142-L155](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageResliceMapper/index.js#L142-L155), [ImageCPRMapper L52-L69](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageCPRMapper/index.js#L52-L69). When the mapper is deleted, it runs from the delete chain: [ImageMapper L1596-L1600](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageMapper/index.js#L1596-L1600), [ImageResliceMapper L2015-L2027](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageResliceMapper/index.js#L2015-L2027), [ImageCPRMapper L1413-L1417](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/ImageCPRMapper/index.js#L1413-L1417). Neither path frees `model.tris`, so its VAO and vertex buffer from the old context are never released.
4. `vtkOpenGLVolumeMapper` does not have this bug. It also owns a `model.tris` helper, and its own cleanup already calls `model.tris.releaseGraphicsResources(openGLRenderWindow)` ([VolumeMapper L1206-L1219](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/VolumeMapper/index.js#L1206-L1219)), called from the same two places: the render window switch ([VolumeMapper L137-L154](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/VolumeMapper/index.js#L137-L154)) and delete ([VolumeMapper L1221-L1235](https://github.com/Kitware/vtk-js/blob/009a4b2a9be3164820803a8d76065b45bbae0be3/Sources/Rendering/OpenGL/VolumeMapper/index.js#L1221-L1235)). This PR gives the three image mappers the same shape.
5. Only one of those two call sites is reachable in practice. Removing an actor from a renderer, and moving a renderer (or a child render window) to a different render window, both prune the mapper's view node through `removeUnusedNodes`, which deletes it exactly the way an explicit `mapper.delete()` does. I could not find a way to reach the render-window-switch branch itself, the `oldOglRenderWindow !== model._openGLRenderWindow` check inside each mapper's own `buildPass`, while the mapper's view node survives. Every move of a renderer or a child render window between two render windows deletes the old view node and builds a fresh one under the new parent, with nothing stale to rebuild. I confirmed this by instrumenting that branch directly: across both ways of moving a renderer, it never ran.
### Results
All numbers come from the new tests, run on master and on this branch. Each scene is one image slice with a real RGB transfer function and piecewise function on the property, so the color and opacity lookup textures land on the render window's shared cache and are already released correctly on removal. That isolates the count to the two GL objects this PR touches: the tris VAO and its vertex buffer.
| Scenario | master | this PR |
|---|---|---|
| ImageMapper: live GL objects left behind after the slice leaves the scene | 2 | 0 |
| ImageResliceMapper: live GL objects left behind after the slice leaves the scene | 2 | 0 |
| ImageCPRMapper: live GL objects left behind after the slice leaves the scene | 2 | 0 |
All three new tests fail on master with the same assertion error, `expected 2 to be +0`: one VAO and one vertex buffer short of the empty-scene baseline. All three pass on this branch.
Each mapper also has a second test that moves its renderer to an independent second render window instead of removing the actor, then checks the slice still draws there and the first window's object count drops back to what it was before the move. On master, these three also fail, off by the same 2 objects, for the same reason: `removeUnusedNodes` prunes the renderer that left through the normal delete path, not through the render-window-switch branch. In every run, with or without the fix, the slice draws correctly in the second window, because a moved renderer's view node is always built fresh. All six tests pass on this branch.
The full suite passes on this branch: 233 test files (226 passed, 7 skipped), 660 tests (647 passed, 13 skipped), 0 failed, in 136 seconds. It ran in headless Chromium with SwiftShader (`CI=1`) in the `mcr.microsoft.com/playwright:v1.60.0-noble` image.
### Changes
- `vtkOpenGLImageMapper`, `vtkOpenGLImageResliceMapper` and `vtkOpenGLImageCPRMapper`: `unregisterGraphicsResources` now also calls `model.tris.releaseGraphicsResources(renderWindow)`. This is the function each mapper already calls both on a render window switch and on delete, so the single call covers both cases without a new call site.
- New tests: `Sources/Rendering/OpenGL/{ImageMapper,ImageResliceMapper,ImageCPRMapper}/test/testReleaseGraphicsResources.js`. One test per mapper builds one image slice with a real transfer function and piecewise function, renders it, removes the actor from the renderer, and checks that the tracked GL object count returns to the pre-render baseline. A second test per mapper moves its renderer to a second, independent render window, checks the slice still draws there by reading the pixel at the viewport's center, and checks the first window's object count returns to what it was before the move.
- No public API, documentation or TypeScript changes.
#### Notes for review
- The transfer function and piecewise function in the new tests are load-bearing, not decoration. Without them, `getRGBTransferFunction()` and `getPiecewiseFunction()` return null and the mapper falls into the still-open default-lookup-texture leak in the same three mappers, which would add more leaked objects to the count and make the result say nothing specific about `tris`.
- `model.tris.releaseGraphicsResources(renderWindow)` passes the render window through even though `vtkOpenGLHelper.releaseGraphicsResources` ignores the argument today. That matches the existing call to `model.openGLTexture.releaseGraphicsResources(renderWindow)` right next to it in `ImageMapper`, and how `VolumeMapper` calls the same method on its own `tris`.
- I went looking for a real trigger for the render-window-switch branch, since releasing `model.tris` there could leave a surviving mapper's quad empty: `ImageMapper` and `ImageCPRMapper` rebuild the quad only when their inputs change, while `ImageResliceMapper` also rebuilds when the quad has no elements. I could not find one. Moving a renderer between two independent render windows, and moving a child render window between two root windows, both delete the old view node and build a fresh one elsewhere; instrumenting the switch branch directly showed zero hits across either operation. If you know a path that reaches it, the same `!model.tris.getCABO().getElementCount()` check `ImageResliceMapper` uses would cover the other two, and I can add it here.
- The renderer-move tests ended up measuring the original leak from a second angle rather than the switch branch: moving a renderer away prunes the mapper's view node through `removeUnusedNodes`, the same delete path `mapper.delete()` and an explicit `removeActor` already use. That is still a real, user-reachable way to hit the original leak, so the tests stay as a second, independent check of it.
- Not fixed here: `vtkOpenGLImageResliceMapper` releases its label outline textures with no render window argument, and the three mappers' still-open default-lookup-texture leak. Both are tracked separately and neither is part of this change.
### PR Checklist
- [ ] GitHub Actions CI passed: semantic-release commit messages, lint, and tests (locally: full suite passes, `oxfmt --check` passes, `oxlint` reports 0 errors with 3 pre-existing warnings unrelated to this change, and `commitlint` passes on the commit message)
- [x] Test coverage added
- [x] Documentation and TypeScript definitions are updated to match these changes (no public API change)