Skip to content

fix(core): legacy scenes with raw nodes load without crashing - #979

Merged
wass08 merged 1 commit into
mainfrom
fix/legacy-load-raw-nodes
Sep 30, 2026
Merged

wass08 merged 1 commit into
mainfrom
fix/legacy-load-raw-nodes

Conversation

@wass08

@wass08 wass08 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Cause

The load migrations added by #976 read stored nodes as if they had been schema-parsed. The oldest stored scenes don't have every field the schema defaults. The private CI fixture legacy-parent-links.test.ts > documentationZone shows it: its walls have no children, and its doors and windows are placed by a legacy offset with no position. Two crashes followed:

  • plateLevelContext read wall.children.flatMap (lib/plate-surface.ts), reached from migrateExteriorThresholds inside reconcileStructureOnLoad.
  • isFloorAnchoredOpening read opening.position[1] (lib/floor-opening-footprints.ts), reached from migrateFloorPlates.

Both the hosted authority (normalize-authority-scene) and the client setScene threw, so any such project failed to open. Prod isn't affected yet because private hasn't bumped to #976.

Fix

One mechanism, applied to every exported load migration: loadMigration / loadMapMigration in core/src/utils/load-migration.ts.

  • Read view. Each migration reads the stored nodes with their container schema defaults filled in: arrays such as children, holes, holeMetadata, position, rotation, boundaryWallIds, and empty objects such as metadata. Scalars stay absent, because migrations read their absence as meaningful: slab thickness/elevation, level height, floorThresholdVersion. A missing container means exactly its default, since the reload after materializeNodeDefaults sees it filled and must migrate the same way.
  • No writes from the view. If a migration carries a fill through untouched (same instance, same content), it's stripped from the output, so no stored node is rewritten by it. When nothing else changed, the stored map identity comes back, so the authority's no-op detection and idempotence still hold. Anything the migration wrote itself, or grew in place, stays.
  • Safety net. If a migration still throws, it's reported ([scene load] <name> failed…) and skipped. The scene then loads as main loaded it, instead of failing. migrateFloorPlates keeps its per-level guard inside this.

Wrapped: legacy material slots, vertical, room zones, ceiling room links, legacy auto openings, floor plates (including piece adoption), slab slots, stair/elevator openings, wall face keys, wall face bands, floor openings, owned floor openings, and structure reconcile on load (including exterior thresholds, legacy wall datums, and footprint following). The legacy map passed to reconcileStructureOnLoad goes through the same view. materializeNodeDefaults, normalizeLegacyStructure, healSceneNodes and removeRetiredDrawingSheetNodes already tolerate raw nodes.

Legacy door offset: main never converted it. The client parsed the door with its schema default position: [0, 0, 0] and left offset in place, and the authority stored neither. That behaviour is unchanged.

Also: Bugbot finding on #976

deleteZone's levelPose placed a wall-hosted item with the curve frame, but took the yaw from the wall chord. Items kept from a curved wall therefore got the wrong rotation when their room was deleted. It now uses the curve tangent at the item's station, the same way opening-floor-datum.ts does.

Tests

  • utils/raw-legacy-load.test.ts
    • Regression: a minimal raw legacy scene shaped like the private fixture (walls without children, door and window with offset only, a vertices slab, a documentation-only zone, a level without height). It loads through the authority's migration sequence and through setScene without throwing and without any migration falling back. Every stored node is retained, the source isn't mutated, and a second load is idempotent. The door stays as main left it: client position: [0, 0, 0] with offset: 1, and the authority writes no position.
    • Gate: a seeded fuzz over 6 corpus fixtures plus that scene. It strips every schema-optional field at rates 1 / 0.5 / 0.25, loads through both pipelines, and asserts no throw and no fallback.
    • The whole file fails on main (9/9) and passes with the fix.
  • utils/load-migration.test.ts: view fills, restore semantics (carried-through fill vs written value vs value grown in place), and the fallback.
  • commands/structure/delete-zone.test.ts: an item kept from a curved wall faces along the tangent. It fails before the fix.

Verification

  • Suites: core 4243, nodes 5208, viewer 465, editor 1906, mcp 485, all passing.
  • tsgo --noEmit clean for core (plus contracts), nodes, viewer, editor and mcp. Manual tsgo dist build of core, nodes, viewer and mcp.
  • World gate (private bench): --set golden 126 scenes, 0 blocking. --set broad 496 scenes, 0 crashes. Both were also run once with the fallback turned into a rethrow, and neither changed, so no migration falls back on any of the 622 real scenes.
  • Private legacy-parent-links.test.ts against these dists: 2/2 pass (documentationZone failed before). The whole private collaboration/__tests__ directory passes: 286.

🤖 Generated with Claude Code

https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG


Note

Medium Risk
Touches the full scene load migration pipeline and persistence shape restoration; failures are swallowed per migration but incorrect restore logic could change stored graphs or skip migrations silently.

Overview
Legacy scene load safety is the main theme: new loadMigration / loadNodeView in load-migration.ts runs each structure load migration on a view with container schema defaults (children, holes, position, etc.) filled in, while scalars stay absent so migrations can still read “missing” fields. Untouched view-only fills are stripped on output so stored JSON is not rewritten for no-op paths; thrown migrations log [scene load] … failed and the pipeline continues with the original nodes.

Wrapped migrations include floor plates, room zones, vertical model, material slots, wall faces, floor/opening reconcile, and related helpers—same public exports, safer behavior on raw stored nodes.

deleteZone / levelPose now subtracts yaw from the wall curve tangent at the item’s station (not the straight chord), so items kept when a room is deleted stay correctly oriented on curved walls.

Tests add load-migration.test.ts, end-to-end raw-legacy-load.test.ts (authority + client, optional-field fuzz), and a curved-wall keep-item case in delete-zone.test.ts.

Reviewed by Cursor Bugbot for commit f8287f8. Bugbot is set up for automated code reviews on this repo. Configure here.

The load migrations added by #976 read stored nodes as if schema-parsed.
The oldest stored scenes omit container fields the schema defaults (walls
without `children`, openings placed by a legacy `offset` with no
`position`), so `plateLevelContext` and `isFloorAnchoredOpening` threw and
both the hosted authority and `setScene` failed to open the project.

Every exported load migration now runs through `loadMigration`: it reads a
view of the stored nodes with container defaults (arrays and empty objects)
filled, never scalars whose absence migrations read, and strips the fills it
carried through untouched so no stored node is rewritten by them. A
migration that still throws is reported and skipped, so the scene loads as
main loaded it instead of failing.

Also: deleting a room re-poses items kept from a curved wall with the curve
tangent at the item, not the wall chord (Bugbot on #976).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
@pascal

pascal Bot commented Sep 30, 2026

Copy link
Copy Markdown

I hit an error while handling your request (Model unavailable on AI Gateway free tier: Free tier users do not have access to this model. Upgrade to paid credits at https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dtop-up for unrestricted…).

Please try again, rephrase, or reach out if it keeps failing.

Error id: ccf2d685-266c-49d4-ab7a-c91ad3225923

@wass08
wass08 merged commit 5666090 into main Sep 30, 2026
4 checks passed
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