Skip to content

v1.14.0 — Room for one more 🗜️ (26-F: the import gate and the decimation worker; the Football template) - #221

Merged
AlexZ005 merged 7 commits into
mainfrom
release/next
Sep 18, 2026
Merged

AlexZ005 merged 7 commits into
mainfrom
release/next

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

Closes roadmaps 25-27 completely. 26-F was the last held phase — it waited through three waves on purpose, because a decimation target had to be a measured number rather than a guess, and #211's rig supplied the measurement.

PR what
#220 26-F: the import budget, the decimation Worker, and one Reduce / Load anyway / Cancel dialog
#218 the Football game def, read from its module, so the Games tab lists it
#219 the 1.13 changelog corrected once that template actually shipped

Evidence

Three new suites green — import-budget 33/33, import-decimate 37/37 (two peers), import-reduce-gate 22/22 — and unit went to 143/143. svelte-check held at 341/47 at every commit.

Decisions worth reading

  • meshoptimizer, not SimplifyModifier, and the reason is measured: SimplifyModifier is quadratic (40k tris 1.0s, 80k tris 4.8s) where meshoptimizer does 200k → 50k in 133ms. It was already in the lockfile. vite.config pre-bundles it, because without that dev reloaded the page on the first reduction.
  • Reversible means the original FILE is retained, not the parsed tree — restoring re-parses it. That holds for the session, not across a reload: the reduced stamp survives, and the menu entry then says plainly that the original is no longer held instead of silently failing.
  • Draw calls are not reducible, so no merge option is offered rather than one that cannot deliver.

A replication bug this lane found outside its own scope

commandsHandler.sendObjects never sent userData on the ROOT group message of a placed import, so a root stamp — AI-generation provenance, and now reduced — never reached peers at all. It now sends the same additive field the nested-group branch already did.

Known reds, each confirmed identical on base

animated-models "peers in phase" · fbx-mtl-import ".mtl texture resolved to a data URL" · three import-duplicates Settings checks · explorer-drop "prefab replicated in place on B" · undo "the placed position replicates" · and newly added to the known list, origin-rows "B has the cubes in the group" and "peer B agrees", which fail identically with commandsHandler at base.

🤖 Generated with Claude Code

AlexZ005 and others added 7 commits September 19, 2026 01:26
- b8d1b7c rewrote the entry to say no template was published, which was
  true then; the template went live 2026-09-19 (PR #218 + scenes v2 tag)
- the entry now names both ways in: Templates > Games > Football and the
  toolbox's Build pitch
- a FORWARD correction on release/next: v1.13.0 stays as tagged, and the
  in-app What's new (rendered from the tag) keeps the old wording until 1.14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[docs] 1.13 changelog: Football now has its Games-tab template
…rs the scene

- THE FINDING: a model file went from the file dialog straight into the scene. The
  wire gate (26-C) and the .tpscene file-open ask count OBJECTS, and one model is one
  object, so a 2.4M-triangle scan passed every gate there was.
- importBudget.js (a LEAF, pure): modelCost walks a parsed tree for triangles,
  vertices, draws (per material group), the largest single mesh, and texture bytes
  counted once per SOURCE; importVerdict judges "the scene plus this model" with
  sceneBudget's own tierOf on the SAME table the meter reads - no second rule.
  RENDER_PASSES = 2 (26-E measured: the shadow pass draws every mesh again), which
  puts the prediction in the meter's unit - asserted against the real renderer
  (predicted 399,420 tris/frame for a 200k GLB, measured +400,092).
- sceneBudget.js: IMPORT_BUDGETS (largest mesh 500k/100k verts, largest texture
  4096/2048px, texture memory 512-1024/256-384MB) beside BUDGETS so tierOf stays
  the one rule; they are not meter rows (nothing samples them per 500ms).
- WHEN IT ASKS: an axis that is red AND this model took it there, or a model heavy
  on its own. An already-red scene does not ask about every cube dropped into it.
- importGate.js: admitModel asks through the app's ONE confirm dialog (the same
  showChoice the file-open ask uses) - "Load anyway / Cancel", with an extraChoices
  seam phase 3 fills. fileHandler: parseModelFile split out of importFile (parse ->
  ask -> place); Cancel disposes the parsed tree and leaves the scene byte-identical
  (no object, no undo entry, nothing sent). importGeneratedGlb asks too (rejects on
  Cancel so the AI job reports it). Debug hook: importBudget + importGate (211/211/211).

Counterfactuals (suite import-budget):
- gate call removed from importFile -> 3 red + the crossing dialog never opens
- RENDER_PASSES = 1 -> 8 red (prediction, headset case, crossing, calls, meter agreement)
- already-red clause removed -> the "does not ask about every smaller model" check red

Suites: import-budget 33/33 (new). Held vs base (A/B'd with src reverted):
ingest-gate 29/29, scene-budget 34/34, animation-persist 22/22, dispose 14/14 green;
animated-models (peers in phase), fbx-mtl-import (.mtl data URL), import-duplicates
(3 Settings checks) red IDENTICALLY on base; explorer-drop "DOM drop routes through
the Explorer handler" flakes on base too (1 of 3 base runs). Unit 143/143.
svelte-check 341/47 (= base). Build green with the server stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ake back

- decimateWorker.js: a module Worker (Vite `new URL(..., import.meta.url)`), one job per
  fresh Worker, typed arrays TRANSFERRED both ways. The main thread only copies arrays
  out and builds geometry back (9-13ms for a 1.2M-triangle model; the simplification
  ran 1.4s in the Worker while frames kept coming, longest gap 17ms).
- THE DECIMATOR IS meshoptimizer, NOT three's SimplifyModifier (plans/QUESTIONS-26-f Q1):
  measured quadratic here - 40k tris 1.0s, 80k 4.8s, so 26-E's 200k case ~30s and a
  2.4M scan hours. meshoptimizer 1.1.1 (MIT, wasm inside the JS, already in the lockfile
  via @types/three) did 200k -> 50k in 133ms keeping normals+uvs and reports its error.
  Now a direct dependency; pre-bundled in vite.config (dev otherwise reloaded the page
  on the first reduction - "optimized dependencies changed").
- decimateCore.js (imports NOTHING; runs in the Worker AND in node): weld a soup by
  position+uv first (unwelded, nothing collapses), simplify each material group
  separately with LockBorder (no crack, no triangle changes material), compact the
  vertex buffer, report the error. planTargets (scene room shared in proportion,
  per-mesh vertex cap, 10% floor, meshes <1000 tris untouched) and planTextureCap.
- decimate.js (leaf): reduceModel(root, plan) in place - same meshes, same uuids,
  new geometry; skinned/morph meshes skipped WITH the reason; __topo dropped; textures
  above the ceiling drawn down on the SAME Texture (flipY/wrap untouched).
  importBudget.reductionPlan: aim each asking axis at the edge of red (5% margin), an
  already-red scene at the green ceiling alone.
- fileHandler: importFile(..., {reduce}) places a reduced import with a `reduced`
  stamp on the root (additive userData), RETAINS the original File (LRU, 256MB) and
  restoreOriginalImport swaps the original back at the same uuid (children too when the
  tree shape is unchanged). The swap is a new `importswap` history kind holding the LIVE
  objects - registered in fileHandler, which nothing in history's static subtree
  reaches (walked: 56 modules, none of ours). Replicates as the ordinary delete + GLTF
  object sync.
- FOUND ON THE WAY (both measured red first):
  · the create/delete kinds' undo re-sends toJSON plain number arrays; binarypack
    overflows past ~40k numbers and broadcast swallows it - a first version built on
    them left the peer with NOTHING at that uuid after Ctrl+Z (6k-tri model). Hence the
    importswap kind. Pre-existing for any undo of a medium mesh delete (handover).
  · commandsHandler.sendObjects: the ROOT group message of a placed import carried no
    userData, so a root stamp (this `reduced`, AI `aiGen` provenance) never reached a
    peer. Now the same additive field the nested-group branch already sends.

Counterfactuals (suite import-decimate unless noted):
- simplify on the main thread instead of the Worker -> frames 4 in 1362ms, longest gap
  1333ms (2 red)
- weld skipped -> soup 14160 -> 14160 (e2e red + unit red)
- no LRU eviction -> retention check red (true,true,true)
- restore via create/delete batch instead of importswap -> "B follows the undo" red,
  B held [] (measured during development)
- root group message without userData -> "stamp rides the ordinary sync" red (measured)
- the long-task observer read 0ms even in the main-thread counterfactual; the check is
  now the rAF gap, which is what a frozen tab actually is

Suites: import-decimate 37/37 (new, two peers). Held: import-budget 33/33, ingest-gate
29/29, object-sync 12/12, dispose 14/14, net-handshake 9/9, shader-graph 67/67; undo
"the placed position replicates" red on base too, "redo restores the pose" a two-peer
flake (green 4 of 5 on this tree); animated-models "peers in phase" red on base.
Unit 143/143 (decimateCore 10 new). svelte-check 341/47 (= base). Build green with the
server stopped; the Worker is emitted as its own chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… / Cancel in one dialog

- ONE STORY: the Reduce choice is a third way out of the SAME dialog phase 1 opens (the
  file-open ask's showChoice), never a second dialog. Its label is the quality judgement
  made visible before anyone commits: "Reduce to ~9,500 triangles" / "Reduce to 4096px
  textures", from the same planner that then does it (plannedTriangles/textureCapFor).
- Offered only where it can act: a draw-call ask gets no Reduce (decimation keeps every
  mesh); an animated model gets none and the dialog SAYS why (its rig reaches peers as
  the original file); a texture-only ask reduces textures and leaves geometry alone.
- While it runs: a "Reducing X... you can keep working" card (the Worker is the point).
  After: a report - "20k -> 9,500 triangles (-52%), no point moved more than 0.04% of
  its size", texture sizes, skipped meshes and WHY, "still above ..." when the 10%
  floor could not reach the budget - with Restore original IN the report.
- The way back outlasts the toast: the object menu carries "Restore original model"
  while the original is held; a copy whose original is not held (a peer, after a
  reload, evicted) shows it DISABLED with the reason. The retained-originals map moved
  into decimate.js (a leaf) so the menu can ask synchronously; the action reaches
  fileHandler by dynamic import (objectMenu stays out of the history family).
- importGeneratedGlb offers the same Reduce (the AI mesh path is an import too).
- importGate.admitModel gained `note` (one sentence for why an option is absent).

Counterfactuals (suite import-reduce-gate):
- no extraChoices -> "ONE dialog, three ways out" + label checks red, run aborts at the click
- extraChoices also for animated models -> the animated check red
- menu entry enabled whether or not the original is held -> the peer-copy check red
- report toast without Restore original -> "offered IN the report" red, run aborts

Suites: import-reduce-gate 22/22 (new). Held: import-decimate 37/37, import-budget
33/33, ingest-gate 29/29, object-sync 12/12, dispose 14/14, multiselect-menu 11/11,
convert-to-mesh 57/57, save-as-formats 39/39; origin-rows "B has the cubes in the
group" + "peer B agrees" red identically with commandsHandler at base (2 of 2 runs).
Unit 143/143. svelte-check 341/47 (= base). Build green with the server stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[feat] 26-F: import budget + decimation Worker (Reduce / Load anyway / Cancel)
…ll template

Named at write time rather than left as "## Unreleased", per RELEASING.md - an
unnamed heading is what got the hardening entries filed under a shipped release.

26-F was the last held phase of roadmaps 25-27: it waited three waves for the
measuring rig so that a decimation target would be a measured number rather than a
guess. With it in, that whole batch is closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit 1e598a4 into main Sep 18, 2026
8 checks passed
@AlexZ005
AlexZ005 deleted the release/next branch September 18, 2026 23:59
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