feat(viewer): per-level base elevation parameter - #312
Conversation
|
This remains a distinct product capability, so I’m keeping it open. Before code review it needs a current-main rebase and one explicit semantic decision: is baseElevation an offset that shifts this level and every level above it, or an absolute Y for this level only? The current cumulative implementation chooses the former, while the field name can be read either way. Please also cover stacked/exploded/solo modes, snapLevelsToTruePositions (bake/export), negative offsets, and selection/floorplan behavior with focused tests. Once that contract is stated, the implementation can be reviewed against it. |
baseElevation is an offset: it shifts the level and every level above it within the same building (cumulative). Integrated into the level stacking computation so stacked, exploded, solo, floorplan, and snap-to-true-positions all respect it. Includes focused tests. Closes pascalorg#209
9d14a64 to
92a550e
Compare
|
Contract, stated explicitly: baseElevation is an offset. It shifts this level and every level above it in the same building (cumulative), it is not an absolute Y, and negative values are allowed. Rebased by porting onto the current level-stacking architecture in 92a550e so stacked, exploded, solo, floorplan, and snapLevelsToTruePositions all read it from the stacking computation, with focused tests for each of those plus negative offsets. |
…r-height-parameter # Conflicts: # packages/core/src/schema/nodes/level.ts # packages/viewer/src/systems/level/level-stacking.test.ts # packages/viewer/src/systems/level/level-stacking.ts # packages/viewer/src/systems/level/level-system.tsx # packages/viewer/src/systems/level/level-utils.ts
…migration Three Bugbot findings, all the same shape: baseElevation was applied in one path and ignored in another. - Covering-slab math assumed the floor above sat exactly one stored storey height away, so wall and ceiling clamps ignored the offset. A positive offset over-shortened walls under thick slabs and a negative one let them penetrate the slab above. Floor-to-floor distance now comes from the stacked elevations (above.baseY - current.baseY) via one helper, so the clamp math and getLevelElevations cannot drift apart. - Stair rise used the stored storey height for the same reason; it now uses the same helper. - Elevator level tables and the first-person elevator colliders built cumulative Y from storey heights, so cab stops desynced from the visible floors. Both now read baseY from getLevelElevations. first-person-controls had its own near-copy of that logic, which is deleted in favour of the shared resolveElevatorLevels. - Levels loaded from older project JSON could omit baseElevation, which made the editor control render NaN. Migration now normalizes it to 0 alongside level and children, with a defensive fallback at the control.
|
All three findings are the same shape, baseElevation applied in one path and ignored in another. Fixed in ac82cf8. The covering-slab math assumed the floor above was exactly one stored storey height up, so the clamps ignored the offset. Floor-to-floor now comes from the stacked elevations (above.baseY minus current.baseY) through a single helper, so it can't drift from getLevelElevations again. Stair rise had the same bug and uses the same helper. Elevator level tables and the first-person colliders were building cumulative Y from storey heights; both now read baseY from getLevelElevations. first-person-controls turned out to have its own near-copy of that logic, so it's gone in favour of the shared resolveElevatorLevels. For the NaN one I fixed it in migration rather than at the control, normalizing baseElevation to 0 next to level and children, with a defensive fallback at the input too. 928 core tests pass, biome clean. |
|
Thanks for this, and for coming back to it in August to push fixes for the Bugbot findings — that is more follow-through than most contributions get. Issue #209 is a genuine gap: there is no way to model a staggered house today. The core tests you added to I reviewed this closely and there are several things to sort out. Taking the mechanical ones first, then the one that actually decides the PR. The CI failure is not the pre-existing error the description points atThe description blames a pre-existing implicit-
That test does not exercise this change
const baseY = (cumulativeYByBuilding.get(level.parentId) ?? 0) + level.baseElevationSo it asserts against the mock, not against your change. Copied onto an unpatched I would delete the file. The viewer picks the offset up for free because it calls the real Relatedly, the description mentions threading through Missing reactivity wiring
|
The quality gate was red for two reasons.
1. `frameCallback?.({}, delta)` typed `never`. The useFrame mock assigns
that binding while LevelSystem() runs, which TypeScript cannot see, so
after the `frameCallback = null` reset it narrowed the binding to
`null`. Reading it through a function keeps the declared type.
2. `mock.module('@pascal-app/core', ...)` replaced core's entire export
surface. mock.module is process-wide and Bun does not restore it, so
every viewer suite that ran after this file got the fake core: running
the systems directory went from 47 pass / 0 fail to 29 / 2, and a
whole-repo run lost 32 tests, including
wall-support-extension's covering-slab case. The failure looked like a
baseElevation regression and was not one.
Core does not need mocking here — `sceneRegistry` is a real in-memory
store with a `clear()`, and `useScene` is a zustand store with
`setState`. The test now drives both directly and only mocks
`@react-three/fiber` and `use-viewer`, the two modules that genuinely
need a renderer or a React context. Dropping the `lerp` mock too: the
real one is already pure.
Also adds the now-required `baseElevation` to the level fixture in
wall-drafting.test.ts — the schema default makes it required on
LevelNode's output type, so the existing `as AnyNode` cast no longer
held.
Gates on the merge ref (main merged in): check clean, check-types 9/9,
test 12/12 tasks with 0 fail, build 7/7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Picked this up as part of a maintenance sweep. The gate was red, so I've pushed the fixes to your branch ( What was failing, and why the log was misleading. CI reported a single error: Line 124 is The second problem, which the type error was hiding. Because
The most confusing casualty was Core didn't need mocking: One more: On the change itself. The Gates on the merge ref, for the record: Nothing outstanding from me. Once CI confirms green here this is ready to land. Thanks for threading the offset through the clamps, the elevator stacking and the migration rather than only the presentation layer — that's the part that would have been painful to retrofit. |
…base elevation pascalorg#556 landed an identity-keyed WeakMap memo on getLevelElevations after this branch last ran green. The new base-elevation case mutated `stackedNodes` in place, so the second resolveStairTotalRise call handed the memo the same object and got the cached 2.9 back instead of 2.1. The memo's contract holds in production — every store write publishes a new record (updateNodesAction spreads into `nextNodes`) — so the fix belongs in the test. The sibling storey-height case in this same file already builds a fresh record; match it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed What brokeThe failure that appeared after my last push wasn't caused by this branch:
const elevationMemo = new WeakMap<object, Map<string, LevelElevation>>()
export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<string, LevelElevation> {
const memoized = elevationMemo.get(nodes)
if (memoized) return memoized
…CI builds Your new case did this: expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.9)
stackedNodes.level_2 = { ...upper, baseElevation: -0.4 }
expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.1) // got 2.9Same object identity on both calls → memo hit → the second assertion re-read the first call's elevations. Note the failure mode: it returned the previous expected value, so had the numbers happened to coincide the test would have passed while asserting nothing. Why the fix is in the test, not in
|
| gate | result |
|---|---|
bun run check |
1600 files, clean |
bun run check-types |
9/9 packages |
bun run test |
944 pass / 0 fail |
bun run build |
7/7 |
Plus the two suites turbo run test doesn't reach, because packages/viewer and packages/editor have no test script — worth knowing when you're checking work in those packages locally:
bun test packages/viewer/src/systems→ 63 pass / 0 failbun test packages/editor/src/components/tools/wall→ 34 pass / 0 fail
Nothing further needed from you on this one — this is a maintainer-side fix to a cross-PR interaction, not a review request.
| if (!context) return DEFAULT_LEVEL_HEIGHT | ||
|
|
||
| let plane = context.storeyHeight | ||
| let plane = context.floorToFloorHeight |
There was a problem hiding this comment.
Auto ceilings ignore base offsets
High Severity
Auto-room wall tops still come from getStoredLevelHeight, while rendered plane-bound walls now use offset-aware getWallPlaneTop / floor-to-floor spacing. When the level above has a non-zero baseElevation, heightForRoom writes auto ceiling heights that no longer match the actual wall plane, leaving rooms short or tall relative to the staggered stack.
Reviewed by Cursor Bugbot for commit 0f47923. Configure here.
…t top The editor-side helper this PR consolidates into resolveElevatorLevels guarded the stack top with Math.max over every level; the core version reads only the topmost level. With the new baseElevation that is no longer the same thing — a negative offset can sink the top level's ceiling below the level beneath it, and the shaft then tops out under a served level and clips the cab (3 m -> 2.5 m for a -2.5 m offset on a 2 m top storey). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b96c3b9. Configure here.
| ? (levelElevations.get(nextLevel.id)?.baseY ?? stackTopY) | ||
| : lastServedLevel | ||
| ? cumulativeY | ||
| ? stackTopY |
There was a problem hiding this comment.
Elevator shaft ignores offset safeguard
High Severity
shaftTopY only applies the highest-ceiling safeguard when the elevator serves the top of the stack. If a higher unserved level exists, it uses that level's baseY alone. A negative baseElevation there can place the shaft top below a served ceiling and clip the cab and first-person colliders.
Reviewed by Cursor Bugbot for commit b96c3b9. Configure here.
|
One more push, Removing cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0))whereas the core version reads the topmost level only: const lastStackedLevel = allLevels[allLevels.length - 1]
const stackTopY = lastStackedLevel
? (levelElevations.get(lastStackedLevel.id)?.baseY ?? 0) +
(levelElevations.get(lastStackedLevel.id)?.height ?? 0)
: 0Before this PR those were interchangeable — the top level always had the highest ceiling.
Final gate run on the merge base
Review summaryThe design is right: Merging. |


What does this PR do?
Adds a per-level
baseElevationparameter so each level can offset its computed stack position upward by a user-specified amount. The site panel surfaces aBase elevationMetricControl on each level (m, 2dp, 0.05 step) and the viewer's level system threads the offset throughgetLevelTargetY/getNextLevelCumulativeYhelpers.How to test
Base elevationfield to a non-zero meter value.EXPLODED_GAPstill adds on top of the cumulative offset.bun test packages/core/test/level.test.ts packages/viewer/test/level-utils.test.ts.Notes
check-typesflags a pre-existing TypeScript error on the base branch unrelated to this change (packages/editor/src/store/use-editor.tsx:249—childIdimplicit any). The error is present onorigin/mainbefore this branch is applied, so the failure isn't introduced by this PR. Happy to fix it in a sibling PR if useful.Checklist
bun devand exercised the change locallybun check(biome clean)bun check-typesfails on pre-existing error (see Notes)mainCloses #209
Note
Medium Risk
Changes core vertical stacking used by walls, ceilings, stairs, elevators, and 3D level placement; behavior is broad but covered by targeted tests and legacy normalization on load.
Overview
Adds
level.baseElevation(meters, default 0): an additive offset applied when stacking floors so it shifts that level and every higher level in the same building; negative values are allowed.Stacking & clamps:
getLevelElevationsnow folds each level’s offset into cumulativebaseY. Covering-slab, wall-plane, and ceiling clamp logic use a newgetLevelFloorToFloorHeight(distance to the floor above) instead of only the stored storey height, so split-level gaps stay correct.Downstream: Stair rise (when not explicit) follows offset-aware floor-to-floor height. Elevator shaft/cab math uses shared
getLevelElevations; shaft top uses the max ceiling in the stack so a sunken top level cannot clip the cab. Scene load normalizes legacy levels vianormalizeLevelBaseElevation. The site panel exposes Base elevation; editor elevator colliders reuseresolveElevatorLevels.Tests cover schema, storey math, migration, elevators, stairs, and viewer level presentation modes.
Reviewed by Cursor Bugbot for commit b96c3b9. Bugbot is set up for automated code reviews on this repo. Configure here.