Skip to content

fix(editor): Play runs the scene tab that is open - #45

Closed
pythonlearner1025 wants to merge 2 commits into
mainfrom
fix/play-active-scene
Closed

pythonlearner1025 wants to merge 2 commits into
mainfrom
fix/play-active-scene

Conversation

@pythonlearner1025

@pythonlearner1025 pythonlearner1025 commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

What part this touches

The Kite3D editor holds many open documents and one 3D viewport. A document is one file on the viewport: a scene, an object asset, a material, or a texture. DocumentStore owns the list, which one is active, and the tab strip that draws them. PlayModeHelper owns Play. It puts a scene on the viewport. It exports an in-memory snapshot of that scene. It then calls the engine's startGame on the same viewer. Stop puts the authored scene back.

There are two ways a project runs. The editor's Play calls startGame(viewer, project, options) on a viewer that already holds its scene. The published game calls createGame. That one builds its own viewer. It loads the scene the mainScene setting names in package.json.

The bug

The owner had assets/weapons-lab.scene.gltf open in a tab. They pressed Play. The main scene ran.

Step by step, before this change:

  1. The weapons lab is the active document. store.activeId is assets/weapons-lab.scene.gltf.
  2. startRunMode reads store.mainScene, not the active document.
  3. It calls store.activateForPlay(mainScene.path), which detaches the lab and attaches the main scene.
  4. The snapshot, startGame and the whole run then act on the main scene.
  5. Stop restores the main scene, and the editor is left on the main scene tab.

There is no error text. The editor does exactly what pass 1a told it to do. The plan said so: "Play from any tab activates the main scene document first". That decision was wrong. A scene on screen is the scene you expect to play.

Measured on a copy of the terminator project, before the change, during the run:

03-lab-tab-running {"activeId":"assets/main.scene.gltf","lab":"","main":"Wall segment 1,Bunker shell 1","component":"GameManager"}

lab lists the nodes only the weapons lab has. It is empty. The main scene's nodes are there instead.

stopRunMode had the same bug on the error path. It read store.mainScene too, so a failed startGame would have restored the wrong document's name and dirty flag.

There is a second half to the report. Play from a tab that holds no scene has to switch to the main scene to run it. Stop then left the editor on that scene. The tab the user pressed Play on never came back.

The fix

  • startRunMode runs store.active when it is a SceneDocument, and store.mainScene otherwise.
  • An object, material or texture tab holds no scene, so the main scene runs from those, as before.
  • The beforeRun record names the scene the run borrowed.
  • stopRunMode restores that scene, not the main scene.
  • The end of stopRunMode no longer re-checks beforeRun, because the top of the function already required it.
  • beforeRun also records store.activeId, read before the activate that Play does.
  • stopRunMode activates that tab again once the scene is restored.
  • activateForPlay already does nothing for a path it cannot find or already shows. So a scene tab stays where it is, and a tab closed meanwhile leaves the scene on screen.
  • The plan's 4.4 Play paragraph states both rules and why.
  • The doc comments on DocumentStore.mainScene, DocumentStore.activateForPlay and EditorDocument.dirty say what the code now does.

Nothing else moves. The snapshot already serializes what sits under the model root. startGame runs the project's scripts on the viewer it is given. The tab strip stays disabled while a game runs, by decision 5.

Two checks the brief asked for:

  • startGame never reads the mainScene setting. RuntimeProject.mainScene is declared at packages/engine/src/runtime/createGame.ts:48 and read once, at :132, inside createGame, which needs it to load its own scene. The editor still fills the field in runtimeProject() (ViewerInstanceManager.ts:844) because the two runtimes share the type. The other mainScene hits in the engine are a local helper in sceneSerialization.ts about a glTF file's default scene, and the validator in projectFormat.ts:113.
  • The game's main.js receives {viewer} alone (createGame.ts:98), so it cannot read the setting. It can read the tree. The terminator project's own main.js already does: it picks getComponentOfType('WeaponsLab') or getComponentOfType('GameManager'). During my runs the page reported WeaponsLab while the lab ran and GameManager while the main scene ran. No runtime change was needed.

The risk trade

The cost: Play is no longer one fixed target. A user on a half-built scene tab now runs that scene. A project whose scripts assume the main scene will see another one. That is the owner's own request, and the scene on screen is the honest answer to "what does Play play".

The tab restore costs one more switch at Stop. A switch detaches one tree and attaches another, which is the same work a tab click does every day.

What lowers the cost: the rule is one line with two outcomes, and both are visible in the editor. The running scene is the tab that stays selected. The strip is dead during the run, so the run cannot drift onto another document.

The rejected alternative: a new store.playScene getter that both Play and Stop would call. It reads well, but it has one real caller. Stop must restore the scene that ran. It must not re-derive it. The beforeRun record already names that scene, so the getter would have added a second source of the same truth.

Tests

Manual, headless, on my own copy of the terminator project at /Users/minjunes/games/terminator-play. Served with CI=1 node /Users/minjunes/kite3d-worktrees/play-active/packages/kite3d/dist/cli.js dev --port 4473 --no-open. Playwright with Chrome for Testing 1243, 1512x982, the swiftshader flags. Nothing took focus.

The served editor was my build. I put a marker file in packages/editor/dist and it came back over HTTP. The first pass ran against assets/index-DDLhxgcd.js. The tab restore, re-run after the rebase, ran against assets/index-XtN193AA.js. The main checkout serves index-BsvpCduU.js, so neither hash can come from it.

Before the change, Play from the weapons lab tab ran the Terminator main menu:
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/before-03-lab-tab-running.png

After the change, the same press ran the weapons lab: the firing range, the turntable, the target dummies, the revolver in hand and the lab HUD:
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/after-03-lab-tab-running.png

Stop put the lab back, pixel for pixel against the shot taken before the run:
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/after-02-lab-tab-edit.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/after-05-after-stop.png

The main scene still runs from the main scene tab and from a non-scene tab:
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/after-07-main-tab-running.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/after-10-object-tab-running.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/after-11b-object-tab-reclicked.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/after-16-texture-tab-running.png

Stop gives the tab back. I pressed Play on four tabs in one session, re-run after the rebase. I read the active id and the tree at each step.

20-object-tab-edit       activeId firing-line.gltf     descendants 5
21-object-tab-running    activeId main.scene.gltf      descendants 909
22-object-tab-after-stop activeId firing-line.gltf     descendants 5
24-lab-tab-running       activeId weapons-lab.scene.gltf  lab nodes present
25-lab-tab-after-stop    activeId weapons-lab.scene.gltf  lab nodes present
27-main-tab-running      activeId main.scene.gltf
28-main-tab-after-stop   activeId main.scene.gltf
29-texture-tab-edit      activeId bullet-roughness.png descendants 1
30-texture-tab-running   activeId main.scene.gltf      descendants 860
31-texture-tab-after-stop activeId bullet-roughness.png descendants 1

The shots for those steps:
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/rebased-20-object-tab-edit.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/rebased-21-object-tab-running.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/rebased-22-object-tab-after-stop.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/rebased-25-lab-tab-after-stop.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/rebased-28-main-tab-after-stop.png
/private/tmp/claude-501/-Users-minjunes-blitz/278f5f97-7e16-4722-b1bc-c061014966db/scratchpad/evidence/play-active/shots/rebased-31-texture-tab-after-stop.png

The readouts, step by step, are in before-report.json, after-report.json, after-edges.json, texture-tab.json, after-fix-report.json and rebased-report.json in the same folder. Zero page errors in every run.

The name and the saved hash prove the restore. The weapons lab document carried sceneName "Weapons Lab" and hash c0fcf1b165. During the run those read "Scene" and 73274aff1f, the snapshot's own. After Stop they were "Weapons Lab" and c0fcf1b165 again.

The edges:

  • An unsaved edit on the scene that runs survives. I moved the node Turntable from x -16 to -9 without saving. During the run the lab ran. After Stop the node was still at -9.
  • Two runs in a row from the same lab tab: the lab ran both times.
  • Lab, Stop, main scene, Stop, lab again: each tab ran its own scene.
  • The strip is dead during a run. A click on the main scene tab while the lab ran did not switch. Every tab read aria-disabled="true" and lost its close cross.
  • A dirty lab still runs its own scene.
  • Play from a texture tab runs the main scene, and Stop leaves the editor on the main scene tab.
  • Worst path, by reading: startGame throws, and its catch calls stopRunMode. That now restores the scene that ran. Before the change it restored the main scene. That is the wrong document when a second scene plays.

Off the happy path, one thing stays as it was:

  • A bad mainScene setting still refuses Play from a non-scene tab, exactly as before.

One thing to know about the dirty flag. In the first session both scene documents in that project copy read dirty at rest, before and after the change. That is pre-existing noise in the copy. The tab restore session opened the weapons lab clean. It ran, and Stop left it clean, with its name and hash back. So the flag round trips in both states.

Suites, all run in the worktree after the change:

  • npm run typecheck: three workspaces, clean.
  • npm run lint: three workspaces, clean.
  • npm test -w packages/kite3d: 10 tests in 5 files, all pass, 6.88s.
  • npm run test:scripts: 2 tests, both pass.

Two tests are new, in packages/kite3d/test/editor-open-tab.test.ts. They sit beside the headless test already there. The owner's two reports justify them. They take 1.9s and 2.4s.

The first builds a temporary project with two scenes. It opens the second one with the Files panel double click, the way a user does. It presses the navbar Run button. It then reads the model root. It asserts the second scene's node is there and the main scene's node is not.

The second opens pixel.png, a texture tab, and presses Run. It asserts the main scene runs and its tab is selected. It presses Edit. It then asserts the texture tab is selected again and the main scene's node is gone. I ran it three times in a row. It passed each time, in 2.3s, 2.5s and 2.4s.

What fails without the fix, measured twice. I reverted the scene choice to store.mainScene, rebuilt the editor and ran the suite. The first new test failed with expected [ 'Scene', 'MainSceneTriangle' ] to include 'SecondSceneTriangle'. I then deleted the line that activates before.activeId and rebuilt. The second new test failed, waiting for the pixel.png tab to come back. I restored both lines and rebuilt each time.

Anti-slop deletions in my own code, by the S1 to S10 lens:

  • S2: the if (this.beforeRun) block at the end of stopRunMode is gone. The function already requires the record at the top.
  • S6: stopRunMode no longer re-derives the scene from store.mainScene. The beforeRun record is the one place that names the scene the run borrowed.
  • S1 and S7: no getter, no options object, no helper for one caller. The test's sceneGltf helper has two callers in that file.
  • S9: no test-only export, branch or flag. The test drives the Files panel and the navbar button.

rg checks for the names I removed, all zero hits outside dist and node_modules: "Play runs the main scene", "Play borrows the main scene", "activates the main scene", "whatever tab was showing". store.mainScene now has one reader, the fallback in startRunMode.

The five review questions:

  1. Did I run it? Yes. I watched the weapons lab run in the viewport, in a headless browser. I compared it with the main menu the same press gave before the change.
  2. Do the types tell the truth? Yes. beforeRun holds a SceneDocument, and store.active instanceof SceneDocument is the narrowing that makes the choice safe.
  3. Is the naming honest? Yes. beforeRun was already the record of the run; it now names the scene as well, and its comment says so.
  4. Did I test the edges? Yes. Unsaved edits, repeat runs, alternating scenes, a dead strip, a dirty scene, an object tab and a texture tab. I read the throw path and the close path.
  5. Would I walk a colleague through it? Yes. One line decides which scene Play runs, one record says which scene Stop gives back.

Not tested: a material tab. This project has no .mat file. It takes the same branch as the object and texture tabs. Both of those ran the main scene. The code tests no file kind beyond instanceof SceneDocument.

Deploy

The editor bundle ships this, through the next kite3d npm release. Nothing else changes.

npm ci
npm run build
npm run typecheck && npm run lint && npm test -w packages/kite3d && npm run test:scripts
npm run release:patch

Rollback: publish or pin the previous version on npm. A project pins kite3d in its devDependencies, so npm i -D kite3d@0.21.0-alpha.5 puts the old Play back.

The owner had assets/weapons-lab.scene.gltf open in a tab, pressed Play, and
the main scene ran. Pass 1a pinned Play to the main scene by the plan's own
decision. A scene on screen is the scene you expect to play, so the rule is now
the active document.

- startRunMode takes store.active when it is a SceneDocument, else store.mainScene.
- beforeRun names the scene the run borrowed, and stopRunMode restores that one.
- The plan's 4.4 Play paragraph records the new rule and why.

Proved headless on a copy of the terminator project: the weapons lab tab now
runs the weapons lab, the main scene tab and an object or texture tab run the
main scene, and Stop puts the scene that ran back with its name, its saved hash
and its unsaved edit. typecheck, lint, kite3d (9 tests) and test:scripts pass.
Play from an object, material or texture tab has to switch to the main scene to
run it. Stop left the editor on that scene. The tab the user pressed Play on is
the tab they expect back.

- beforeRun records store.activeId before the activate that Play does.
- stopRunMode activates it again once the scene is restored.
- activateForPlay already no-ops on a tab that is gone or already on screen, so
  a scene tab stays where it is and a closed tab leaves the scene on screen.

Proved headless on a copy of the terminator project: from an object tab and from
a texture tab the main scene runs and Stop brings the tab back with its own tree
on the viewport; from the weapons lab tab and from the main scene tab Stop stays
put. One new test guards it. typecheck, lint, kite3d (10 tests) and test:scripts
pass.
@pythonlearner1025
pythonlearner1025 deleted the fix/play-active-scene branch September 17, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant