Skip to content

Boundary import + layout model, metric elevation migration, content toggles, segment slope - #1792

Merged
kfarr merged 13 commits into
mainfrom
claude/elevation-level-boundary-parity-crppiu
Jul 5, 2026
Merged

Boundary import + layout model, metric elevation migration, content toggles, segment slope#1792
kfarr merged 13 commits into
mainfrom
claude/elevation-level-boundary-parity-crppiu

Conversation

@kfarr

@kfarr kfarr commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #1733 (includes its managed-street building import work, rebuilt on the canonical boundary object). Addresses #1775 and #1774; part of the building-parity epic #1286 / #1720; lays groundwork for #1751. Up to date with main.

Boundary layout model (buildingboundary)

The segment type is renamed boundary — the better term for the set it covers (buildings, waterfront, fences, parking lots). More importantly, the layout model changed:

  • The travelled way is the only layout input. street-align centers the travelled way alone; street-ground and street-label span it alone. Boundary segments are positioned outside the travelled way's edges by their side property (stacking outward if multiple), whether visible or not.
  • Nothing ever moves: adding, removing, hiding, or showing a boundary cannot shift the street. The sidebar toggle and the scene-graph eye are pure visibility.
  • Ends-only by construction: a boundary renders at the street edge regardless of its position in the segment list (structural editing rules remain with Move a managed street segment left/right (reorder segment index) #1751).
  • This anticipates the future centerline mechanism: the travelled way will anchor to centerline points; boundaries stay edge-derived.
  • Load-time migrations for saved scenes: type: buildingtype: boundary and showBuildingsshowBoundaries (string + object forms, unit-tested); json-blobs accepting legacy building too. The legacy street component's processBuildings path is untouched.

Elevation: integer level → metric elevation

Streetmix schema 33+ stores elevation in meters; we were down-converting to a lossy integer "curb level". Killed before beta exit:

  • street-segment.elevation (number, meters) replaces level (int); presets updated (10.15); calculateHeight() takes meters; CURB_HEIGHT kept only as the migration constant
  • convertStreetValues() normalizes pre-33 integer levels to meters; 33+ passes through
  • Hard cut-over on save; legacy scenes migrate at load time (migrateSegmentLevelToElevation in createEntityFromObj, levelToElevation fallback in parseStreetObject)
  • Negative elevations intentionally unsupported: schema min: 0, render clamp, legacy negative levels migrate to 0
  • Sidebar shows "Elevation (m)" (localized); AI tool schemas, presets, StreetPlan path all swept

Boundary object import (#1775)

  • getBoundaryFromStreetData() reads the canonical v34 boundary.left/right (variant/floors/elevation in meters) with flat-field fallback; boundary wins when both present
  • Boundary elevation applies to the segment (coastmix raised edges import correctly); floors persists via a new schema prop (metadata only; height mapping is an open question)
  • Unknown variants warn and skip; legacy streetmix-loader reads the boundary variant too

Toggles: showBoundaries, showGround, showStriping, showVehicles

Migrated from the legacy street prototype (skipping globalAnimated) as managed-street schema props with sidebar rows. Realtime, non-destructive, visibility-only:

  • Boundaries: always imported, toggle hides/shows in place (no reflow — see layout model)
  • Ground: hides the dirtbox · Striping: hides generated striping planes · Vehicles: hides vehicle/cyclist clones by mixin catalog category (pedestrians untouched)
  • A subtree observer re-applies content visibility to late-generated clones while a toggle is off
  • Creation functions/AI tools that already passed these flags now actually take effect

Segment slope (#1774, coastmix v34)

  • getSegmentSlope() parses slope: {on, values: [start, end]} (meters); seeded v34 defaults stay flat
  • below-box tilts its top face between the two edge heights; entity sits at mean height (generated content at mean elevation); round-trips through save/load
  • Direction assumption: values[0] → segment start (−x) edge; confirm against a live coastmix export

Drive-by fix

wobble-geometry-box.play() used object3D.children[0].geometry — crashes entity load when clone Groups attach before the surface mesh (waterfront boundaries after main's clone changes). Now getObject3D('mesh') + object3dset retry.

Verification (headless Chromium against the dev server)

  • Layout invariance: 17.4 m travelled way — sidewalk x = −7.5 and ground = 17.4 m across ALL states (boundaries on / off / one eyed-on / a boundary reparented mid-DOM); boundaries hold x = −13.7 / +10.7 throughout
  • Triple legacy migration in one pass: a scene with type: building; level: 1 + showBuildings: false loads as a boundary at elevation: 0.15 with showBoundaries: false
  • Boundary import (floors 2/3, 0.15 m), content toggles (striping/vehicles/ground, pedestrians untouched, late-regenerated clones stay hidden), slope tilt (+0.075/−0.075 at mean 0.225 m), metric elevation math — all exercised live

Tests

  • v34 fixtures: coastal-boundary-street.streetmix.json (synthesized; keeps slope coverage) + kfarr-demo-street-v34.streetmix.json (real streetmix.net export of kfarr/3, fetched 2026-07-04 — covers the boundary object and metric elevations against production data; the live export has no sloped segments, hence keeping the synthesized one)
  • 88 mocha + 368 vitest + 30 component tests passing locally; lint + prettier + i18n:check clean; CI green including test:components
  • Parity harness run locally against the dev server (19 fixtures, SHOW_BUILDINGS=false): exit 0; coastal-boundary-street is the closest match of all at 2.37% pixel diff; higher outliers (marina-parking 19%, kfarr-demo-v34 16.6%) are the known parking non-parity and random clone placement noise on content-heavy streets
  • Docs PR: Update managed-street docs for elevation/boundary format 3dstreet-docs#91 (JSON format + component pages; hold until this deploys)

Open questions for review

  • floors → model height mapping (proposal: pick models by floor count from catalog as follow-up)
  • compound-wall aliases seawall; beach/other new coastmix variants unmapped (warn-and-skip); boundary id not persisted
  • Slope editing UI not included — import/round-trip only
  • Existing-scene layout shift: pre-PR layout included visible building segments in centering; the new model centers the travelled way alone. Old saved scenes with visible asymmetric-width boundaries re-center on load, shifting the travelled way by (leftW−rightW)/2 relative to other layers (matters most for geo-anchored scenes). Scope confirmed narrow: main's managed streetmix import never creates buildings, so streetmix-sourced scenes are unaffected. Saved scenes with buildings exist only via the "Building Placement Demo" add-layer card (6.5 m/6 m asymmetry → 0.25 m shift), saved StreetPlan imports, or a manual type change. Judged acceptable-by-design
  • One-way migration: scenes re-saved by this version (elevation/boundary) will not render correctly if opened by a pre-PR build (segments flatten to level 0, unknown segment type) — relevant to rollbacks and self-hosted older deployments
  • Structural ends-only editing rules (drag-restrictions in scene graph) → Move a managed street segment left/right (reorder segment index) #1751
  • Accepted behaviors from manual testing (keep as-is for now): the Boundaries toggle on a street with no boundary segments is a no-op (it does not create blank boundaries — possible future affordance); two boundaries on the same side stack outward, which is fine since >1 per side is unintended/undefined

🤖 Generated with Claude Code

https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs

kfarr and others added 4 commits June 23, 2026 21:31
default building lot widths need work
showBuildings is an import-time conversion argument on
loadAndParseStreetmixURL (not a schema prop). Parity harness drives both
legacy and managed with a unified SHOW_BUILDINGS constant.

Known issue: the managed parity trigger now calls loadAndParseStreetmixURL
before street-align/street-ground load, racing under the full sequential run
(pageerrors + buildings leakage). See docs/managed-street-buildings-wip.md.
Elevation: replace the deprecated integer `level` (1 level == 0.15m curb
height) with metric `elevation` everywhere, matching Streetmix schema 33+:

- street-segment schema: `elevation` (number, meters) replaces `level` (int);
  TYPES presets updated (sidewalk/building 1 -> 0.15)
- calculateHeight() takes meters directly; new levelToElevation() and
  migrateSegmentLevelToElevation() helpers; CURB_HEIGHT kept only as the
  level<->meters migration constant
- convertStreetValues() now normalizes pre-33 integer levels to meters and
  passes 33+ metric elevation through (previously the reverse, lossy)
- legacy `street` parser derives its integer-level lookup indices locally
  via metricElevationToLevel()
- serialization writes `elevation` only; legacy saved scenes are migrated at
  load time in createEntityFromObj (prop-string and object forms) and in
  parseStreetObject for legacy json-blobs
- editor sweep: SegmentAdd/Update commands, AI tool schemas + prompt
  examples, defaultStreets presets, segment sidebar (Elevation (m))

Boundary parity (#1775, part of #1286/#1720): import building edges from
the canonical schemaVersion 34+ `boundary` object, falling back to the
deprecated flat leftBuildingVariant/rightBuildingVariant fields:

- new getBoundaryFromStreetData() normalizes both shapes
  ({ variant, floors, elevation-in-meters })
- managed-street building import applies boundary elevation (meters) to the
  building segment and persists `floors` as a new street-segment schema
  property (metadata only; does not drive model height yet)
- legacy streetmix-loader path reads the boundary variant too
- unknown boundary variants still warn and are skipped

Tests: v34 boundary fixture (coastal-boundary-street), metric elevation
math, level->elevation migration, boundary normalization/fallback, and
updated schema-version expectations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
claude added 7 commits July 3, 2026 18:43
showBuildings is now a managed-street schema property (default true),
available at layer creation time (parity harness, AddLayerPanel, AI tools
via entityUpdate) and as a Buildings checkbox in the managed street
sidebar.

Semantics: imports always create the boundary building segments when the
source has boundary data; the property only controls whether they show.
Toggling is realtime and non-destructive — off hides the building
segments (visible: false, they stay in the DOM and serialize with the
scene), on restores them. New street-layout-utils.getLayoutSegments()
makes street-align, street-ground, and street-label treat hidden
buildings as absent, so toggling off re-centers the street, shrinks the
ground slab, and drops the building width labels — identical to a street
imported without buildings. Non-building segments are always included in
layout regardless of visibility so hiding a lane doesn't reflow the
street.

loadAndParseStreetmixURL keeps its optional showBuildings argument for
direct callers but now writes it back to the component property so state
stays consistent; the parity harness passes the toggle as component
state instead.

Verified end-to-end in headless Chromium against the dev server with the
v34 boundary fixture: toggle off re-centers the travelled way (sidewalk
x -4.5 -> -7.5) and shrinks the ground (31.4m -> 17.4m street width),
toggle on restores exactly; saved JSON carries showBuildings plus the
hidden state; legacy level json-blobs still migrate to metric elevation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
…lope

Toggles (from the legacy street component prototype, minus globalAnimated
which has no managed equivalent yet): showGround, showStriping, and
showVehicles join showBuildings as managed-street schema properties with
sidebar rows. All are realtime and non-destructive — content is always
generated, the toggles only control visibility. Vehicles are identified
by their mixin's catalog category (same approach as the legacy
street-utils helpers, now scoped to the street entity); striping targets
the planes created by street-generated-striping; ground hides the
street-ground dirtbox. A subtree observer (no-op while toggles are on)
re-applies visibility to late-generated content, so clones regenerated
after a segment edit stay hidden while a toggle is off. The creation
functions and AI tools that already passed showVehicles/showStriping into
managed-street now actually take effect instead of being silently
dropped.

Segment slope (#1774, coastmix schema v34): parse slope { on, values }
where values are [start, end] elevations in meters; flat for slope.on
false (the seeded v34 default), so nothing changes for existing streets.
New getSegmentSlope() normalizes/validates the upstream shape and
calculateSlopedHeights() computes the mean surface height plus per-edge
deltas; the below-box geometry displaces its top-face vertices so the
surface tilts between the two edge heights while the box bottom stays on
the dirt layer. Generated content sits at the mean elevation for now.
slope/slopeStart/slopeEnd round-trip through save/load and
getManagedStreetJSON.

Verified end-to-end in headless Chromium: sloped fixture segment renders
at mean height 0.225m with top face tilting +0.075/-0.075 across its
width; striping (3), vehicles (12), and ground toggle off/on in realtime
while 22 pedestrians stay untouched; clones regenerated with showVehicles
off are created hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
Conflict resolutions:
- src/tested/* and test/core/*: main converted these modules to ESM;
  kept this branch's content (metric elevation, boundary, slope helpers
  and their tests) in main's export/import style
- aframe-streetmix-parsers.js: inlined the metric->integer level
  derivation instead of requiring the now-ESM streetmix-utils (this
  deprecated CJS parser must stay require()-able by the mocha tests)
- StreetSegmentSidebar.jsx: kept the Elevation (m) row, adopted main's
  react-intl label pattern (segmentSidebar.elevation replaces
  segmentSidebar.curbLevel across the locale files)
- compare-imports.mjs: main moved the parity harness to Playwright;
  kept the SHOW_BUILDINGS parameterization for both import paths and
  adapted the evaluate/waitForFunction calls to Playwright signatures
- editor/i18n/config.js: brace a long single-line if that main
  committed unformatted — lint-staged's prettier pass rewraps it into
  a curly violation that blocked any commit staging the file

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
wobble-geometry-box.play() read this.el.object3D.children[0].geometry —
positional indexing that breaks when child entities attach their Groups
before the surface mesh exists. After main's generated-clones changes,
the seawall clones on a waterfront building segment land in object3D
ahead of the water surface mesh, so children[0] had no geometry and the
throw aborted entity load (the managed street's import promise then
never resolved). Use getObject3D('mesh') instead, retrying via
object3dset when the mesh isn't built yet, and guard tick until the
waves exist.

Found by re-running the headless-Chromium verification after merging
main: the coastal fixture import hung with the waterfront building stuck
in pendingEntities. All checks pass again post-fix (slope tilt, content
toggles, buildings toggle, legacy level migration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
Negative elevations (below road level) are intentionally out of scope
for now. calculateHeight already clamped them to the base surface depth
at render time, but the intent was implicit and negatives could still
ride along in data. Codify it:

- elevation / slopeStart / slopeEnd schema get min: 0 (editor number
  inputs stop at road level)
- levelToElevation clamps negative legacy levels to 0, so a saved
  level: -1 migrates to elevation: 0 — the same rendered result it had
  before, now with clean data
- comments on the schema and the calculateHeight clamp state the
  decision; the AI segment tool schema says negatives are unsupported
- tests updated to assert the clamped values

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
The layers-panel eye icon and the managed-street showBuildings toggle
both write the building segment's `visible` component, but only the
sidebar path re-ran layout. Re-showing a building via the eye while
showBuildings was off made it appear at the stale position it held
before the street re-centered — on top of the travelled way.

Treat any change to a building segment's `visible` as a layout change:
each segment gets a componentchanged listener (the event does not
bubble, so a parent listener never fires) attached at managed-street
init for saved scenes and via the child mutation observer for segments
added later. The handler re-emits segments-changed so street-align,
street-ground, and street-label recompute — the re-shown building slots
in at the street edge and the ground/labels grow to match; hiding one
building via the eye symmetrically shrinks the layout. Bulk updates from
updateBuildingVisibility suppress the per-segment handler and emit one
event as before.

Verified in headless Chromium: sidebar off then eye-on left building
repositions it (x -10.7 -> -8.7), re-centers the street (sidewalk
-7.5 -> -2.5) and grows the ground 17.4m -> 27.4m (street + that one
building) with edges exactly adjacent; eye-off shrinks back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
Rework how flanking land use relates to street layout, and rename the
segment type from 'building' to 'boundary' (the better term for the
wider set it covers: buildings, waterfront, fences, parking lots).

Layout model: the travelled way is the only layout input. street-align
centers the travelled way alone; street-ground and street-label span it
alone; boundary segments are positioned OUTSIDE the travelled way's
edges by their `side` property (stacking outward if multiple), whether
visible or not. Consequences, all verified live:

- adding, removing, hiding, or showing a boundary never moves the
  street — the toggle and the scene-graph eye are pure visibility, so
  the previous eye-toggle re-layout machinery (per-segment
  componentchanged watchers) is deleted, not just bypassed
- boundaries render at the street ends regardless of their position in
  the segment list — ends-only by construction (structural editing
  rules remain with #1751)
- this also anticipates the future centerline mechanism: the travelled
  way will anchor to real-world centerline points, boundaries stay
  edge-derived

Rename: street-segment type 'boundary' replaces 'building'
(TYPES.boundary, schema oneOf, side/variant behavior checks, sidebar
gates + Boundary Variant label with locale updates, AI tool schemas,
default street presets, StreetPlan Buildings mapping, import creator +
"Boundary • variant (side)" layer names). managed-street's toggle is
now `showBoundaries`. Load-time migrations cover saved scenes:
`type: building` -> `type: boundary` and `showBuildings` ->
`showBoundaries` (string + object forms, unit-tested); parseStreetObject
accepts legacy 'building' in json-blobs. The legacy street component's
processBuildings path is untouched.

Verified in headless Chromium: with a 17.4m travelled way the sidewalk
sits at x=-7.5 and ground stays 17.4m across all states (boundaries on,
off, one eyed-on, and a boundary reparented into the middle of the DOM);
left/right boundaries hold x=-13.7/+10.7 throughout; a saved scene with
'type: building; level: 1' + 'showBuildings: false' loads as a boundary
at elevation 0.15 with showBoundaries false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eU9iRHukcxHhvXxDFjeSs
@kfarr kfarr changed the title Streetmix boundary object import + migrate elevation from integer levels to meters Boundary import + layout model, metric elevation migration, content toggles, segment slope Jul 5, 2026
The live streetmix.net export has no sloped segments, so the synthesized
coastal fixture stays as the slope coverage; the real export covers the
boundary object and metric elevations against production data.
@kfarr

kfarr commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Manual test plan (pre-merge)

Setup: npm start, open http://localhost:3333 in a fresh profile (or clear local scene state).

1. Streetmix import with boundaries

  • New scene → Add Layer → Streetmix import → https://streetmix.net/kfarr/3
  • Street loads with a waterfront (left) and fence (right) boundary; water surface animates
  • Scene graph shows Boundary • waterfront (left) first and Boundary • fence (right) last
  • Select each boundary: sidebar shows "Boundary Variant" + Side; Elevation (m) = 0.15; floors persisted (2 left / 3 right in the properties panel / saved JSON)

2. Layout invariance (the core behavioral guarantee)

  • Note the x-position of the leftmost sidewalk and the ground extent
  • Toggle Boundaries off/on in the managed street sidebar → street and ground never shift; only boundaries hide/show
  • Hide a single boundary via the scene-graph eye icon → same: no reflow
  • Drag a boundary to the middle of the layer list → it still renders at the street edge (position in list is cosmetic)
  • Street label (Labels toggle on) spans only the travelled way, not the boundaries

3. Content toggles

  • Ground toggle hides/shows the dirt slab
  • Striping toggle hides/shows lane striping only
  • Vehicles toggle hides vehicles + cyclists but leaves pedestrians
  • With Vehicles off, change a drive lane's width (forces clone regeneration) → new clones stay hidden
  • All four toggles survive save + reload

4. Elevation editing

  • Select a sidewalk → "Elevation (m)" shows 0.15; set to 0.3 → segment raises visibly
  • Try entering a negative value → rejected/clamped to 0
  • Undo/redo the elevation change works

5. Slope (import + round-trip only)

  • Streetmix URL dialog → http://localhost:3333/test/parity/fixtures/coastal-boundary-street.streetmix.json
  • Last segment renders tilted (0.15 → 0 across its width); generated content sits at mean height
  • Save scene, reload → tilt is identical (slope/slopeStart/slopeEnd round-trip)

6. Legacy scene migration (back-compat)

  • Load a pre-change saved scene (or fixture) containing type: building, level: 1, showBuildings: false
  • It loads as boundary at elevation: 0.15 with boundaries hidden; no console errors
  • Re-save → JSON now contains elevation / boundary (hard cut-over), loads again cleanly
  • Layout-shift check: load an old saved managed-street scene with visible, asymmetric-width buildings (e.g. waterfront 6 m left, fence 4 m right). Old layout included visible buildings in centering; the new model centers the travelled way alone, so the street shifts by (leftW−rightW)/2 (1 m in that example) relative to other layers. Verify this is acceptable — especially in a geo-anchored scene where the street was aligned to real-world tiles
  • Old scene with a level: -1 (below-grade) segment loads clamped to road level (intentional; negative unsupported)

7. Regression sweep

  • StreetPlan import still works (remember share URLs expire ~30 min — generate fresh)
  • Default streets from Add Layer panel look unchanged
  • Create street via AI assistant / JSON paste with new-format fields works
  • Legacy street + streetmix-loader scenes unaffected

Pre-merge/deploy checklist

  • 88 mocha + 368 vitest + 30 component tests green locally; CI green (incl. test:components)
  • lint / prettier / i18n check clean
  • Parity harness run locally against dev server (SHOW_BUILDINGS=false path)
  • Real kfarr/3 v34 export added as fixture (covers boundary object on production data; synthesized coastal fixture retained for slope coverage — the live kfarr/3 has no sloped segments)
  • Docs PR opened: Update managed-street docs for elevation/boundary format 3dstreet-docs#91 (hold merge until this PR deploys)
  • Manual test plan above executed by a human on the PR build
  • Slope direction assumption (values[0] = segment −x edge) confirmed against a live coastmix export — no public coastmix instance with slope data was reachable; needs a real export from the coastmix team
  • Mark PR ready for review (currently draft) + reviewer pass on the open questions (floors→height mapping, unmapped coastmix variants, boundary id not persisted)
  • After deploy: merge docs PR Update managed-street docs for elevation/boundary format 3dstreet-docs#91

Deferred (tracked, not blockers): floors → model height mapping proposal, slope editing UI, structural ends-only drag rules (#1751).

street-segment only emitted segment-changed for width/length, and
managed-street only relayed width changes, so converting a segment to/from
boundary or flipping a boundary's side left the layout stale until the
next width edit. Both properties now trigger the same segments-changed
re-layout path; generated-content listeners already self-guard on
width/length so no spurious clone regeneration.

Verified live: type change alone re-centers the travelled way and moves
the boundary outside the edge; side flip alone jumps it across; converting
back restores the original layout.
@kfarr
kfarr marked this pull request as ready for review July 5, 2026 06:45
@kfarr
kfarr merged commit 567995c into main Jul 5, 2026
2 checks passed
@kfarr
kfarr deleted the claude/elevation-level-boundary-parity-crppiu branch July 5, 2026 06:46
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.

2 participants