Skip to content

perf(editor): stop repeating work the editor already did - #25

Draft
pythonlearner1025 wants to merge 361 commits into
mainfrom
perf/editor-scene-update-coalesce
Draft

pythonlearner1025 wants to merge 361 commits into
mainfrom
perf/editor-scene-update-coalesce

Conversation

@pythonlearner1025

Copy link
Copy Markdown
Member

What part this touches

Two editor hot paths. They sit in different packages and are independent of each other. Both are the same shape of bug: work the editor already did, done again.

ViewerInstanceManager in packages/editor owns the editor's viewer. It listens to sceneUpdate on the scene. That event fires once per object that changed. Its handler calls changed(), which dispatches stateChange. useManagerVersion turns every stateChange into a setVersion(v => v + 1) for 13 components, including the root App.

scene-diff.ts in packages/kite3d compares the old and new main scene file so the dev server can write a line to the scene journal. The dev server awaits that diff inside the PUT /files/<scene>.gltf handler, so it sits between the editor pressing Save and the response coming back.

The problem

One, the dispatch per object

private onEditSceneUpdate = (event: {object?: IObject3D}) => {
    if (!this.loadingScene && !this.savingScene && event.object) {
        this.loadedNeedsSave = true
    }
    this.changed()
}

Twenty objects changing in one frame produced twenty stateChange dispatches, and twenty rounds of 13 setState calls.

The audit that opened this said the cost was 3.3 ms of React per moved object per frame. That was wrong, and measuring the fix is what caught it. React 18 batches updates that arrive in the same task, so a synchronous burst was already collapsing into one render. Measured on a 2,405 node project, 20 objects moved per synchronous frame: React self time 161.2 ms before against 142.0 ms after, over 40 frames. That is 0.48 ms per frame. With one object moved there is no difference at all.

The real cost is where React cannot batch: updates arriving in separate tasks. Asset loads, watcher reloads and nested asset instantiation all arrive that way. Forty updates in forty separate tasks cost 135.7 ms of React work and 511 ms of total JavaScript.

Two, the material flattened once per property

const properties = new Set([...flattenMaterial(oldMaterial).keys(), ...flattenMaterial(newMaterial).keys()])
const nodes = materialNodes(after, match.after)
for (const property of [...properties].sort()) {
    const oldValue = flattenMaterial(oldMaterial).get(property)
    const newValue = flattenMaterial(newMaterial).get(property)

flattenMaterial recurses the whole material and builds a fresh Map. Calling it inside the loop walks the material once per property compared. A material with P properties is walked 2P times instead of twice.

The audit called this real but cheap. That was also wrong. The audit's synthetic materials put their properties under extras, and flattenMaterial skips extras at the top level:

if (!path && (key === 'name' || key === 'extras')) continue

So the audit benchmarked a loop that never ran. With properties where a real material carries them, 20 materials with 64 properties took 143.94 ms, and with 256 properties 2198.89 ms. That is over two seconds blocking a save.

The fix

Coalesce scene updates to one stateChange per animation frame, keyed on a single pending frame handle.

Cancel that pending frame in dispose(), so a torn down manager cannot dispatch.

Leave every other caller of changed() immediate. Status text, save completion and play transitions are one off, not per object.

Hoist the two flattenMaterial calls out of the property loop into oldFlat and newFlat.

The risk trade

The coalescing costs one frame of latency on the editor's reaction to a scene change. The change itself is applied immediately; only the React re-render waits. A drag already renders at frame rate, so nothing visible moves later than it did.

The failure mode worth naming is coalescing that drops rather than delays. A test covers exactly that: after a single coalesced update, Save Scene must become enabled.

The alternative considered and rejected: move the coalescing inside changed() so every caller benefits. Rejected because status and lifecycle transitions are already one per event, so it would add a frame of latency to messages like "Scene saved" for no gain.

The flattenMaterial hoist has no behaviour to trade. The output is identical, which two of the four new tests assert directly.

On why these ship together: the owner asked for fewer PRs, and this is one two line change plus one seven line change, both removing repeated work on an editor path, both with no behaviour change. If a reviewer prefers them split, the two commits are separable by file with no conflict.

Tests

packages/editor/test/editor/scene-update-coalescing.spec.ts, 2 playwright cases.

  • a burst of scene updates in one frame dispatches one stateChange. Without the fix it gets 20, expected 1.
  • a coalesced scene update still enables Save Scene. Passes both before and after, by design. It is the guard against coalescing that drops the update.

packages/kite3d/test/scene-diff.test.ts, 4 vitest cases. They pass a material wrapped in a Proxy that counts key enumerations, so the assertion is a call count and not a timing.

  • flattens each material once, whatever the property count. Fails without the fix.
  • flattens each material once even as the property count grows. Fails without the fix, and takes 129 ms against 1 ms.
  • still reports the property that changed, and reports no material change when the two are identical. Both pass before and after. They hold the output fixed across the refactor.

One honest note on method. The first run of the editor tests passed against the unfixed source, because runDev serves the built packages/editor/dist and the revert had not been rebuilt. Rebuilding after the revert produced the real failure, 20 against 1. Every before number below comes from a rebuilt bundle.

Measured, same build on both sides, profiling project of 2,405 nodes, headless Chromium with swiftshader:

measure before after
40 updates in separate tasks, React self time 135.7 ms 0 ms
40 updates in separate tasks, total JS 511 ms 76 ms
40 updates in separate tasks, wall 820 ms 546 ms
10 updates in separate tasks, React self time 37.1 ms 3.6 ms
20 objects moved per synchronous frame, React over 40 frames 161.2 ms 142.0 ms
1 object moved per frame no difference no difference
diff, 20 materials x 16 properties 10.23 ms 0.98 ms
diff, 20 materials x 64 properties 143.94 ms 2.85 ms
diff, 20 materials x 256 properties 2198.89 ms 12.11 ms
diff, real 88 KB scene with 0 materials 0.91 ms 0.88 ms

Full CI mirrored locally on this branch: build, typecheck, lint, test:kite3d 13 passed, test:editor 10 passed, test:runtime 1 passed.

Deploy

The coalescing ships in @kite3d/editor, which the dev server serves. The hoist ships in kite3d, the CLI and dev server package. Neither touches @kite3d/engine, so published games are unaffected.

npm run build
npm run typecheck
npm run lint
npm run test:kite3d
npm run test:editor
npm run test:runtime

Then the owner's usual release. Users pick it up when they bump kite3d.

Rollback: revert this commit and release again. No data change, no format change, no migration. The scene journal keeps writing the same lines it wrote before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T623SnndzSVwCSmuQrVRj2

pythonlearner1025 and others added 29 commits September 11, 2026 12:34
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The board holds working notes and local paths. It now lives in blitz-cloud/docs/PLAN-REVIEW.md, which is private.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
What changed: the edit viewer keeps a permanent animation loop. When the editor is stopped and nothing changes it still ran the full frame pipeline at display rate: plugins, timeline, UI refresh, stats. The stopped loop is now held at 15 callbacks a second through threepipe's frameWaitTime countdown, released to full rate on any viewer update, and parked while the tab is hidden. A change still renders within one tick.

Why: with several editor tabs open at high DPR, Chrome's compositor fell behind and intermittently skipped UI paint and input while the DOM stayed correct: toolbar icons present but not painted, clicks ignored. Measured about 60 callbacks a second before and 13 after. The blanking did not reproduce in headless Chrome, so the fix rests on the measured pressure and a known Chromium report on macOS with 120 Hz canvas work.

Verification: editor Playwright suite 31 of 31 including the new idle-cap test (5 to 20 callbacks a second while idle, zero renders, an on-demand render within 100 ms); lint 3 of 3; typecheck; CI runs 34654625600 and 34654610739 green on the branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
What changed: the right panel has two tabs. Inspector shows the selection with a
name line (object name, type chip, path), the object's sections in one row style
(96 px label column, 28 px rows, 3 px radius, uppercase 11 px section labels),
one Components section listing attached components with their fields and an Add
control, and for generator nodes one Generator section with Module, Params, and
Bake as the single primary button. With nothing selected it shows a one-line
prompt and a Scene summary: scene file, the scene's own camera, object count,
last save, last check result; rows are omitted when the data does not exist.
Project lists Scripts (label kept), Plugins, and Dependencies including dev
dependencies, each row with a real status chip; a script that fails to load
shows the error and line. The Settings tab is gone: the viewer's Rendering and
Timeline config and the Import and Preview mode options live in an Editor
settings popover under the header cog, with a viewport-bound height and inner
scroll. Memory is hidden behind ?memory=1 or a localStorage flag, documented in
packages/editor/README.md. The panel body is the only scroll container; the
vendored renderer's overflow-y: scroll is overridden from our stylesheet and our
text rows use overflow: auto. The frame-loop cap from 68947cf is merged in.

Why: four tabs with viewer settings in the Inspector fallback was information
overload with three row styles and two scrollbars. The owner approved the
mockup; Scripts stays Scripts.

Verification: typecheck, lint, build; editor Playwright 33 of 33 twice (serial
run included), kite3d 163 of 163; screenshots of five states at 1400 by 900
reviewed against the mockup; vendored uiconfig-blueprint untouched; no em
dashes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
On Linux a stdout pipe is written synchronously, so a reader can see the Project line and send SIGINT before the next statement installs the handler, and the process dies by the signal. The CI test that stops kite3d dev with SIGINT hit exactly that on the redesign commit. Register SIGINT and SIGTERM first, then print.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Playwright clears test-results at the start of every run, so the five committed redesign screenshots showed as deleted after each suite run and blocked the release's clean-tree check. The private board keeps the visual evidence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The upgrade fixture hard-coded a future migration at 0.16.0, so the 0.16.0 bump made it current and two upgrade tests failed with future migration ran. The fixture now uses the next major version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…nd drop

Dragging or double-clicking an item from the asset library failed with
"RangeError: Invalid typed array length" and a createObjectURL error.
Two causes on main:

- ExternalFilesGrid hard-coded draggable={false} and the viewer did not
  install CanvasFileDropHandler, so library items were not draggable.
- Both asset URL modifiers (editor and runtime) dropped everything after
  the asset id, so /kite3d/@id/Camera_01.bin and every texture resolved
  to the root .gltf. The loader then read glTF JSON as a Float32Array.

What changed:
- Library glTF imports download every external buffer and image, keep
  safe relative paths, rewrite unsafe or colliding ones, and register the
  whole bundle in assets.json in one write (rollback on failure).
- assets.json entries can carry a files map from glTF URI to project path.
- The engine exports createProjectAssetURLModifier; editor and runtime
  share it. Unknown files under a registered asset raise a clear error.
- Library drag and drop and double-click placement work again; clones
  keep the persistent root reference without exporting imported children.
- .gltf, .phmatgltf and .hdr are accepted library asset types.

Verification (worktree library-drop, codex GPT 5.6 Sol pass):
- typecheck 3 workspaces, lint 3 workspaces, build: pass
- npm test -w packages/editor: 34/34, run twice
- npm test -w packages/kite3d: 163/163
- engine unit tests: 30/30 (new projectFormat.test.ts)
- new Playwright test "persists a dropped library glTF with its buffer
  and texture" fails without the fix (draggable="false")
- live check against the asset library proxy: Camera_01 imported with
  11 files, 5 meshes, 4 textured, saved and reloaded, zero errors

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The Inspector's source editor was a plain textarea. It now mounts a
CodeMirror 6 view with line numbers, active line, bracket matching,
indentation with Tab, and syntax highlighting through Lezer's
classHighlighter (stable tok-* classes). Languages by extension:
JavaScript (.js, .mjs, .cjs), TypeScript (.ts, .tsx), JSON (.json,
.gltf, assets.json), HTML, CSS, XML (.xml, .mjcf), Markdown. Everything
else opens as plain text. The editor root carries data-language.

Kept: the aria-label "Source editor: <path>", the 1 MiB limit, the
draft and saved states, Cmd/Ctrl+S, Revert, the Reload/Overwrite
conflict flow, and external file changes applied without losing the
scroll position. Known binary formats still show metadata only; other
text extensions are now editable.

CodeMirror loads on demand in its own chunk (540 KB, 187 KB gzip); the
main editor chunk grows by 191 bytes. Editor tarball grows 16 percent.

Verification (worktree source-highlight, codex GPT 5.6 Sol pass):
- typecheck 3 workspaces, lint 3 workspaces, build: pass
- source-editor.spec.ts: 9/9 (editing, language, tokens, binary,
  scroll, save, conflict)
- npm test -w packages/editor: 35/35, run twice
- npm test -w packages/kite3d: 163/163
- lockfile keeps every platform optional entry (24 rollup, 25 esbuild)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…ssword (#5)

`kite3d claim [--no-open]` prints one line per unclaimed deploy,
`Claim <slug>: <claim URL>`, and opens each URL in the default browser
unless --no-open is given. The claim page on blitz.dev signs the user
in with Google and claims the game. The old --email, --password, and
--login options are removed: the backend no longer has a password flow,
and a password on the command line landed in shell history and logs.

The claim URL comes from the publish response and is stored in
.kite3d/deploys.json as claim_url; older entries get one built from
the backend URL, the slug, and the claim secret. Before printing, claim
and status ask the backend for each game and mark entries claimed when
the game no longer expires. That check is best-effort: a failed request
keeps the local state.

Written by the owner's codex session on feat/claim-opens-browser.

Verification (detached worktree at 7cbf603):
- build, typecheck, lint: pass
- npm test -w packages/kite3d: 172/172 (163 on main, 9 new claim tests)
- the existing test still proves a failed publish prints no deploy
  token or claim secret
- CI run 34679398382 on the PR: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
#3)

`kite3d skills` prints one line per bundled skill: the name, a tab,
and the absolute SKILL.md path. `--json` prints [{name, path}]. It
works from any directory with no project, from an npx cache, and from
a global install: the CLI resolves ../skills/ from its own module URL.

The first skill, kite3d-project, tells an agent to read the project
AGENTS.md, run `kite3d doctor` and `kite3d sources`, and read
.kite3d/check.json after `kite3d check`. The skills directory ships in
the package. README, docs/agents.md, and the template AGENTS.md
document the command.

Ported from the draft on proposal/cli-skills (78 commits behind, old
template path), then rebased onto the claim change (#5) with no
conflicts. The draft's command policy table was not ported; the
current command checks stay, and `skills` joins the no-project and
legacy-project exemptions.

Verification (worktree cli-skills, codex GPT 5.6 Sol pass, rerun after
the rebase):
- build, typecheck, lint: pass
- npm test -w packages/kite3d: 178/178 in 12 files (172 on main)
- without the feature five tests fail: "kite3d: Unknown command: skills"
- packed tarball installed into an empty temp directory; `npx
  --no-install kite3d skills` there and the binary from /tmp print the
  same readable path

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…l schema export (#7)

A generator module may export a flat `params` schema: type, label,
help, default, options, min, max, step. Types: boolean, number,
integer, string, select, vector, color, json; an omitted type is
inferred from options or the default. runGenerator parses the export
safely and stores it on the component; the editor passes it through
the generator state the Inspector reads.

The Generator card renders one row per declared param in schema order,
then an inferred row per undeclared saved key, then a collapsed "Edit
as JSON" section with Apply and an inline error. Absent declared params
show their default. Switch and select write at once; other fields on
blur or Enter. Wrong types and out-of-range numbers show an inline
error and do not write. A focused draft survives an external file
change. Invalid schema entries are dropped with one console warning.
docs/agents.md and the template AGENTS.md document the export.

Verification (worktree generator-params, codex GPT 5.6 Sol pass):
- build, typecheck, lint: pass
- engine unit tests 35/35 (30 on main); kite3d 178/178
- npm test -w packages/editor: 37/37, two full runs (36 on main)
- without the feature the new test fails: Shading label not found
- PR CI run: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
A Library drop or double-click opens the "Add library asset" dialog
before any scene change. The target is the selected hierarchy object,
else the object under the cursor, else the scene root; the dialog names
it and says which rule picked it. Models add under the target or at the
root. Materials apply to the selected mesh or to every mesh under a
group. Textures apply to a chosen slot: base color, normal, roughness,
metalness, emissive, occlusion; a multi-material mesh asks which one.
Environment maps set the environment, the background, or both. A misfit
target offers import only.

"Remember my choice for <type>" stores the action and slot in
localStorage (kite3d.editor.dropChoices) and skips the dialog next
time; "Reset drop prompts" in Editor settings clears it. Every action
runs through the undo manager. Cancel and Escape run no command. Finder
file drops keep their immediate behavior.

Verification (worktree library-drop-dialog, codex GPT 5.6 Sol pass):
- build, typecheck, lint: pass
- npm test -w packages/editor: 37/37 in three full runs (36 on main)
- without the feature the new test fails: library-drop-dialog not found
- PR CI run: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…guide (#9)

* feat!: remove Generator components

* docs: replace the generator sections with scene asset management bullets

The guide no longer documents Generator components, bake, or the params
schema. A new Scene asset management section gives concise rules: assets
are files under assets/, the scene is a list of placed assets edited by
scripts, a script that makes an asset lives beside its outputs and runs
by hand, props stay individual nodes with human names, speed work never
changes the files, and assets are registered in assets.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk

* fix(engine): point the removed-generator notice at the guide section

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…PNG (#10)

`kite3d screenshot [--name] [--headless] [--full] [--width] [--height]
[--json]` asks the connected editor to render once and capture its
viewport, composited over the viewport background, and saves the PNG
under .kite3d/screenshots/<timestamp>-<name>.png. The absolute path is
the only stdout line. With no editor connected it falls back to a
headless render through the launcher `check` already uses; --full
captures the whole editor window. The server relays the request over
its event stream with a bounded wait; the editor posts the PNG back.
The guide and the bundled skill tell agents to look before and after
visual changes.

Verification (worktree screenshot, codex GPT 5.6 Sol passes):
- build, typecheck, lint: pass
- npm test -w packages/kite3d: 180/180 (176 on main); new
  screenshot.test.ts covers the connected capture, hidden tab, JSON,
  headless fallback, --full, the timeout, and the composite
- npm test -w packages/editor: 38/38
- by hand: a fresh project with a box, connected capture shows the dark
  viewport and the box
- PR CI: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…ind kite3d open (#13)

The CLI side of the project hub. A project index in
~/.kite3d/projects.json, written by init and every dev start. Git facts
from git: repo root, the shared git directory that groups worktrees,
the worktree list, the branch. `kite3d dev --detach` and `--stop` with
the log in .kite3d/dev.log. `kite3d open` always opens the launcher, a
hub server on port 4320 that serves the editor with no project;
`open --stop` ends it. Hub routes on the hub and on every dev server:
list projects grouped by repo with worktrees, list folders under home,
start, stop, create, add.

Two fixes found while the owner tried it: the token cookie is now
scoped per port (kite3d-token-<port>), because browsers key cookies by
host and two servers overwrote each other's cookie; and start runs any
pinned version's plain `dev --no-open` with the launcher doing the
detaching, because a project pinned to 0.18.0 has no --detach. Failures
answer 502 with the log's last line or 409 with the install
instruction, never a bare 500.

Verification (worktree hub-cli, codex GPT 5.6 Sol passes):
- build, typecheck, lint: pass
- npm test -w packages/kite3d: 191/191 in 17 files
- runtime 25 plus 11, gates 7, editor 38
- by hand: the hub started, answered state, projects, and folders,
  refused a path outside home; start on the owner's real 0.18.0 project
  returned a URL in under a second, its editor loaded, stop ended it
- PR CI: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…rktrees section (#12)

The editor side of the project hub. In hub mode the editor shows the
welcome dialog at once. Its Projects tab: New Project and Open Project,
Active editors, and Projects grouped by repo with a row per worktree.
Open always opens a new tab, starting the server first when needed with
a spinner; Stop stops it. Open Project is a folder list from the local
server with the kite icon on projects; New Project picks the parent the
same way and asks only for a name. The four old file-based actions are
gone. The navbar project button opens a Blueprint menu with the same
lists. The Project tab gets a Worktrees section above Scripts. No
running dots, no ports; a running project shows the RUNNING chip; a
detached worktree shows its short commit; server error text shows
inline.

Verification (worktree hub-editor, codex GPT 5.6 Sol passes):
- build, typecheck, lint: pass
- npm test -w packages/editor: 44/44 (38 on main); new hub.spec.ts with
  real servers and real git worktrees
- npm test -w packages/kite3d: 191/191
- by hand: the launcher ran against the owner's real projects and
  started a 0.18.0 worktree from a click
- PR CI: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The library drop dialog test in dev-server.spec.ts started the material
drag right after the first model drop, before the imported mesh existed
in the group, so on a loaded runner the dialog counted one mesh instead
of two. The test now waits on the real condition: both meshes under the
live group and the imported hierarchy row. No production code changes.

Verification (worktree flake-drop-count, codex GPT 5.6 Sol at medium):
- the fixed test alone with --repeat-each=5: 5/5
- npm test -w packages/editor: 44/44
- PR CI: success on both runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Edit-mode camera keys. `]` doubles the WASD flight speed and `[`
halves it, clamped to 0.0625 through 64, with Shift and Control still
multiplying on top; a SPEED label shows the value for one second and the
value persists per editor in local storage. `/` toggles isolate for the
whole current selection: selected objects, their parents, and their
children stay visible, everything else under the model root is hidden,
lights and cameras never. Isolate remembers and restores every previous
visibility value, persists across selection changes, and exits on the
chip, the menu item, Play, scene load, or edit-mode disable. The
hierarchy right-click menu gets Isolate, or Exit Isolate while active.
Isolate never dirties the scene or adds an undo entry, and a save while
isolated writes the pre-isolate visibility and keeps isolate on. Cmd+S
now reaches Save Scene from the Kite3D toolbar; it was shown but never
wired.

Verification (worktree keys, codex GPT 5.6 Sol at medium, pr-skill
applied: manual headless walkthrough first, screenshots viewed, S1 to
S10 pass, five questions answered):
- build, typecheck, lint: pass
- npm test -w packages/editor: 43/43 (38 on main); new keys.spec.ts with
  5 tests, each naming the assertion that fails without the feature
- npm test -w packages/kite3d: 180/180
- screenshots of every added UI state:
  https://claude.ai/code/artifact/6388d799-3096-4db7-bffa-5cc6dc15a94b
- PR CI: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The scene save dropped hidden objects. serializeSceneGltf exported with
onlyVisible true, so hiding a wall with the eye icon and saving removed
it from the scene file, the source of truth. The exporter now runs with
onlyVisible false and the engine passes one predicate that omits
non-authored objects by identity (excludeFromExport, widget roots,
widgets) whatever their visibility. Hidden objects round-trip through
the existing WEBGI_object3d_extras visible flag. The hierarchy eye icon
reads object.visible, so a reloaded hidden object shows the crossed eye.

Verification (worktree hidden-save, codex GPT 5.6 Sol at medium,
pr-skill applied):
- by hand, headless: hide, save, reload; the node stays in the file
  and the hierarchy shows it hidden; screenshots viewed
- one regression guard, hidden-objects.spec.ts, fails without the fix
  at the node lookup; the serializer unit tests were dropped per the
  no-proactive-tests rule
- build, typecheck, lint: pass; engine 25/25; editor spec 1/1
- PR CI: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…17)

Library drops failed silently, as the owner reported. Three causes,
reproduced on a scratch 0.18.0 project: a drop that landed before the
background download and import finished returned with nothing added; a
failed load was swallowed by the importer's empty result; a material
applied by drop never dirtied the object, so it was never saved. Each
drag now owns one import promise and a landed drop waits for it, with
the spinner up and drag end unable to cancel it. The manager throws the
loader's reason when the importer returns nothing, and the drag and
double-click paths show it in the editor's danger toast with the asset
name. Material apply and undo dirty their targets.

Verification (worktree drop-fix, codex GPT 5.6 Sol at medium, pr-skill
applied):
- by hand, headless: a real pointer drag landing before a five-second
  download adds the model; a 500 on the file route shows the red toast;
  a material drop saves; Escape, two drags, outside drop, reimport, and
  remembered choice with a failed import checked
- three regression guards in library-drop-flow.spec.ts, each failing
  without its fix; the double-click duplicate was dropped per the
  no-proactive-tests rule
- build, typecheck, lint: pass; editor 42/42; kite3d 191/191
- PR CI: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…nt rules (#18)

The CI suites held 275 tests, most of them proactive: written with the
feature, guarding nothing a human saw fail, flaking under load, and
passing on fixtures that rendered nothing. The owner's rule from now
on: a test exists only to guard behaviour that was flaky in manual
testing by a human or a bug a user reported, and proof of a change is
a manual walkthrough with screenshots that show the change. 257 tests
are deleted and 18 stay, each with a comment naming the report or the
manual observation it guards: security guards for token and secret
output, the local server defenses, the Ctrl-C hang, the Linux watcher
miss, split-write journaling, the cookie 401 report, the launcher 500
report, init and the guide contract, the persistence gate, the three
check gates through the editor, the dropped GLB and library glTF
reports, the three drop guards from PR #17, the hidden-objects guard
from PR #16, and the project creation smoke. The racing drop dialog
test is deleted on purpose; PR #17's material guard covers its path.
Unused fixtures and helpers go with the deleted files. The root
AGENTS.md states the repository rules in ten bullets.

Verification (worktree test-cut, codex GPT 5.6 Sol at medium):
- kite3d 9/9, runtime 1/1 with zero unit tests accepted, editor 8/8
- build, typecheck, lint: pass; guide equality: pass
- PR CI: success on both runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
A prerelease channel so testers get alphas without moving latest.
set-version accepts an explicit x.y.z-word.n version. release publishes
a prerelease with --tag next and a stable version with no tag, logs the
full publish command per package, and skips the global agent guide
upload on a prerelease so nobody else's guide changes; runtime
registration is per version and still runs. The CLI orders prerelease
versions (0.18.0 < 0.19.0-alpha.1 < 0.19.0-alpha.2 < 0.19.0), allows
the upgrade from an alpha to the stable with the migration applied
once, and accepts an exact prerelease pin. README and the guide (with
its template mirror) document npx kite3d@next init and upgrade.

Verification (worktree prerelease, codex GPT 5.6 Sol at medium; no
tests added per the repo rule):
- release dry run at 0.19.0-alpha.1: --tag next on all three publishes,
  guide upload skipped, tag v0.19.0-alpha.1
- comparator run on the built CLI for the four orderings and the
  alpha-to-stable upgrade
- test:scripts 7/7; build, typecheck, lint: pass; guide equality: pass
- PR CI: success

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Two independent redundancies on editor hot paths.

1. ViewerInstanceManager dispatched one stateChange per sceneUpdate. The
scene fires one of those per changed object, and 13 components re-render
on each dispatch. React batches a synchronous burst on its own, so a
gizmo drag was never the real cost. Updates that arrive in separate tasks,
the way asset loads and watcher reloads do, are not batched, and each one
paid for a full editor re-render. Collapse them to one dispatch per frame
and cancel the pending frame on dispose.

2. compareMaterials rebuilt flattenMaterial for both materials inside the
per property loop, so a material with P properties was walked 2P times
instead of twice. flattenMaterial recurses the whole material, which made
the scene diff quadratic. That diff is awaited on the PUT of the main
scene file, so it blocks the editor's save.

Measured, same build both sides:
40 updates in separate tasks, React self time 135.7 ms to 0 ms, total JS
511 ms to 76 ms. 20 materials with 64 properties, diff 143.94 ms to
2.85 ms; with 256 properties, 2198.89 ms to 12.11 ms.

Tests: 2 new playwright cases in packages/editor and 4 new vitest cases
in packages/kite3d. 1 and 2 of those fail without the respective change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T623SnndzSVwCSmuQrVRj2
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