diff --git a/apps/editor/bunfig.toml b/apps/editor/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/apps/editor/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/apps/editor/package.json b/apps/editor/package.json index 31b4cde8a5..09852b125a 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -36,7 +36,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "zod": ">=4.5.4 <4.6" }, "devDependencies": { diff --git a/apps/ifc-converter/package.json b/apps/ifc-converter/package.json index 67ec77a56e..c5798f6e32 100644 --- a/apps/ifc-converter/package.json +++ b/apps/ifc-converter/package.json @@ -29,7 +29,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "web-ifc": "^0.0.77" }, "devDependencies": { diff --git a/bun.lock b/bun.lock index d785effb1b..e28a311ebd 100644 --- a/bun.lock +++ b/bun.lock @@ -52,7 +52,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "zod": ">=4.5.4 <4.6", }, "devDependencies": { @@ -87,7 +87,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.185.0", + "three": "^0.186.0", "web-ifc": "^0.0.77", }, "devDependencies": { @@ -125,7 +125,7 @@ "@types/react": "^19.2.2", "@types/three": "^0.184.0", "react": "^19.2.4", - "three": "^0.185.0", + "three": "^0.186.0", "typescript": "6.0.3", }, "peerDependencies": { @@ -135,7 +135,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/cli": { @@ -178,7 +178,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/editor": { @@ -244,7 +244,7 @@ "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "packages/eslint-config": { @@ -321,7 +321,7 @@ "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", "zustand": "^5", }, }, @@ -369,7 +369,7 @@ "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", }, }, "tooling/typescript": { @@ -383,7 +383,7 @@ "@types/three": "0.184.1", "next": "16.3.0", "react-grab": "0.1.50", - "three": "0.185.1", + "three": "0.186.0", }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1942,7 +1942,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], + "three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000000..958800cebc --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["./scripts/bun-preload-three.ts"] + +[test] +preload = ["./scripts/bun-preload-three.ts"] diff --git a/package.json b/package.json index 732ddfcbfa..614716ae0e 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@types/three": "0.184.1", "next": "16.3.0", "react-grab": "0.1.50", - "three": "0.185.1" + "three": "0.186.0" }, "optionalDependencies": { "@tailwindcss/oxide-darwin-arm64": "4.3.0", diff --git a/packages/capture-viewer/bunfig.toml b/packages/capture-viewer/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/capture-viewer/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/capture-viewer/package.json b/packages/capture-viewer/package.json index 9b7db8a290..d59369f216 100644 --- a/packages/capture-viewer/package.json +++ b/packages/capture-viewer/package.json @@ -37,7 +37,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "devDependencies": { "@pascal-app/capture-protocol": "^1.0.0-beta.4", @@ -50,7 +50,7 @@ "@types/react": "^19.2.2", "@types/three": "^0.184.0", "react": "^19.2.4", - "three": "^0.185.0", + "three": "^0.186.0", "typescript": "6.0.3" }, "keywords": [ diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/core/package.json b/packages/core/package.json index 1d6ff58f0e..0a1f26ced7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -73,7 +73,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "@pascal-app/capture-protocol": "^1.0.0-beta.4", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d250404a9..7ff7c5c225 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -395,12 +395,25 @@ export { isSplineFence, sampleFenceSpline, } from './systems/fence/fence-spline' +export { resolveRoofElevation, resolveRoofWallTopElevation } from './systems/roof/roof-elevation' +export { RoofElevationSystem } from './systems/roof/roof-elevation-system' +export { + fitRoofFootprint, + type RoofFootprintTarget, + resolveRoomRoofFootprint, + resolveRoomRoofFootprintOnLevel, +} from './systems/roof/roof-footprint' export { resolveSlabPlacementElevation } from './systems/slab/slab-placement' export { clampSlabElevationForWalls, getSlabElevationUpperBound, type SlabElevationClamp, } from './systems/slab/slab-support' +export { + createDefaultStairSegment, + createStairFlightFromStair, + type StairFlightOverrides, +} from './systems/stair/stair-flight' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' diff --git a/packages/core/src/schema/nodes/roof-elevation.test.ts b/packages/core/src/schema/nodes/roof-elevation.test.ts new file mode 100644 index 0000000000..4b5186ab26 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-elevation.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'bun:test' +import { RoofNode } from './roof' + +test('roofs default to custom level support', () => { + expect(RoofNode.parse({}).support).toEqual({ kind: 'level' }) +}) + +test('wall-follow support survives JSON parsing without host references', () => { + const roof = RoofNode.parse({ support: { kind: 'walls' }, position: [2, -0.5, 1] }) + expect(RoofNode.parse(JSON.parse(JSON.stringify(roof)))).toEqual(roof) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/packages/core/src/schema/nodes/roof-segment-walls.test.ts index 61c087f554..8b6004f82b 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.test.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -23,6 +23,15 @@ function segment(overrides: Partial = {}): RoofSegmentNode { } describe('roof wall face frames', () => { + test('zero-height gable profiles keep their base at zero and raise the eave to five centimeters', () => { + const face = getRoofSegmentWallFace(segment({ wallHeight: 0 }), 'right') + + expect(Math.min(...face.profile.map(([, v]) => v))).toBe(0) + expect(face.profile[2]?.[1]).toBe(0.05) + expect(face.profile[4]?.[1]).toBe(0.05) + expect(face.profile[3]?.[1]).toBeCloseTo(0.05 + 3.05 * Math.tan((40 * Math.PI) / 180)) + }) + test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => { const seg = segment() // front face, u at the face middle, v = 1, mid-plane. diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index d6fa9c5bc7..5595a2a8b2 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -16,7 +16,9 @@ import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment' * (`getVol(wallThickness / 2, 0, 0, …)`): the volume is the segment * footprint extended outward by `wallThickness / 2`, which drops the eave * line by `(wallThickness / 2) · tanθ` and raises the ridge by the same - * amount so the apex stays at `wallHeight + activeRh`. + * amount so the apex stays at `wallHeight + activeRh` unless the eave + * hits the CSG minimum. The base stays at 0; the eave is raised to at + * least 0.05 above it to avoid sinking the shell into the supporting wall. */ export type RoofWallFaceId = 'front' | 'back' | 'right' | 'left' @@ -76,7 +78,7 @@ function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame { const autoDrop = (wallThickness / 2) * tanTheta const wV = Math.max(0.01, node.width + wallThickness) const dV = Math.max(0.01, node.depth + wallThickness) - const eaveY = Math.max(0.01, node.wallHeight - autoDrop) + const eaveY = Math.max(0.05, node.wallHeight - autoDrop) let rh = activeRh if (activeRh > 0) { rh = activeRh + autoDrop diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index cc2ed84915..ca9ae35220 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -14,6 +14,7 @@ export type RoofSurfaceMaterialSpec = { export const RoofSupport = z .discriminatedUnion('kind', [ z.object({ kind: z.literal('level') }), + z.object({ kind: z.literal('walls') }), z.object({ kind: z.literal('roof'), roofSegmentId: RoofSegmentNode.shape.id, @@ -49,7 +50,7 @@ export const RoofNode = BaseNode.extend({ When not being edited, segments are visually combined into a single solid. - position: center position of the roof group - rotation: rotation around Y axis - - support: level placement or an explicit roof-surface attachment + - support: custom level placement, spatial wall-top following, or a roof-surface attachment - children: array of RoofSegmentNode IDs `, ) diff --git a/packages/core/src/systems/roof/roof-elevation-system.test.ts b/packages/core/src/systems/roof/roof-elevation-system.test.ts new file mode 100644 index 0000000000..0617edb6e4 --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation-system.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { initSpatialGridSync } from '../../hooks/spatial-grid/spatial-grid-sync' +import { type AnyNode, type AnyNodeId, LevelNode, RoofNode, SlabNode, WallNode } from '../../schema' +import { + pauseSceneHistory, + resumeSceneHistory, + type SceneCommit, + subscribeSceneCommits, +} from '../../store/history-control' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { initializeRoofElevationSync } from './roof-elevation-system' + +type RafFn = (callback: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( + callback, +) => { + callback(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const originalState = useScene.getState() +let stopRoofSync = () => {} +let stopGridSync = () => {} +let stopCommitSubscription = () => {} + +beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() +}) + +afterEach(() => { + stopRoofSync() + stopGridSync() + stopCommitSubscription() + spatialGridManager.clear() + useScene.setState(originalState) + clearSceneHistory() +}) + +function setup() { + const level = LevelNode.parse({ height: 3, level: 0 }) + const upper = LevelNode.parse({ height: 3, level: 1 }) + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [4, 0], height: 3 }) + const roof = RoofNode.parse({ + parentId: upper.id, + support: { kind: 'walls' }, + position: [2, 0, 1], + rotation: 0.4, + }) + const manual = RoofNode.parse({ parentId: level.id, position: [6, 7, 8] }) + const otherWalls = [ + WallNode.parse({ parentId: level.id, start: [4, 0], end: [4, 3], height: 3 }), + WallNode.parse({ parentId: level.id, start: [4, 3], end: [0, 3], height: 3 }), + WallNode.parse({ parentId: level.id, start: [0, 3], end: [0, 0], height: 3 }), + ] + level.children = [wall.id, ...otherWalls.map((node) => node.id), manual.id] + upper.children = [roof.id] + useScene.setState({ + nodes: Object.fromEntries( + [level, upper, wall, ...otherWalls, roof, manual].map((node) => [node.id, node]), + ) as Record, + rootNodeIds: [level.id, upper.id], + }) + clearSceneHistory() + stopRoofSync = initializeRoofElevationSync() + stopGridSync = initSpatialGridSync() + return { level, upper, wall, otherWalls, roof, manual } +} + +function currentRoof(id: RoofNode['id']): RoofNode { + return useScene.getState().nodes[id] as RoofNode +} + +describe('RoofElevationSystem', () => { + test('a wall height edit moves the roof after one microtask without adding an undo step', async () => { + const { wall, roof, manual } = setup() + await Promise.resolve() + const commits: SceneCommit[] = [] + stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit)) + useScene.getState().updateNode(wall.id, { height: 4.2 }) + expect(currentRoof(roof.id).position).toEqual([2, 0, 1]) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBeCloseTo(1.2) + expect(currentRoof(roof.id).rotation).toBe(0.4) + expect(currentRoof(manual.id).position).toEqual([6, 7, 8]) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect(commits).toHaveLength(2) + expect((commits[1]?.before.nodes[roof.id] as RoofNode).position[1]).toBe(0) + expect((commits[1]?.current.nodes[roof.id] as RoofNode).position[1]).toBeCloseTo(1.2) + expect(commits[1]?.changedNodeIds).toEqual(new Set([roof.id])) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(0) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBeCloseTo(1.2) + }) + + test('does not publish previews owned by an outer history pause', async () => { + const { wall, roof } = setup() + await Promise.resolve() + const commits: SceneCommit[] = [] + stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit)) + pauseSceneHistory(useScene) + try { + useScene.getState().updateNode(wall.id, { height: 4 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(1) + expect(useScene.temporal.getState().isTracking).toBe(false) + expect(commits).toHaveLength(0) + } finally { + resumeSceneHistory(useScene) + } + }) + + test('waits for the slab grid listener and tracks subsequent slab elevation edits', async () => { + const { level, roof } = setup() + const slab = SlabNode.parse({ + elevation: 0.5, + polygon: [ + [-1, -1], + [5, -1], + [5, 2], + [-1, 2], + ], + }) + useScene.getState().createNode(slab, level.id) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(0.5) + useScene.getState().updateNode(slab.id, { elevation: 0.9 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBeCloseTo(0.9) + }) + + test('keeps a custom Y edit after follow mode is disabled', async () => { + const { wall, roof } = setup() + await Promise.resolve() + useScene.getState().updateNode(roof.id, { support: { kind: 'level' }, position: [2, 9, 1] }) + useScene.getState().updateNode(wall.id, { height: 5 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(9) + expect(currentRoof(roof.id).support).toEqual({ kind: 'level' }) + }) + + test('undo and redo restore custom/follow mode and Y together', async () => { + const { roof } = setup() + await Promise.resolve() + useScene.getState().updateNode(roof.id, { support: { kind: 'level' }, position: [2, 9, 1] }) + await Promise.resolve() + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 0, 1] }) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'level' }, position: [2, 9, 1] }) + useScene.getState().updateNode(roof.id, { support: { kind: 'walls' } }) + expect(currentRoof(roof.id).position[1]).toBe(9) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 0, 1] }) + expect(useScene.temporal.getState().pastStates).toHaveLength(2) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'level' }, position: [2, 9, 1] }) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 0, 1] }) + }) + + test('deleting all walls freezes Y and redrawing an enclosure resumes following', async () => { + const { level, wall, otherWalls, roof } = setup() + useScene.getState().updateNode(wall.id, { height: 4 }) + await Promise.resolve() + const walls = [wall, ...otherWalls] + useScene.getState().deleteNodes(walls.map((node) => node.id)) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 1, 1] }) + const replacements = walls.map((node) => + WallNode.parse({ ...node, id: undefined, height: 2.5 }), + ) + useScene.getState().createNodes(replacements.map((node) => ({ node, parentId: level.id }))) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ + support: { kind: 'walls' }, + position: [2, -0.5, 1], + }) + useScene.temporal.getState().undo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [2, 1, 1] }) + useScene.temporal.getState().redo() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ + support: { kind: 'walls' }, + position: [2, -0.5, 1], + }) + }) + + test('an XZ-only move resolves a different enclosure without changing mode', async () => { + const { level, wall, otherWalls, roof } = setup() + const second = [wall, ...otherWalls].map((node) => + WallNode.parse({ + ...node, + id: undefined, + height: 5, + start: [node.start[0] + 10, node.start[1]], + end: [node.end[0] + 10, node.end[1]], + }), + ) + useScene.getState().createNodes(second.map((node) => ({ node, parentId: level.id }))) + await Promise.resolve() + useScene.getState().updateNode(roof.id, { position: [12, 0, 1] }) + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'walls' }, position: [12, 2, 1] }) + }) + + test('initialization never changes existing custom roofs', async () => { + const { roof } = setup() + stopRoofSync() + useScene.getState().updateNode(roof.id, { support: { kind: 'level' }, position: [2, 8, 1] }) + clearSceneHistory() + stopRoofSync = initializeRoofElevationSync() + await Promise.resolve() + expect(currentRoof(roof.id)).toMatchObject({ support: { kind: 'level' }, position: [2, 8, 1] }) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + }) + + test('coalesces rapid edits, ignores epsilon drift and cancels queued work on disposal', async () => { + const { wall, roof } = setup() + await Promise.resolve() + useScene.getState().updateNode(wall.id, { height: 3.00001 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(0) + useScene.getState().updateNode(wall.id, { height: 4 }) + useScene.getState().updateNode(wall.id, { height: 5 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(2) + useScene.getState().updateNode(wall.id, { height: 6 }) + stopRoofSync() + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBe(2) + }) +}) diff --git a/packages/core/src/systems/roof/roof-elevation-system.tsx b/packages/core/src/systems/roof/roof-elevation-system.tsx new file mode 100644 index 0000000000..e5b9cd7ead --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation-system.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useEffect } from 'react' +import type { AnyNode, AnyNodeId } from '../../schema' +import { + getSceneHistoryPauseDepth, + notifySceneCommit, + pauseSceneHistory, + resumeSceneHistory, + type SceneSnapshot, +} from '../../store/history-control' +import useScene from '../../store/use-scene' +import { resolveRoofElevation } from './roof-elevation' + +const ROOF_ELEVATION_EPSILON = 1e-4 + +function sceneSnapshot(): SceneSnapshot { + const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() + return { nodes, rootNodeIds, collections, materials, installedPlugins } +} + +function isElevationRelevant(node: AnyNode | undefined): boolean { + return ( + node?.type === 'roof' || + node?.type === 'wall' || + node?.type === 'slab' || + node?.type === 'level' || + node?.type === 'building' || + node?.type === 'site' + ) +} + +export function initializeRoofElevationSync(): () => void { + let disposed = false + let queued = false + let syncing = false + + const schedule = () => { + if (queued) return + queued = true + // Wall bases use the spatial grid, whose listener must finish before this pass. + queueMicrotask(() => { + queued = false + if (disposed) return + const { nodes, updateNodes, readOnly } = useScene.getState() + if (readOnly) return + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'roof' || node.support?.kind !== 'walls') continue + const elevation = resolveRoofElevation(node, nodes) + if (Math.abs(node.position[1] - elevation) <= ROOF_ELEVATION_EPSILON) continue + updates.push({ + id: node.id, + data: { position: [node.position[0], elevation, node.position[2]] }, + }) + } + if (updates.length === 0) return + const before = sceneSnapshot() + const publishCommit = + useScene.temporal.getState().isTracking && getSceneHistoryPauseDepth() === 0 + syncing = true + pauseSceneHistory(useScene) + try { + updateNodes(updates) + } finally { + resumeSceneHistory(useScene) + syncing = false + } + // Deferred writes miss the originating commit; publish the settled roof without + // another undo step. An outer pause belongs to a gesture that owns its commit. + if (publishCommit) { + notifySceneCommit({ + origin: 'local', + before, + current: sceneSnapshot(), + changedNodeIds: new Set(updates.map(({ id }) => id)), + }) + } + }) + } + + const unsubscribe = useScene.subscribe((state, previous) => { + if (syncing || state.nodes === previous.nodes) return + const ids = new Set([...Object.keys(state.nodes), ...Object.keys(previous.nodes)]) + for (const id of ids) { + const next = state.nodes[id as AnyNodeId] + const prev = previous.nodes[id as AnyNodeId] + if (next !== prev && (isElevationRelevant(next) || isElevationRelevant(prev))) { + schedule() + return + } + } + }) + schedule() + + return () => { + disposed = true + unsubscribe() + } +} + +export function RoofElevationSystem() { + useEffect(initializeRoofElevationSync, []) + return null +} diff --git a/packages/core/src/systems/roof/roof-elevation.test.ts b/packages/core/src/systems/roof/roof-elevation.test.ts new file mode 100644 index 0000000000..827076622e --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation.test.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { + type AnyNode, + BuildingNode, + LevelNode, + RoofNode, + RoofSegmentNode, + SlabNode, + WallNode, +} from '../../schema' +import { planWallSplitAtPoint } from '../wall/wall-topology' +import { resolveRoofElevation } from './roof-elevation' + +beforeEach(() => spatialGridManager.clear()) +afterEach(() => spatialGridManager.clear()) + +function room(level: LevelNode, heights: Array, offsetX = 0) { + const polygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + return polygon.map((point, index) => { + const next = polygon[(index + 1) % polygon.length]! + return WallNode.parse({ + parentId: level.id, + start: [point[0] + offsetX, point[1]], + end: [next[0] + offsetX, next[1]], + height: heights[index % heights.length], + }) + }) +} + +function scene(heights: Array) { + const level = LevelNode.parse({ height: 3, level: 0 }) + const upper = LevelNode.parse({ height: 3, level: 1 }) + const walls = room(level, heights) + const roof = RoofNode.parse({ + parentId: upper.id, + support: { kind: 'walls' }, + position: [2, 0, 1], + }) + level.children = walls.map((wall) => wall.id) + upper.children = [roof.id] + const nodes: Record = Object.fromEntries( + [level, upper, roof, ...walls].map((node) => [node.id, node]), + ) + return { level, upper, walls, roof, nodes } +} + +describe('resolveRoofElevation', () => { + test("follows walls on the roof's own level when it sits on the top floor", () => { + const { roof, level, upper, nodes } = scene([2.5]) + // Move the roof onto the walls' level: no storey above, roof and walls share it. + delete nodes[upper.id] + roof.parentId = level.id + level.children = [...level.children, roof.id] + roof.position = [2, 2.5, 1] + expect(resolveRoofElevation(roof, nodes)).toBe(2.5) + }) + + test('follows the walls under the segment footprint when the room is not closed', () => { + const { roof, walls, nodes, level } = scene([4.5]) + // Drop the east wall: point-in-room finds no enclosure, the footprint still does. + const east = walls[1]! + delete nodes[east.id] + level.children = level.children.filter((id) => id !== east.id) + const segment = RoofSegmentNode.parse({ + parentId: roof.id, + roofType: 'gable', + width: 4, + depth: 3, + position: [0, 0, 0], + }) + roof.children = [segment.id] + nodes[segment.id] = segment + expect(resolveRoofElevation(roof, nodes)).toBe(1.5) + }) + + test('honors an explicit height above the storey in the roof level frame', () => { + const { roof, nodes } = scene([4.5]) + expect(resolveRoofElevation(roof, nodes)).toBe(1.5) + }) + + test('takes the highest top across mixed explicit and plane-bound walls', () => { + const { roof, nodes } = scene([3, undefined, 5, 2]) + expect(resolveRoofElevation(roof, nodes)).toBe(2) + }) + + test('includes the elected slab base and wall support offset', () => { + const { level, walls, roof, nodes } = scene([4.5]) + const slab = SlabNode.parse({ + parentId: level.id, + elevation: 0.8, + polygon: [ + [-1, -1], + [5, -1], + [5, 4], + [-1, 4], + ], + }) + spatialGridManager.handleNodeCreated(slab, level.id) + const wall = { ...walls[0]!, supportSlabId: slab.id, supportOffset: 0.2 } + expect(resolveRoofElevation(roof, { ...nodes, [wall.id]: wall, [slab.id]: slab })).toBe(2.5) + }) + + test('does not lift a plane-bound top when its base rises', () => { + const { walls, roof, nodes } = scene([undefined]) + const wall = { ...walls[0]!, supportOffset: 0.6 } + expect(resolveRoofElevation(roof, { ...nodes, [wall.id]: wall })).toBe(0) + }) + + test('2.5 m walls under a 3 m storey pull the roof down to -0.5 m', () => { + const { roof, nodes } = scene([2.5]) + expect(resolveRoofElevation(roof, nodes)).toBe(-0.5) + }) + + test('freezes without an enclosure, retaining follow intent', () => { + const { walls, roof, nodes } = scene([4]) + const remaining = { ...nodes } + delete remaining[walls[0]!.id] + expect(resolveRoofElevation(roof, remaining)).toBe(0) + for (const wall of walls) delete remaining[wall.id] + expect(resolveRoofElevation(roof, remaining)).toBe(0) + expect(roof.support).toEqual({ kind: 'walls' }) + expect(resolveRoofElevation(roof, nodes)).toBe(1) + }) + + test('level roofs and roof-surface attachments keep their Y', () => { + const { roof, nodes } = scene([4]) + expect(resolveRoofElevation({ ...roof, support: { kind: 'level' } }, nodes)).toBe(0) + const attached = RoofNode.parse({ + ...roof, + support: { kind: 'roof', roofSegmentId: 'rseg_host', localPosition: [0, 0] }, + }) + expect(resolveRoofElevation(attached, nodes)).toBe(0) + }) + + test('re-resolves replacement wall pieces after a topology split', () => { + const { level, walls, roof, nodes } = scene([4]) + const split = planWallSplitAtPoint(nodes, { levelId: level.id, point: [2, 0], radius: 0.05 }) + expect(split.ok).toBe(true) + if (!split.ok) throw new Error(split.reason) + const { create, delete: deleted } = split.plan.changes + expect(create).toHaveLength(2) + const next = { ...nodes } + for (const id of deleted) delete next[id] + for (const { node } of create) next[node.id] = { ...node, height: 5 } as WallNode + next[level.id] = { + ...level, + children: [ + ...walls.filter((wall) => !deleted.includes(wall.id)).map((wall) => wall.id), + ...create.map(({ node }) => node.id), + ], + } + expect(resolveRoofElevation(roof, next)).toBe(2) + }) + + test('chooses the smallest enclosure containing the roof centre', () => { + const { level, roof, nodes } = scene([5]) + const innerPolygon: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const innerWalls = innerPolygon.map((start, index) => + WallNode.parse({ + parentId: level.id, + start, + end: innerPolygon[(index + 1) % innerPolygon.length], + height: 2.5, + }), + ) + const next = { + ...nodes, + ...Object.fromEntries(innerWalls.map((wall) => [wall.id, wall])), + [level.id]: { ...level, children: [...level.children, ...innerWalls.map((wall) => wall.id)] }, + } + expect(resolveRoofElevation(roof, next)).toBe(-0.5) + }) + + test('moving XZ chooses the enclosure at the new centre', () => { + const { level, roof, nodes } = scene([4]) + const secondWalls = room(level, [5], 10) + const next = { + ...nodes, + ...Object.fromEntries(secondWalls.map((wall) => [wall.id, wall])), + [level.id]: { + ...level, + children: [...level.children, ...secondWalls.map((wall) => wall.id)], + }, + } + expect(resolveRoofElevation(roof, next)).toBe(1) + expect(resolveRoofElevation({ ...roof, position: [12, 1, 1] }, next)).toBe(2) + expect(resolveRoofElevation({ ...roof, position: [20, 7, 1] }, next)).toBe(7) + }) + + test('uses the lower neighbour in the same building across ordinal gaps and offsets', () => { + const { level, upper, roof, nodes } = scene([4.5]) + const building = BuildingNode.parse({ children: [level.id, upper.id] }) + const otherBuilding = BuildingNode.parse({}) + const unrelated = LevelNode.parse({ parentId: otherBuilding.id, level: 8, height: 100 }) + const next = { + ...nodes, + [building.id]: building, + [otherBuilding.id]: otherBuilding, + [unrelated.id]: unrelated, + [level.id]: { ...level, parentId: building.id, level: -2, baseElevation: 2 }, + [upper.id]: { ...upper, parentId: building.id, level: 10, baseElevation: 0.5 }, + } + expect(resolveRoofElevation(roof, next)).toBe(1) + }) + + test('looks at its own level and the one below, never two floors down', () => { + const { level, upper, roof, nodes } = scene([4]) + const middle = LevelNode.parse({ level: 0.5, height: 2 }) + // A wall-less storey slipped between roof and walls: the walls are now two + // floors down and the roof freezes where it is. + expect(resolveRoofElevation(roof, { ...nodes, [middle.id]: middle })).toBe(0) + // Walls on the roof's own level count (top floor without a storey above). + const onParent = room(upper, [20]) + const next = { + ...nodes, + [upper.id]: { ...upper, children: [...upper.children, ...onParent.map((wall) => wall.id)] }, + ...Object.fromEntries(onParent.map((wall) => [wall.id, wall])), + } + expect(resolveRoofElevation(roof, next)).toBe(20) + expect(resolveRoofElevation({ ...roof, parentId: level.id }, nodes)).toBe(4) + }) + + test('matches a conical arc by centre and radius after replacement, without an enclosure', () => { + const { level, roof, nodes } = scene([4]) + const segment = RoofSegmentNode.parse({ + parentId: roof.id, + roofType: 'conical', + width: 4, + depth: 4, + conicalFullCircle: true, + }) + const curved = WallNode.parse({ + parentId: level.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + height: 2.5, + }) + const cone = { + ...roof, + children: [segment.id], + position: [0, 0, 0] as [number, number, number], + } + const next = { + ...nodes, + [level.id]: { ...level, children: [curved.id] }, + [curved.id]: curved, + [segment.id]: segment, + } + expect(resolveRoofElevation(cone, next)).toBe(-0.5) + const replacement = WallNode.parse({ ...curved, id: undefined, height: 5 }) + const replaced = { + ...next, + [level.id]: { ...level, children: [replacement.id] }, + [replacement.id]: replacement, + } + delete replaced[curved.id] + expect(resolveRoofElevation(cone, replaced)).toBe(2) + expect(resolveRoofElevation({ ...cone, position: [1, 7, 0] }, replaced)).toBe(7) + expect( + resolveRoofElevation(cone, { ...replaced, [segment.id]: { ...segment, width: 6 } }), + ).toBe(0) + }) +}) diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts new file mode 100644 index 0000000000..1e75ab60f7 --- /dev/null +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -0,0 +1,119 @@ +import { + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, +} from '../../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, WallNode } from '../../schema' +import { findLevelBelowId, getLevelElevations } from '../../services/storey' +import { wallOverlapsSlabFootprint } from '../slab/slab-support' +import { getWallArcData } from '../wall/wall-curve' +import { resolveRoomRoofFootprintOnLevel } from './roof-footprint' + +export function resolveRoofWallTopElevation( + targetLevelId: LevelNode['id'], + wall: WallNode, + nodes: Readonly>, + elevations = getLevelElevations(nodes), +): number { + const sourceLevelY = elevations.get(resolveLevelId(wall, nodes))?.baseY ?? 0 + const targetLevelY = elevations.get(targetLevelId)?.baseY ?? 0 + return ( + sourceLevelY + + getWallBaseElevationForNodes(wall, nodes) + + getWallEffectiveHeightForNodes(wall, nodes) - + targetLevelY + ) +} + +export function resolveRoofElevation( + roof: RoofNode, + nodes: Readonly>, +): number { + if (roof.support?.kind !== 'walls') return roof.position[1] + const levelId = resolveLevelId(roof, nodes) + if (nodes[levelId]?.type !== 'level') return roof.position[1] + const elevations = getLevelElevations(nodes) + // A roof usually sits on the storey above its walls, but the top floor (or a + // roof armed from the walls' own level) keeps roof and walls on one level. + const belowId = findLevelBelowId(levelId, elevations) + const candidateWallIds = [levelId, belowId] + .map((id) => (id ? nodes[id] : undefined)) + .filter((node): node is LevelNode => node?.type === 'level') + .flatMap((level) => level.children) + + const conicalSegments = roof.children + .map((id) => nodes[id]) + .filter( + (node): node is RoofSegmentNode => + node?.type === 'roof-segment' && node.roofType === 'conical', + ) + const cos = Math.cos(roof.rotation) + const sin = Math.sin(roof.rotation) + const toLevel = (x: number, z: number): [number, number] => [ + roof.position[0] + x * cos + z * sin, + roof.position[2] - x * sin + z * cos, + ] + // Walls under the footprint, closed room or not: a room missing a wall, an + // L-shaped room whose centre falls outside, or a redrawn enclosure all still + // hold the roof up. The band test is curve- and thickness-aware and + // boundary-inclusive, so perimeter walls on the footprint edge count. + const footprints = roof.children + .map((id) => nodes[id]) + .filter((node): node is RoofSegmentNode => node?.type === 'roof-segment') + .map((segment) => { + const c = Math.cos(segment.rotation) + const s = Math.sin(segment.rotation) + const halfW = segment.width / 2 + const halfD = segment.depth / 2 + const corners: Array<[number, number]> = [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] + return corners.map(([x, z]) => + toLevel(segment.position[0] + x * c + z * s, segment.position[2] - x * s + z * c), + ) + }) + const wallIds = conicalSegments.length + ? candidateWallIds.filter((id) => { + const wall = nodes[id] + if (wall?.type !== 'wall') return false + const arc = getWallArcData(wall) + if (!arc) return false + return conicalSegments.some((segment) => { + const [centerX, centerZ] = toLevel(segment.position[0], segment.position[2]) + return ( + Math.hypot(arc.center.x - centerX, arc.center.y - centerZ) <= 1e-4 && + Math.abs(arc.radius - segment.width / 2) <= 1e-4 + ) + }) + }) + : footprints.length + ? candidateWallIds.filter((id) => { + const wall = nodes[id] + return ( + wall?.type === 'wall' && + footprints.some((polygon) => wallOverlapsSlabFootprint(wall, polygon)) + ) + }) + : ([levelId, belowId] + .map((id) => + id + ? resolveRoomRoofFootprintOnLevel(id as LevelNode['id'], nodes, [ + roof.position[0], + roof.position[2], + ]) + : null, + ) + .find((target) => target !== null)?.wallIds ?? []) + + let highest: number | undefined + for (const id of wallIds) { + const wall = nodes[id] + if (wall?.type !== 'wall') continue + const top = resolveRoofWallTopElevation(levelId as LevelNode['id'], wall, nodes, elevations) + highest = highest === undefined ? top : Math.max(highest, top) + } + return highest ?? roof.position[1] +} diff --git a/packages/core/src/systems/roof/roof-footprint.ts b/packages/core/src/systems/roof/roof-footprint.ts new file mode 100644 index 0000000000..5fa4b8adf1 --- /dev/null +++ b/packages/core/src/systems/roof/roof-footprint.ts @@ -0,0 +1,118 @@ +import { pointInPolygon as pointInPolygon2D } from '../../lib/polygon-relations' +import { detectSpacesForLevel } from '../../lib/space-detection' +import type { AnyNode, LevelNode, WallNode } from '../../schema' +import { getLevelBelow } from '../../services/storey' + +export type RoofFootprintTarget = { + id: string + polygon: Array<[number, number]> + wallIds: WallNode['id'][] + center: [number, number] + width: number + depth: number + rotation: number + rectangular: boolean +} + +function polygonArea(polygon: ReadonlyArray): number { + return Math.abs( + polygon.reduce((area, point, index) => { + const next = polygon[(index + 1) % polygon.length] + return next ? area + point[0] * next[1] - next[0] * point[1] : area + }, 0) / 2, + ) +} + +export function fitRoofFootprint( + id: string, + polygon: Array<[number, number]>, + wallIds: WallNode['id'][], +): RoofFootprintTarget | null { + if (polygon.length < 3) return null + + let best: + | { + center: [number, number] + width: number + depth: number + rotation: number + area: number + } + | undefined + + for (let index = 0; index < polygon.length; index++) { + const point = polygon[index] + const next = polygon[(index + 1) % polygon.length] + if (!(point && next)) continue + const rotation = Math.atan2(next[1] - point[1], next[0] - point[0]) + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const rotated = polygon.map(([x, z]) => [x * cos + z * sin, -x * sin + z * cos] as const) + const xs = rotated.map(([x]) => x) + const zs = rotated.map(([, z]) => z) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minZ = Math.min(...zs) + const maxZ = Math.max(...zs) + const width = maxX - minX + const depth = maxZ - minZ + const area = width * depth + if (area <= 0 || (best && best.area <= area)) continue + const localCenterX = (minX + maxX) / 2 + const localCenterZ = (minZ + maxZ) / 2 + best = { + center: [localCenterX * cos - localCenterZ * sin, localCenterX * sin + localCenterZ * cos], + width, + depth, + rotation: -rotation, + area, + } + } + + if (!best) return null + return { + id, + polygon, + wallIds, + center: best.center, + width: best.width, + depth: best.depth, + rotation: best.rotation, + rectangular: polygonArea(polygon) / best.area >= 0.96, + } +} + +export function resolveRoomRoofFootprint( + levelId: LevelNode['id'], + nodes: Readonly>, + point: [number, number], + options: { rectangularOnly?: boolean } = {}, +): RoofFootprintTarget | null { + const activeTarget = resolveRoomRoofFootprintOnLevel(levelId, nodes, point) + if (activeTarget && (!options.rectangularOnly || activeTarget.rectangular)) return activeTarget + if (activeTarget) return null + const levelBelow = getLevelBelow(levelId, nodes as Record) + const levelBelowTarget = levelBelow + ? resolveRoomRoofFootprintOnLevel(levelBelow.id, nodes, point) + : null + return levelBelowTarget && (!options.rectangularOnly || levelBelowTarget.rectangular) + ? levelBelowTarget + : null +} + +export function resolveRoomRoofFootprintOnLevel( + levelId: LevelNode['id'], + nodes: Readonly>, + point: [number, number], +): RoofFootprintTarget | null { + const level = nodes[levelId] + if (level?.type !== 'level') return null + const walls = level.children + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + const spaces = detectSpacesForLevel(levelId, walls) + .spaces.filter((space) => !space.isExterior && pointInPolygon2D(point, space.polygon)) + .sort((left, right) => polygonArea(left.polygon) - polygonArea(right.polygon)) + const space = spaces[0] + return space ? fitRoofFootprint(space.id, space.polygon, space.wallIds) : null +} diff --git a/packages/core/src/systems/stair/stair-flight.ts b/packages/core/src/systems/stair/stair-flight.ts new file mode 100644 index 0000000000..fcdc7e9c84 --- /dev/null +++ b/packages/core/src/systems/stair/stair-flight.ts @@ -0,0 +1,41 @@ +import type { AnyNode, StairNode } from '../../schema' +import { StairSegmentNode } from '../../schema' +import { resolveStairTotalRise } from './stair-rise' + +const MIN_STAIR_FLIGHT_RISE = 0.1 +const MIN_STAIR_FLIGHT_STEP_COUNT = 2 + +export type StairFlightOverrides = Partial< + Pick< + StairSegmentNode, + 'width' | 'length' | 'height' | 'stepCount' | 'attachmentSide' | 'fillToFloor' | 'thickness' + > +> + +/** + * The single definition of a default straight flight. Anything left out falls + * through to the `StairSegmentNode` schema defaults (length 3 m, 10 steps, + * filled to floor) rather than being spelled again per call site, so the stair + * tool's seed segment, the flight the panel materializes when a curved stair + * becomes straight, and the viewer's fallback body all describe one stair. + */ +export function createDefaultStairSegment(overrides: StairFlightOverrides = {}): StairSegmentNode { + return StairSegmentNode.parse({ segmentType: 'stair', position: [0, 0, 0], ...overrides }) +} + +/** + * The flight a straight stair implies from its own fields — used wherever a + * straight stair has to stand in for missing `stair-segment` children. + */ +export function createStairFlightFromStair( + stair: StairNode, + nodes: Record, +): StairSegmentNode { + return createDefaultStairSegment({ + width: stair.width, + height: Math.max(resolveStairTotalRise(stair, nodes), MIN_STAIR_FLIGHT_RISE), + stepCount: Math.max(MIN_STAIR_FLIGHT_STEP_COUNT, Math.round(stair.stepCount ?? 10)), + thickness: stair.thickness, + fillToFloor: stair.fillToFloor, + }) +} diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts index 8a2e78e7a3..d4939b81e7 100644 --- a/packages/core/src/systems/stair/stair-rise.test.ts +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -95,6 +95,27 @@ function buildDeckScene(options: { return { deck, stair, nodes } } +function registerStairFootprint() { + registerNode({ + kind: 'stair', + schemaVersion: 1, + schema: z.object({ type: z.literal('stair') }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: { + floorPlaced: { + footprints: (node) => [ + { + position: (node as StairNodeType).position, + dimensions: [1, 1, 2] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + ], + }, + }, + } as AnyNodeDefinition) +} + function buildLevelSceneWithSegments(options: { levelHeight: number totalRise?: number @@ -349,24 +370,7 @@ describe('deck-attached rise with a floor-lifted base', () => { ] beforeEach(() => { - registerNode({ - kind: 'stair', - schemaVersion: 1, - schema: z.object({ type: z.literal('stair') }) as never, - category: 'structure', - defaults: () => ({}) as never, - capabilities: { - floorPlaced: { - footprints: (node) => [ - { - position: (node as StairNodeType).position, - dimensions: [1, 1, 2] as [number, number, number], - rotation: [0, 0, 0] as [number, number, number], - }, - ], - }, - }, - } as AnyNodeDefinition) + registerStairFootprint() }) function makeFloorSlab(elevation: number) { @@ -497,3 +501,104 @@ describe('deck-attached rise with a floor-lifted base', () => { expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25) }) }) + +// A level-destination stair climbs to the storey plane above, which is an +// absolute level-local height — so a slab that lifts the stair's own base eats +// into the rise. Without the subtraction the last step overshoots the floor +// above by the slab's thickness (and a tall storey used to be missed entirely). +describe('level rise with a floor-lifted base', () => { + const FLOOR_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ] + + beforeEach(() => { + registerStairFootprint() + }) + + function buildLiftedLevelScene(options: { + levelHeight: number + floorElevation?: number | null + totalRise?: number + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> + }) { + const scene = buildLevelSceneWithSegments({ + levelHeight: options.levelHeight, + totalRise: options.totalRise, + segments: options.segments ?? [], + }) + if (options.floorElevation == null) return { ...scene, floor: null } + + const floor = SlabNode.parse({ + id: 'slab_floor', + type: 'slab', + polygon: FLOOR_POLYGON, + elevation: options.floorElevation, + thickness: 0.05, + }) + spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1') + return { + ...scene, + floor, + nodes: { ...scene.nodes, [floor.id]: floor } as Record, + } + } + + it('lands the last step on the storey plane: rise = floor-to-floor − elected base', () => { + const { stair, nodes } = buildLiftedLevelScene({ levelHeight: 5.3, floorElevation: 0.05 }) + const base = getFloorPlacedElevation({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: 'level_1', + }) + expect(base).toBeCloseTo(0.05) + const rise = resolveStairTotalRise(stair, nodes) + expect(rise).toBeCloseTo(5.25) + expect(base + rise).toBeCloseTo(5.3) + }) + + it('keeps the full storey height when the stair stands on bare ground', () => { + const { stair, nodes } = buildLiftedLevelScene({ levelHeight: 5.3, floorElevation: null }) + expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(5.3) + }) + + it('lets an explicit totalRise win over the base-adjusted storey rise', () => { + const { stair, nodes } = buildLiftedLevelScene({ + levelHeight: 5.3, + floorElevation: 0.05, + totalRise: 2.7, + }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.7) + }) + + it('converges a straight flight to the base-adjusted storey rise', () => { + const { nodes } = buildLiftedLevelScene({ + levelHeight: 5.3, + floorElevation: 0.05, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(5.25) + }) + + it('re-converges after the base slab elevation changes', () => { + const scene = buildLiftedLevelScene({ + levelHeight: 2.5, + floorElevation: 0.05, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.45 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const movedFloor = { ...scene.floor, elevation: 0.3 } + const nodes = { ...scene.nodes, slab_floor: movedFloor as AnyNode } + spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(2.2) + }) +}) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts index f67a07755a..3e62daf6bc 100644 --- a/packages/core/src/systems/stair/stair-rise.ts +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -10,32 +10,30 @@ export function resolveStairTotalRise(stair: StairNode, nodes: Record node.type === 'level' && node.children.includes(stair.id), ) + // Both destinations are absolute level-local heights, while the stair's own + // base may be lifted onto a floor slab by the floor-stack + // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at + // `position[1] + elected slab elevation`). The rise is measured from that + // base, so subtract it — electing the base exactly the way the visual + // systems do (persisted `supportSlabId` honored, uncapped election + // otherwise) keeps base + rise landing precisely on the destination surface. + const baseElevation = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: level?.id ?? null, + })[1] + if (stair.deckSlabId) { + // The deck's `elevation` IS its walking surface (level-local). A stale + // reference (deck gone) falls through to the level-derived rise. const deck = nodes[stair.deckSlabId] - // The deck's `elevation` IS its walking surface (level-local), but the - // stair's own base may be lifted onto a floor slab by the floor-stack - // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at - // `position[1] + elected slab elevation`). The rise is measured from - // that base, so subtract it — electing the base exactly the way the - // visual systems do (persisted `supportSlabId` honored, uncapped - // election otherwise) keeps base + rise landing precisely on the deck's - // walking surface. A stale reference (deck gone) falls through to the - // level-derived rise. - if (deck?.type === 'slab') { - const baseElevation = getFloorStackedPosition({ - node: stair, - nodes, - position: stair.position, - rotation: stair.rotation, - levelId: level?.id ?? null, - })[1] - return (deck.elevation ?? 0.05) - baseElevation - } + if (deck?.type === 'slab') return (deck.elevation ?? 0.05) - baseElevation } - return level?.type === 'level' - ? getLevelFloorToFloorHeight(level.id, nodes as Record) - : DEFAULT_LEVEL_HEIGHT + if (level?.type !== 'level') return DEFAULT_LEVEL_HEIGHT + return getLevelFloorToFloorHeight(level.id, nodes as Record) - baseElevation } const RISE_SYNC_EPSILON = 1e-4 diff --git a/packages/editor/bunfig.toml b/packages/editor/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/editor/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/editor/package.json b/packages/editor/package.json index b12dc98a4a..d019a2476f 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -20,7 +20,7 @@ "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 0a2280b66c..1c63b3a3cf 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -39,10 +39,13 @@ import { collectParticipants, computeGroupBox, expandToComponent, + type GroupPlanBounds, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupPatches, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from '../editor/group-transform-shared' @@ -112,6 +115,9 @@ export function startFloorplanGroupMove( affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + startBounds: GroupPlanBounds + restBounds: GroupPlanBounds + rotation: number restCenter: Vec2 lastDelta: Vec2 | null } @@ -140,20 +146,16 @@ export function startFloorplanGroupMove( // The group aligns as one rigid footprint: its bbox corners + center are // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays // mounted under every view mode); plan coords are level-frame, so convert. - const restBox = computeGroupBox(fullIds) const { inverse: frameInv } = levelFrame(levelId) - const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null - const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null - const restAnchors = - boxMin && boxMax - ? bboxCornerAnchors( - 'group-move', - Math.min(boxMin.x, boxMax.x), - Math.min(boxMin.z, boxMax.z), - Math.max(boxMin.x, boxMax.x), - Math.max(boxMin.z, boxMax.z), - ) - : [] + const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + if (!restBounds) return null + const restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) for (const id of affectedIds) { useLiveTransforms.getState().clear(id) @@ -169,12 +171,23 @@ export function startFloorplanGroupMove( nodeId, handle: GROUP_MOVE_DRAG_LABEL, }) - // Rotation pivot for mid-drag R/T — the participant DATA extents' center - // (stable across the drag; rotations re-seed around the same point). - const ext = participantExtents(starts) - const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0] + // Rotation pivot for mid-drag R/T — the START footprint's center, the same + // point the 3D body drag, the idle keyboard rotate and the rotate gizmos + // orbit. Stable across the drag; rotations re-seed around the same point. + const restCenter = planBoundsCenter(restBounds) - return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null } + return { + starts, + links, + affectedIds, + candidates, + restAnchors, + startBounds: restBounds, + restBounds, + rotation: 0, + restCenter, + lastDelta: null, + } } const applyMove = (e: PointerEvent, s: Session) => { @@ -241,18 +254,20 @@ export function startFloorplanGroupMove( // current delta — the carried group turns exactly like the idle keyboard // rotate, and the commit stays a single updateNodes. const rotateSession = (s: Session, direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - s.starts, - s.links, - { x: s.restCenter[0], z: s.restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: s.restCenter[0], z: s.restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + s.rotation += delta + s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation) + s.restAnchors = bboxCornerAnchors( + 'group-move', + s.restBounds.minX, + s.restBounds.minZ, + s.restBounds.maxX, + s.restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0) } @@ -358,7 +373,22 @@ export function startFloorplanGroupMove( const onKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { - if (!session) return + // Armed but still under the drag threshold: engage first (exactly what + // the next pointer-move would do) so the rotation lands inside this + // session. Falling through to the global idle arm instead would write + // the scene behind snapshots already captured here, and the first + // `applyDelta` would republish them — undoing the rotation. + if (!session) { + session = engage() + if (!session) { + // No plane hit yet: swallow the chord and keep the gesture armed so + // the next pointer-move can still engage; the idle arm must not run + // behind the snapshots captured here. + e.preventDefault() + e.stopPropagation() + return + } + } e.preventDefault() e.stopPropagation() rotateSession(session, key === 'r' ? 1 : -1) @@ -415,9 +445,13 @@ export function startFloorplanGroupRotate(event: { const { starts, links } = collectParticipants(fullIds, nodes, levelId) if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - const ext = participantExtents(starts) - if (!ext) return false - const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 } + const { inverse: frameInv } = levelFrame(levelId) + const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + if (!bounds) return false + // Same pivot as the dashed box the handles hang off (and as the 3D rotate + // gizmo): its centre, not the anchor points' centre. + const [pivotX, pivotZ] = planBoundsCenter(bounds) + const pivot = { x: pivotX, z: pivotZ } const startPlan = clientToPlan(event.clientX, event.clientY) if (!startPlan) return false // Bearing around the pivot in the plan frame — the same atan2 x→z sense diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index f05e375959..953a88b00a 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -94,6 +94,7 @@ const LOOK_SENSITIVITY = 0.002 // constant is an exponential approach rate, not a linear acceleration. const DRONE_SPEED = 7 const DRONE_RUN_MULTIPLIER = 3 +const DRONE_SLOW_MULTIPLIER = 0.2 const DRONE_SMOOTHING = 12 const CONTROLLER_CENTER_FROM_EYE = 0.85 const DOOR_INTERACTION_DISTANCE = 2.5 @@ -675,6 +676,7 @@ export const FirstPersonControls = () => { const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) const crouchKeyRef = useRef(false) const droneAscendKeyRef = useRef(false) + const droneSlowKeyRef = useRef(false) const droneDescendKeyRef = useRef(false) const droneVelocityRef = useRef(new Vector3()) const suspendRef = useRef(false) @@ -1164,10 +1166,15 @@ export const FirstPersonControls = () => { // Shutter hold: the shot is rendering — a mouse twitch must not pan it. if (useEditor.getState().captureShutterHold) return - yawRef.current -= e.movementX * LOOK_SENSITIVITY + const lookSensitivity = + LOOK_SENSITIVITY * + (useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current + ? DRONE_SLOW_MULTIPLIER + : 1) + yawRef.current -= e.movementX * lookSensitivity pitchRef.current = Math.max( -(Math.PI / 2 - 0.05), - Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * lookSensitivity), ) } @@ -1280,6 +1287,10 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() if (!suspendRef.current) droneAscendKeyRef.current = true + } else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) { + event.preventDefault() + event.stopPropagation() + if (!suspendRef.current) droneSlowKeyRef.current = true } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() @@ -1336,6 +1347,9 @@ export const FirstPersonControls = () => { if (event.code === 'KeyE' && !suspendRef.current) { droneAscendKeyRef.current = false } + if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) { + droneSlowKeyRef.current = false + } applyMovementKey(event, false) } @@ -1344,6 +1358,7 @@ export const FirstPersonControls = () => { crouchKeyRef.current = false droneAscendKeyRef.current = false droneDescendKeyRef.current = false + droneSlowKeyRef.current = false } } @@ -1617,7 +1632,14 @@ export const FirstPersonControls = () => { if (droneDesiredVelocity.lengthSq() > 0) { droneDesiredVelocity .normalize() - .multiplyScalar(DRONE_SPEED * (movement.run ? DRONE_RUN_MULTIPLIER : 1)) + .multiplyScalar( + DRONE_SPEED * + (droneSlowKeyRef.current + ? DRONE_SLOW_MULTIPLIER + : movement.run + ? DRONE_RUN_MULTIPLIER + : 1), + ) } droneVelocityRef.current.lerp(droneDesiredVelocity, 1 - Math.exp(-step * DRONE_SMOOTHING)) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index d14e8775ac..e2c0d68765 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -40,9 +40,11 @@ import { collectParticipants, computeGroupBox, expandToComponent, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from './group-transform-shared' @@ -104,46 +106,15 @@ export function startGroupPickUp( if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - // Rest bounds in the level frame. Prefer the mounted meshes' world box - // (footprint-accurate), but fall back to the participant DATA when the - // meshes aren't up yet — Duplicate starts the pick-up synchronously after - // `createNodes`, one frame before the clones' renderers mount. const { inverse: frameInv } = levelFrame(levelId) const restBox = computeGroupBox(fullIds) - let minX = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - if (restBox) { - const boxMin = restBox.min.clone().applyMatrix4(frameInv) - const boxMax = restBox.max.clone().applyMatrix4(frameInv) - minX = Math.min(boxMin.x, boxMax.x) - minZ = Math.min(boxMin.z, boxMax.z) - maxX = Math.max(boxMin.x, boxMax.x) - maxZ = Math.max(boxMin.z, boxMax.z) - } else { - const reach = (x: number, z: number) => { - minX = Math.min(minX, x) - minZ = Math.min(minZ, z) - maxX = Math.max(maxX, x) - maxZ = Math.max(maxZ, z) - } - for (const s of starts) { - if (s.kind === 'endpoint') { - reach(s.start[0], s.start[1]) - reach(s.end[0], s.end[1]) - } else if (s.kind === 'polygon') { - for (const [x, z] of s.polygon) { - reach(x, z) - } - } else { - reach(s.position[0], s.position[2]) - } - } - } - if (!Number.isFinite(minX)) return false + const startBounds = groupPlanBounds(restBox, starts, frameInv) + if (!startBounds) return false + // Mutable: mid-carry R/T re-seeds the footprint around the same pivot. + let restBounds = startBounds + let carriedRotation = 0 // Rotation pivot for mid-carry R/T; stable across the whole pick-up. - const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2] + const restCenter = planBoundsCenter(restBounds) // Ground plane for the 3D surface: the meshes' base when available, floor // level otherwise. Placements live in the level frame, so both surfaces // resolve into it before measuring. @@ -157,7 +128,13 @@ export function startGroupPickUp( if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ) + let restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) // Cursor → level-frame plan point, whichever surface the pointer is over. const ndc = new Vector2() @@ -274,18 +251,20 @@ export function startGroupPickUp( // the current delta — the carried group turns exactly like the idle // keyboard rotate, and the placement stays a single updateNodes. const rotateCarried = (direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - starts, - links, - { x: restCenter[0], z: restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: restCenter[0], z: restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(starts, links, pivot, delta) starts = rotated.starts links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + carriedRotation += delta + restBounds = rotatePlanBounds(startBounds, pivot, carriedRotation) + restAnchors = bboxCornerAnchors( + 'group-move', + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0) } diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index 967238dafe..3a26251e5d 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -32,9 +32,12 @@ import { collectParticipants, computeGroupBox, expandToComponent, + type GroupPlanBounds, + groupPlanBounds, levelFrame, - participantExtents, + planBoundsCenter, rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, type Vec2, } from './group-transform-shared' @@ -88,6 +91,9 @@ export function armGroupMove3d(args: { affectedIds: AnyNodeId[] candidates: ReturnType restAnchors: ReturnType + startBounds: GroupPlanBounds + restBounds: GroupPlanBounds + rotation: number restCenter: Vec2 plane: Plane startLocal: Vector3 @@ -130,14 +136,14 @@ export function armGroupMove3d(args: { if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - const boxMin = restBox.min.clone().applyMatrix4(frameInv) - const boxMax = restBox.max.clone().applyMatrix4(frameInv) + const restBounds = groupPlanBounds(restBox, starts, frameInv) + if (!restBounds) return null const restAnchors = bboxCornerAnchors( 'group-move', - Math.min(boxMin.x, boxMax.x), - Math.min(boxMin.z, boxMax.z), - Math.max(boxMin.x, boxMax.x), - Math.max(boxMin.z, boxMax.z), + restBounds.minX, + restBounds.minZ, + restBounds.maxX, + restBounds.maxZ, ) for (const id of affectedIds) { @@ -154,9 +160,11 @@ export function armGroupMove3d(args: { nodeId, handle: GROUP_MOVE_DRAG_LABEL, }) - // Rotation pivot for mid-drag R/T — the participant DATA extents' center. - const ext = participantExtents(starts) - const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0] + // Rotation pivot for mid-drag R/T — the START footprint's center, the same + // point the idle keyboard rotate and the rotate gizmos orbit. Fixed for the + // whole session: the snapshots are start placements, and `applyDelta` adds + // the live drag delta on top of them. + const restCenter = planBoundsCenter(restBounds) return { starts, @@ -164,6 +172,9 @@ export function armGroupMove3d(args: { affectedIds, candidates, restAnchors, + startBounds: restBounds, + restBounds, + rotation: 0, restCenter, plane, startLocal, @@ -245,18 +256,22 @@ export function armGroupMove3d(args: { // current delta — the carried group turns exactly like the idle keyboard // rotate, and the commit stays a single updateNodes. const rotateSession = (s: Session, direction: 1 | -1) => { - const rotated = rotateGroupSnapshots( - s.starts, - s.links, - { x: s.restCenter[0], z: s.restCenter[1] }, - -direction * (Math.PI / 4), - ) + const pivot = { x: s.restCenter[0], z: s.restCenter[1] } + const delta = -direction * (Math.PI / 4) + const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta) s.starts = rotated.starts s.links = rotated.links - const ext = participantExtents(rotated.starts) - if (ext) { - s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ) - } + // Re-fit from the start footprint at the accumulated angle: rotating the + // previous axis-aligned fit would inflate the box every step. + s.rotation += delta + s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation) + s.restAnchors = bboxCornerAnchors( + 'group-move', + s.restBounds.minX, + s.restBounds.minZ, + s.restBounds.maxX, + s.restBounds.maxZ, + ) sfxEmitter.emit('sfx:item-rotate') applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0) } @@ -369,7 +384,22 @@ export function armGroupMove3d(args: { const onKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { - if (!session) return + // Armed but still under the drag threshold: engage first (exactly what + // the next pointer-move would do) so the rotation lands inside this + // session. Falling through to the global idle arm instead would write + // the scene behind snapshots already captured here, and the first + // `applyDelta` would republish them — undoing the rotation. + if (!session) { + session = engage() + if (!session) { + // No plane hit yet: swallow the chord and keep the gesture armed so + // the next pointer-move can still engage; the idle arm must not run + // behind the snapshots captured here. + e.preventDefault() + e.stopPropagation() + return + } + } e.preventDefault() e.stopPropagation() rotateSession(session, key === 'r' ? 1 : -1) diff --git a/packages/editor/src/components/editor/group-transform-shared.test.ts b/packages/editor/src/components/editor/group-transform-shared.test.ts index 5b741b45d7..ff9bedfd24 100644 --- a/packages/editor/src/components/editor/group-transform-shared.test.ts +++ b/packages/editor/src/components/editor/group-transform-shared.test.ts @@ -4,7 +4,10 @@ import { z } from 'zod' import { classifyParticipant, collectParticipants, + planBoundsCenter, rotateGroupPatches, + rotateGroupSnapshots, + rotatePlanBounds, translateGroupPatches, } from './group-transform-shared' @@ -40,6 +43,34 @@ function registerElevatorTestKind() { } as AnyNodeDefinition) } +// A level holding one of each rigid placement shape: an item ([x,y,z] rotation) +// and a column (numeric rotation). +function placedNodes() { + return { + building_test: { id: 'building_test', type: 'building', children: ['level_test'] }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: ['item_chair', 'column_post'], + }, + item_chair: { + id: 'item_chair', + type: 'item', + parentId: 'level_test', + position: [1, 0, 3], + rotation: [0, 0.5, 0], + }, + column_post: { + id: 'column_post', + type: 'column', + parentId: 'level_test', + position: [4, 0, -1], + rotation: 1.25, + }, + } as unknown as Record +} + describe('group transform participants', () => { beforeAll(() => { registerBuildingScopedTestKind() @@ -362,6 +393,53 @@ describe('group transform participants', () => { expect(lampPatch.position).toEqual([3, 2.4, 2]) }) + test('translate patches carry the snapshot rotation for vec3 and scalar kinds', () => { + const { starts } = collectParticipants( + ['item_chair', 'column_post'], + placedNodes(), + 'level_test', + ) + const patches = Object.fromEntries(translateGroupPatches(starts, [], 1, 2)) + + expect(patches.item_chair).toEqual({ position: [2, 0, 5], rotation: [0, 0.5, 0] }) + expect(patches.column_post).toEqual({ position: [5, 0, 1], rotation: 1.25 }) + }) + + test('a mid-drag rotation survives the next translate re-publish', () => { + const { starts } = collectParticipants( + ['item_chair', 'column_post'], + placedNodes(), + 'level_test', + ) + // What a mid-drag R does: turn the snapshots, then re-apply the live delta. + const rotated = rotateGroupSnapshots(starts, [], { x: 0, z: 0 }, Math.PI / 2) + const patches = Object.fromEntries( + translateGroupPatches(rotated.starts, rotated.links, 1, 2), + ) as Record + + // Orbited 90° in the atan2 x→z sense ((x, z) → (-z, x)), then slid. + expect(patches.item_chair!.position[0]).toBeCloseTo(-2) + expect(patches.item_chair!.position[2]).toBeCloseTo(3) + expect(patches.column_post!.position[0]).toBeCloseTo(2) + expect(patches.column_post!.position[2]).toBeCloseTo(6) + // …and the facings turned with it instead of reverting to the pre-drag yaw. + expect((patches.item_chair!.rotation as number[])[1]).toBeCloseTo(0.5 - Math.PI / 2) + expect(patches.column_post!.rotation as number).toBeCloseTo(1.25 - Math.PI / 2) + }) + + test('rotating the plan bounds keeps the footprint centred on the pivot', () => { + const bounds = { minX: 0, minZ: 0, maxX: 4, maxZ: 2 } + const [pivotX, pivotZ] = planBoundsCenter(bounds) + const rotated = rotatePlanBounds(bounds, { x: pivotX, z: pivotZ }, Math.PI / 2) + + expect(rotated.minX).toBeCloseTo(1) + expect(rotated.maxX).toBeCloseTo(3) + expect(rotated.minZ).toBeCloseTo(-1) + expect(rotated.maxZ).toBeCloseTo(3) + expect(planBoundsCenter(rotated)[0]).toBeCloseTo(pivotX) + expect(planBoundsCenter(rotated)[1]).toBeCloseTo(pivotZ) + }) + test('supports legacy level-parented elevators already loaded in the editor', () => { const nodes = { building_test: { diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 8e077474c8..408eb2cc65 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -387,12 +387,11 @@ export function rotateGroupSnapshots( return { starts: rotatedStarts, links: rotatedLinks } } +export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number } + // Level-frame XZ extents of the participant DATA — the mesh-free sibling of -// `computeGroupBox`, used when meshes aren't mounted yet and to re-seed -// alignment anchors after a mid-drag rotation. -export function participantExtents( - starts: ParticipantStart[], -): { minX: number; minZ: number; maxX: number; maxZ: number } | null { +// `computeGroupBox`, used when meshes aren't mounted yet. +function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null { let minX = Number.POSITIVE_INFINITY let minZ = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY @@ -419,8 +418,73 @@ export function participantExtents( return { minX, minZ, maxX, maxZ } } +// The one footprint every group transform measures itself against: the +// selection's mounted meshes (world box, converted into the level frame) with +// the participant DATA extents as the fallback when the meshes aren't up yet +// (Duplicate picks up its clones a frame before their renderers mount). Anchor +// points alone sit metres inside a wide selection's real footprint, so a +// gesture that pivots on the data extents orbits a different point than the +// idle keyboard rotate and the rotate gizmos, which both use the mesh box. +export function groupPlanBounds( + box: Box3 | null, + starts: ParticipantStart[], + frameInv: Matrix4, +): GroupPlanBounds | null { + if (!box) return participantExtents(starts) + const min = box.min.clone().applyMatrix4(frameInv) + const max = box.max.clone().applyMatrix4(frameInv) + return { + minX: Math.min(min.x, max.x), + minZ: Math.min(min.z, max.z), + maxX: Math.max(min.x, max.x), + maxZ: Math.max(min.z, max.z), + } +} + +export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [ + (b.minX + b.maxX) / 2, + (b.minZ + b.maxZ) / 2, +] + +// Re-seed the footprint after a mid-gesture rotation by orbiting the box +// corners and re-fitting an axis-aligned box. Re-measuring the rotated DATA +// extents instead would slide the centre off the pivot the snapshots turned +// around, dragging the alignment anchors away from the group under the cursor. +export function rotatePlanBounds( + b: GroupPlanBounds, + center: { x: number; z: number }, + delta: number, +): GroupPlanBounds { + const cos = Math.cos(delta) + const sin = Math.sin(delta) + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + const corners: Vec2[] = [ + [b.minX, b.minZ], + [b.maxX, b.minZ], + [b.maxX, b.maxZ], + [b.minX, b.maxZ], + ] + for (const [x, z] of corners) { + const dx = x - center.x + const dz = z - center.z + const rx = center.x + dx * cos - dz * sin + const rz = center.z + dx * sin + dz * cos + minX = Math.min(minX, rx) + minZ = Math.min(minZ, rz) + maxX = Math.max(maxX, rx) + maxZ = Math.max(maxZ, rz) + } + return { minX, minZ, maxX, maxZ } +} + // Rigid group slide: shift every participant (and each linked neighbour's -// shared endpoint) by the same level-frame XZ delta. Y and rotations untouched. +// shared endpoint) by the same level-frame XZ delta. Y is untouched; the +// snapshot's rotation rides along because a mid-gesture R/T turns the +// SNAPSHOTS — dropping it here would republish (and commit) the pre-rotation +// facing, orbiting the layout while every member keeps its old bearing. export function translateGroupPatches( starts: ParticipantStart[], links: LinkedNeighbor[], @@ -437,7 +501,10 @@ export function translateGroupPatches( if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift)) patches.push([s.id, patch]) } else { - patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }]) + const position: Vec3 = [s.position[0] + dx, s.position[1], s.position[2] + dz] + const rotation = + s.kind === 'vec3' ? ([s.rotation[0], s.rotation[1], s.rotation[2]] as Vec3) : s.rotation + patches.push([s.id, { position, rotation }]) } } for (const l of links) { diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d6544ee254..8da2bcd690 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -34,6 +34,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' +import { useSaveShortcut } from '../../hooks/use-save-shortcut' import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' import { applySceneGraphToEditor, @@ -194,6 +195,12 @@ export interface EditorProps { // Persistence — defaults to localStorage when omitted onLoad?: () => Promise onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise + /** + * Cmd/Ctrl+S. Return true when the host handled the save (the community + * version checkpoint); anything else falls through to flushing the autosave, + * so the chord still saves when the host's control isn't mounted. + */ + onSaveShortcut?: () => boolean | undefined onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void @@ -1229,6 +1236,7 @@ function EditorContent({ projectId, onLoad, onSave, + onSaveShortcut, onDirty, onSaveStatusChange, previewScene, @@ -1249,13 +1257,19 @@ function EditorContent({ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode }) - const { isLoadingSceneRef } = useAutoSave({ + const { isLoadingSceneRef, saveNow } = useAutoSave({ onSave, onDirty, onSaveStatusChange, isVersionPreviewMode, }) + const handleSaveShortcut = useCallback(() => { + if (onSaveShortcut?.() === true) return + saveNow() + }, [onSaveShortcut, saveNow]) + useSaveShortcut(handleSaveShortcut) + const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) // A failed `onLoad` is shown as an error with a retry, never as an empty diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index f97b8d98f6..226df5742b 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -159,6 +159,7 @@ const CAMERA_NAV_HINTS: Record { const mergedMesh = group.getObjectByName('merged-stair') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeStairIds.has(stairId) + // A straight stair with no segment children has an empty wrapper, so + // edit mode would hide the merged body and leave nothing on screen. + const isEditable = !isCurved && (stairNode?.children?.length ?? 0) > 0 - if (mergedMesh) mergedMesh.visible = !(isActive || isCurved) - if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved + if (mergedMesh) mergedMesh.visible = !((isActive && isEditable) || isCurved) + if (segmentsWrapper) segmentsWrapper.visible = isActive && isEditable if (stairNode?.children?.length) { const wasActive = prevActiveStairIds.current.has(stairId) diff --git a/packages/editor/src/components/tools/item/placement-strategies.test.ts b/packages/editor/src/components/tools/item/placement-strategies.test.ts index b01048dd45..73006ceb06 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.test.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { BlockNode, + type GridEvent, ItemNode, type LevelNode, type NodeEvent, @@ -9,7 +10,7 @@ import { type WallNode, } from '@pascal-app/core' import { BufferGeometry, Mesh, MeshBasicMaterial, type Object3D, Vector3 } from 'three' -import { faceHostStrategy, wallStrategy } from './placement-strategies' +import { faceHostStrategy, floorStrategy, wallStrategy } from './placement-strategies' import type { PlacementContext, SpatialValidators } from './placement-types' import { registerTestBlockFaceHost } from './test-face-host' @@ -455,3 +456,39 @@ describe('wallStrategy.move', () => { expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6) }) }) + +describe('floorStrategy.move', () => { + function makeGridEvent(x: number, y: number, z: number): GridEvent { + return { + position: [x, y, z], + localPosition: [x, y, z], + nativeEvent: {} as GridEvent['nativeEvent'], + } + } + + test('follows the live grid Y so a raised placement keeps its height', () => { + const context = floorItemContext() + context.gridPosition.set(0, 0.9, 0) + + const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25)) + if (!result) throw new Error('expected a placement result') + + expect(result.gridPosition[1]).toBe(0.9) + expect(result.cursorPosition[1]).toBe(0.9) + }) + + // `detachItemSurfaceToFloor` zeroes the grid Y when an item is taken off a + // host; the floor path must honour that instead of a Y frozen at drag start, + // or the item commits floating at the shelf's height. + test('drops to the level plane once un-hosting zeroes the grid Y', () => { + const context = floorItemContext() + context.gridPosition.set(0, 0, 0) + + const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25)) + if (!result) throw new Error('expected a placement result') + + expect(result.gridPosition[1]).toBe(0) + expect(result.cursorPosition[1]).toBe(0) + expect(result.nodeUpdate?.position?.[1]).toBe(0) + }) +}) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index ccc793d417..b6a66b393e 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -6,6 +6,7 @@ import { type CeilingEvent, collectAlignmentAnchors, emitter, + findLevelAncestorId, type GridEvent, getScaledDimensions, type ItemEvent, @@ -193,6 +194,27 @@ function getGridAlignedPreviewNode(item: ItemNode): ItemNode { } } +/** + * Building-local Y of the storey the floor-path ghost belongs to. + * + * The cursor group is mounted inside ToolManager's building-local group, which + * carries no per-floor elevation, while every floor-path position (grid + * position, `getFloorVisualPosition`) is LEVEL-local — so on an upper storey the + * wireframe and its dimension labels render a floor too low. The wall / ceiling + * / item-surface paths don't need this: they convert a world hit through + * `worldToBuildingLocal`, which already carries the storey. + * + * Read off the level mesh (same source as `LevelOffsetGroup`) rather than the + * stored elevation so the ghost also follows the exploded-view lerp. + */ +function getPlacementLevelY(draft: ItemNode | null | undefined): number { + const levelId = + (draft ? findLevelAncestorId(draft.id, useScene.getState().nodes) : null) ?? + useViewer.getState().selection.levelId + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null + return levelMesh ? levelMesh.position.y : 0 +} + // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ @@ -521,7 +543,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 if (cursorGroupRef.current) { - cursorGroupRef.current.position.y = 0 + cursorGroupRef.current.position.y = getPlacementLevelY(draftNode.current) } } @@ -731,7 +753,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) - const floorAuthoredY = draftNode.current?.position[1] ?? 0 const preserveDragOffset = configRef.current.preserveDragOffset === true // The host the item was grabbed from + its pre-drag host-local position. // Each surface's grab anchor preserves the grab offset only on THAT host, @@ -890,7 +911,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } } else if (cursorGroupRef.current) { + // No registered mesh yet (a just-created draft renders next tick), so + // fall back to the level-local grid position lifted onto its storey. cursorGroupRef.current.position.copy(gridPosition.current) + cursorGroupRef.current.position.y += getPlacementLevelY(draftNode.current) cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 } } @@ -1064,9 +1088,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useAlignmentGuides.getState().clear() } + // `result.gridPosition[1]` is the LIVE `gridPosition.current.y` — seeded + // from the draft's authored Y by `initDraft` (so a block-face / raised + // construction-plane item keeps its height) and zeroed by + // `detachItemSurfaceToFloor` / `faceHostStrategy.leave` when the item + // comes back down. Freezing it at drag start instead left an item taken + // off a shelf floating at the shelf's height. let gridPos: [number, number, number] = [ result.gridPosition[0] + alignX, - floorAuthoredY, + result.gridPosition[1], result.gridPosition[2] + alignZ, ] frozenSupportSlabIdRef.current = undefined @@ -1104,7 +1134,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!draft && asset.attachTo) { cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo) } - cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2]) + cursorGroupRef.current.position.set( + cursorPosition[0], + cursorPosition[1] + getPlacementLevelY(draft), + cursorPosition[2], + ) // Floor items only rotate on Y; keep the preview box (and the live // transform the 2D floorplan mirrors) aligned with the draft's // rotation. Without this the box stays at its seed rotation until a @@ -1684,7 +1718,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea levelId ? { parentId: levelId } : undefined, ) if (cursorGroupRef.current) { - cursorGroupRef.current.position.set(...floorVisualPosition) + cursorGroupRef.current.position.set( + floorVisualPosition[0], + floorVisualPosition[1] + getPlacementLevelY(draftNode.current), + floorVisualPosition[2], + ) } const draft = draftNode.current @@ -2271,8 +2309,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draft.position = [x, gridPosition.current.y, z] if (cursorGroupRef.current) { if (surface === 'floor') { + const visual = getFloorVisualPosition([x, gridPosition.current.y, z]) cursorGroupRef.current.position.set( - ...getFloorVisualPosition([x, gridPosition.current.y, z]), + visual[0], + visual[1] + getPlacementLevelY(draft), + visual[2], ) } else { cursorGroupRef.current.position.x = x @@ -2601,6 +2642,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // moving existing node has no draft here, so the grid reads that case straight // off the node's mesh. Cleared when idle. const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) + const surfaceWorldPointRef = useRef(new Vector3()) const facingForwardRef = useRef(new Vector3(0, 0, 1)) const facingQuatRef = useRef(new Quaternion()) const ghostSurfaceQuatRef = useRef(new Quaternion()) @@ -2642,13 +2684,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const fwd = facingForwardRef.current.copy(n) if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z) // The forward triangle is a floor aid; drop it to the building-local floor - // under the hosted plane. - facingY = 0 + // under the hosted plane — the storey's floor, not world ground. + facingY = getPlacementLevelY(draftNode.current) } else { ghost.getWorldQuaternion(ghostSurfaceQuatRef.current) resolveItemPlacementSurfaceNormal(surf, ghostSurfaceQuatRef.current, null, n) } - publishPlacementSurface(ghost.position, n) + // `publishPlacementSurface` is a WORLD-space contract (the grid reads it in + // world space), but the ghost lives in the building-local tool group. + publishPlacementSurface(ghost.getWorldPosition(surfaceWorldPointRef.current), n) if (shape.depth > 0) { useFacingPose.getState().set({ @@ -2716,7 +2760,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z, ]) mesh.position.y = visualPosition[1] - cursorGroupRef.current.position.y = visualPosition[1] + cursorGroupRef.current.position.y = + visualPosition[1] + getPlacementLevelY(draftNode.current) } } else if (placementState.current.surface === 'block-face') { const rotation = draftNode.current.rotation diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 1868208709..7b62a4473f 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -11,6 +11,7 @@ import { collectAlignmentAnchors, createSceneApi, emitter, + findLevelAncestorId, footprintAABBFrom, type GridEvent, type GroupMoveSnapResult, @@ -33,12 +34,16 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useThree } from '@react-three/fiber' +import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { Group } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' -import { resolvePrioritizedPlanarCursorPosition } from '../../../lib/planar-cursor-placement' +import { + offsetPlanPositionByLocalCenter, + resolvePrioritizedPlanarCursorPosition, +} from '../../../lib/planar-cursor-placement' import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform' import { movementSfxStepKey } from '../../../lib/sfx/movement-tick' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -95,20 +100,6 @@ type DragBoundsOverride = { centerY?: number } -function offsetPlanPositionByLocalCenter( - position: [number, number, number], - center: [number, number, number], - rotationY: number, -): [number, number, number] { - const cos = Math.cos(rotationY) - const sin = Math.sin(rotationY) - return [ - position[0] + center[0] * cos + center[2] * sin, - position[1] + center[1], - position[2] - center[0] * sin + center[2] * cos, - ] -} - /** * Alignment anchors for the moving node. When the kind declares * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet @@ -233,6 +224,20 @@ const ALIGNMENT_THRESHOLD_M = 0.08 type ClickTriggerEvent = GridEvent | NodeEvent export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + const previewGroupRef = useRef(null) + useFrame(() => { + if (!previewGroupRef.current) return + const nodes = useScene.getState().nodes + const parentId = nodes[node.id]?.parentId ?? node.parentId + const parent = parentId ? nodes[parentId as AnyNodeId] : undefined + // Building-parented kinds already preview in the tool group's frame. + const levelId = + (parentId ? findLevelAncestorId(parentId as AnyNodeId, nodes) : null) ?? + (parent?.type === 'building' ? null : useViewer.getState().selection.levelId) + previewGroupRef.current.position.y = levelId + ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) + : 0 + }) // Live camera ref — the pointer-surface cap reconstructs the cursor world // ray (camera → grid hit) to find which walking surface is aimed at. const camera = useThree((s) => s.camera) @@ -702,12 +707,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const magnetic = isMagneticSnapActive() const attachmentEnabled = magnetic || isGridSnapActive() + const absolute = useAbsoluteCursorPlacement || cursorAttached + const centerOffset: [number, number, number] = + absolute && dragBounds?.center + ? offsetPlanPositionByLocalCenter( + [0, 0, 0], + dragBounds.center, + previewRotationY(freeRotationRef.current), + ) + : [0, 0, 0] let attachmentRotationY: number | null = null const resolved = resolvePrioritizedPlanarCursorPosition({ cursor: [rawX, rawZ], original: [originalPlanPosition[0], originalPlanPosition[2]], anchor: dragAnchorRef.current, - mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', + mode: absolute ? 'absolute' : 'relative', + localCenter: dragBounds?.center, + rotationY: previewRotationY(freeRotationRef.current), // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. snap: gridSnapPositionConfig ? undefined : snapToGridStep, snapPoint: @@ -715,7 +731,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? ([planX, planZ]) => { const snappedPosition = gridSnapPositionConfig({ node, - candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), + // Kind-owned grid hooks exchange origins and apply their own footprint offsets. + candidatePosition: canonicalPositionFromPlan( + planX - centerOffset[0], + originalPosition[1], + planZ - centerOffset[2], + ), candidateRotation: freeRotationRef.current, movingIds: [node.id as AnyNodeId], nodes: useScene.getState().nodes as Record, @@ -729,7 +750,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { snappedPosition, freeRotationRef.current, ) - return [snappedPlanPosition[0], snappedPlanPosition[2]] + return [ + snappedPlanPosition[0] + centerOffset[0], + snappedPlanPosition[2] + centerOffset[2], + ] } : undefined, resolveAttachment: @@ -738,7 +762,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const snapArgs: Parameters>[0] = { node, candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), - candidateRotation: rotationRef.current, + candidateRotation: + absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current, movingIds: [node.id as AnyNodeId], nodes: useScene.getState().nodes as Record, levelId: @@ -1119,10 +1144,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ) if (nextFreeRotation === null) return sfxEmitter.emit('sfx:item-rotate') + let position = lastCursorRef.current + if ( + hasMovedRef.current && + (useAbsoluteCursorPlacement || cursorAttached) && + dragBounds?.center + ) { + const planCenter = offsetPlanPositionByLocalCenter( + getVisualPosition(position), + dragBounds.center, + previewRotationY(rotationRef.current), + ) + const planOrigin = offsetPlanPositionByLocalCenter( + planCenter, + [-dragBounds.center[0], 0, -dragBounds.center[2]], + previewRotationY(nextFreeRotation), + ) + position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2]) + lastCursorRef.current = position + } freeRotationRef.current = nextFreeRotation rotationRef.current = freeRotationRef.current setCursorRotationY(previewRotationY(rotationRef.current)) - const position = lastCursorRef.current const visualPosition = getVisualPosition(position) setCursorPosition(visualPosition) applyMeshPose(position) @@ -1255,12 +1298,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (boxDimensions && !dragBounds?.center) { return ( - + + + ) } @@ -1270,7 +1315,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { : cursorPosition return ( - <> + - + ) } diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index a07de846c2..71dabb502e 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,9 +1,13 @@ import { type AnyNode, collectAlignmentAnchors, + createDefaultStairSegment, createSurfaceOpeningPreviewController, + DEFAULT_LEVEL_HEIGHT, emitter, type GridEvent, + getFloorStackedPosition, + getLevelFloorToFloorHeight, type LevelNode, movingAlignmentAnchors, type NodeEvent, @@ -11,7 +15,7 @@ import { resolveFrozenFloorPlacementPatch, resolveSupportSlabPatch, StairNode, - StairSegmentNode, + type StairSegmentNode, syncAutoStairOpenings, useScene, } from '@pascal-app/core' @@ -50,7 +54,6 @@ import { DEFAULT_SPIRAL_TOP_LANDING_MODE, DEFAULT_STAIR_ATTACHMENT_SIDE, DEFAULT_STAIR_FILL_TO_FLOOR, - DEFAULT_STAIR_HEIGHT, DEFAULT_STAIR_LENGTH, DEFAULT_STAIR_OPENING_OFFSET, DEFAULT_STAIR_RAILING_HEIGHT, @@ -71,8 +74,8 @@ type MoveTriggerEvent = GridEvent | NodeEvent * Generates the step-profile geometry for the ghost preview. * Same algorithm as StairSystem's generateStairSegmentGeometry. */ -function createStairPreviewGeometry(): THREE.BufferGeometry { - const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT +function createStairPreviewGeometry(rise: number): THREE.BufferGeometry { + const riserHeight = rise / DEFAULT_STAIR_STEP_COUNT const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT const shape = new THREE.Shape() @@ -103,19 +106,40 @@ function createStairPreviewGeometry(): THREE.BufferGeometry { } /** - * Creates a default straight stair segment. + * Creates a default straight stair segment climbing `rise` — the storey it is + * dropped on, not a constant: the placed stair has no explicit `totalRise`, so + * this is the height `syncStairRises` immediately converges it to anyway. */ -function createDefaultStairSegment() { - return StairSegmentNode.parse({ - segmentType: 'stair', +function resolvePlacedStairRise( + nodes: Record, + levelId: LevelNode['id'], + stair: StairNode, + supportSurface: PointerSupportSurface | null, +): number { + // Same contract as `resolveStairTotalRise` for a stair that is not in the + // scene yet: the storey height minus whatever slab lifts the drop point, + // capped by the surface the pointer actually aims at (a floor under an + // overlapping deck must not elect the deck). + const base = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId, + maxElevation: supportSurface?.elevation ?? null, + })[1] + return getLevelFloorToFloorHeight(levelId, nodes) - base +} + +function createSeedStairSegment(rise: number) { + return createDefaultStairSegment({ width: DEFAULT_STAIR_WIDTH, length: DEFAULT_STAIR_LENGTH, - height: DEFAULT_STAIR_HEIGHT, + height: rise, stepCount: DEFAULT_STAIR_STEP_COUNT, attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, thickness: DEFAULT_STAIR_THICKNESS, - position: [0, 0, 0], }) } @@ -178,7 +202,7 @@ function commitStairPlacement( const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const name = `Staircase ${stairCount + 1}` - const segment = createDefaultStairSegment() + const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const destinationPlan = resolveStairDestinationLevel({ createMissing: true, @@ -194,10 +218,14 @@ function commitStairPlacement( nextLevelId, position, rotation, - segmentId: segment.id, + segmentId: seed.id, }), parentId: placementLevelId, }) + const segment = { + ...seed, + height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface), + } const prospectiveNodes = { ...nodes, [stair.id]: stair, @@ -248,7 +276,13 @@ export const StairTool: React.FC = () => { const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) - const previewGeometry = useMemo(() => createStairPreviewGeometry(), []) + const previewRise = useScene((state) => + currentLevelId ? getLevelFloorToFloorHeight(currentLevelId, state.nodes) : DEFAULT_LEVEL_HEIGHT, + ) + const previewRiseRef = useRef(previewRise) + previewRiseRef.current = previewRise + const previewGeometry = useMemo(() => createStairPreviewGeometry(previewRise), [previewRise]) + useEffect(() => () => previewGeometry.dispose(), [previewGeometry]) useEffect(() => { if (!currentLevelId) return @@ -261,11 +295,18 @@ export const StairTool: React.FC = () => { // Reset rotation when tool activates rotationRef.current = 0 useStairBuildPreview.getState().reset() - if (previewRef.current) previewRef.current.rotation.y = 0 + if (previewRef.current) { + previewRef.current.rotation.y = 0 + previewRef.current.scale.y = 1 + } lastCanonicalPositionRef.current = null supportSurfaceRef.current = null - const buildPreviewScene = (position: [number, number, number], rotation: number) => { + const buildPreviewScene = ( + position: [number, number, number], + rotation: number, + supportSurface: PointerSupportSurface | null, + ) => { const nodes = useScene.getState().nodes const placementLevelId = resolveStairPlacementLevelId( nodes, @@ -280,15 +321,19 @@ export const StairTool: React.FC = () => { nodes, }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId - const segment = createDefaultStairSegment() + const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes)) const stair = createDefaultStairNode({ name: 'Staircase Preview', levelId: placementLevelId, nextLevelId, position, rotation, - segmentId: segment.id, + segmentId: seed.id, }) + const segment = { + ...seed, + height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface), + } const previewNodes = { ...nodes, ...(destinationPlan?.createdLevel @@ -298,7 +343,7 @@ export const StairTool: React.FC = () => { [segment.id]: { ...segment, parentId: stair.id }, } as Record - return { placementLevelId, previewNodes, stair } + return { placementLevelId, previewNodes, stair, rise: segment.height } } // The preview rebuild (full-scene copy + destination-level resolution + @@ -319,7 +364,7 @@ export const StairTool: React.FC = () => { if (key === lastPreviewKey) return lastPreviewKey = key useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation) - const preview = buildPreviewScene(position, rotation) + const preview = buildPreviewScene(position, rotation, supportSurface) const frozenPatch = preview && supportSurface?.sourceNodeId ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, { @@ -355,6 +400,9 @@ export const StairTool: React.FC = () => { if (previewRef.current) { previewRef.current.position.set(...visualPosition) previewRef.current.rotation.y = rotation + // The ghost geometry is built for the storey height; squash it to the + // rise the placed flight will get on this surface. + previewRef.current.scale.y = preview ? preview.rise / previewRiseRef.current : 1 } // Forward-facing triangle (editor-side overlay). The run ascends along @@ -388,7 +436,7 @@ export const StairTool: React.FC = () => { z: number, rotation: number, ): ReturnType | null => { - const preview = buildPreviewScene([x, 0, z], rotation) + const preview = buildPreviewScene([x, 0, z], rotation, supportSurfaceRef.current) const moving = preview ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation) : [] diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index b81d279ff7..ccc4b031a6 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -59,6 +59,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { keys: ['Delete / Backspace'], action: 'Delete selected objects' }, { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' }, { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' }, + { keys: ['Cmd/Ctrl', 'S'], action: 'Save' }, ], }, { diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index a64e6a047d..adf1d3e8d5 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -94,7 +94,10 @@ export function useAutoSave({ onDirty, onSaveStatusChange, isVersionPreviewMode = false, -}: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject } { +}: UseAutoSaveOptions): { + isLoadingSceneRef: MutableRefObject + saveNow: () => void +} { const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) // Starts TRUE: the scene is "loading" from mount until the Editor's load @@ -339,5 +342,24 @@ export function useAutoSave({ setSaveStatus('saved') }, [isVersionPreviewMode, setSaveStatus]) - return { isLoadingSceneRef } + // Imperative flush for the save shortcut: drop the debounce and write now, + // through the same `executeSave` so the wipe guard and status callbacks stay + // in the loop. A write already in flight only arms the follow-up. + const saveNow = useCallback(() => { + if (isLoadingSceneRef.current) return + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + saveTimeoutRef.current = undefined + } + + if (isSavingRef.current) { + pendingSaveRef.current = true + return + } + + executeSaveRef.current?.() + }, []) + + return { isLoadingSceneRef, saveNow } } diff --git a/packages/editor/src/hooks/use-save-shortcut.ts b/packages/editor/src/hooks/use-save-shortcut.ts new file mode 100644 index 0000000000..ad738c52ed --- /dev/null +++ b/packages/editor/src/hooks/use-save-shortcut.ts @@ -0,0 +1,33 @@ +'use client' + +import { useEffect, useRef } from 'react' + +/** + * Claims Cmd/Ctrl+S for the app's save. + * + * Capture phase and ungated on purpose: the browser's "Save page" dialog must + * never appear anywhere in the editor — including first-person, studio mode and + * while focus sits in an input, where people still expect the chord to save. + * `e.code` keeps it on the physical S key across keyboard layouts. + */ +export function useSaveShortcut(onSave: () => void) { + const onSaveRef = useRef(onSave) + + useEffect(() => { + onSaveRef.current = onSave + }, [onSave]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return + if (e.code !== 'KeyS') return + + e.preventDefault() + e.stopPropagation() + onSaveRef.current() + } + + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, []) +} diff --git a/packages/editor/src/lib/planar-cursor-placement.test.ts b/packages/editor/src/lib/planar-cursor-placement.test.ts index f49c533985..8089ff77c1 100644 --- a/packages/editor/src/lib/planar-cursor-placement.test.ts +++ b/packages/editor/src/lib/planar-cursor-placement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + offsetPlanPositionByLocalCenter, resolvePlanarCursorPosition, resolvePrioritizedPlanarCursorPosition, } from './planar-cursor-placement' @@ -7,6 +8,83 @@ import { const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5 describe('resolvePlanarCursorPosition', () => { + test('absolute mode puts the unrotated footprint centre at the snapped cursor', () => { + const result = resolvePlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + snap: snapHalf, + }) + + expect(result.point).toEqual([2.7, 2.3]) + expect(result.anchor).toBeNull() + }) + + test('absolute mode snaps the centre before deriving the rotated origin', () => { + const proposals: [number, number][] = [] + const localCenter: [number, number, number] = [1.3, 0.5, 0.2] + const result = resolvePlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter, + rotationY: Math.PI / 2, + snapPoint: (point) => { + proposals.push(point) + return [snapHalf(point[0]), snapHalf(point[1])] + }, + }) + + expect(proposals).toEqual([[4.24, 2.26]]) + expect(result.point[0]).toBeCloseTo(3.8) + expect(result.point[1]).toBeCloseTo(3.8) + const centre = offsetPlanPositionByLocalCenter( + [result.point[0], 0, result.point[1]], + localCenter, + Math.PI / 2, + ) + expect(centre[0]).toBeCloseTo(4) + expect(centre[2]).toBeCloseTo(2.5) + }) + + test('absolute mode follows the unsnapped cursor at an oblique rotation', () => { + const localCenter: [number, number, number] = [1.5, 0.5, -0.3] + const result = resolvePlanarCursorPosition({ + cursor: [-2.13, 6.27], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter, + rotationY: -Math.PI / 4, + }) + const centre = offsetPlanPositionByLocalCenter( + [result.point[0], 0, result.point[1]], + localCenter, + -Math.PI / 4, + ) + + expect(centre[0]).toBeCloseTo(-2.13) + expect(centre[2]).toBeCloseTo(6.27) + }) + + test('relative mode ignores the footprint centre and rotation', () => { + const result = resolvePlanarCursorPosition({ + cursor: [4.9, 5.2], + original: [10, 20], + anchor: [4.1, 6.1], + mode: 'relative', + localCenter: [1.3, 0.5, 0.2], + rotationY: Math.PI / 2, + snap: snapHalf, + }) + + expect(result.point).toEqual([11, 19]) + expect(result.anchor).toEqual([4.1, 6.1]) + }) + test('absolute mode places the point directly at the snapped cursor', () => { const result = resolvePlanarCursorPosition({ cursor: [1.24, -2.26], @@ -103,6 +181,46 @@ describe('resolvePlanarCursorPosition', () => { }) describe('resolvePrioritizedPlanarCursorPosition', () => { + test('attachment receives the corrected raw origin and returns the final origin', () => { + const proposals: [number, number][] = [] + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + rotationY: Math.PI / 2, + snapPoint: () => { + throw new Error('Grid snapping must not run after attachment') + }, + resolveAttachment: (proposal) => { + proposals.push(proposal) + return [3, 5] + }, + }) + + expect(proposals).toHaveLength(1) + expect(proposals[0]![0]).toBeCloseTo(4.04) + expect(proposals[0]![1]).toBeCloseTo(3.56) + expect(result.point).toEqual([3, 5]) + expect(result.attachmentSnapped).toBe(true) + }) + + test('snaps the footprint centre when attachment declines the corrected origin', () => { + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [4.24, 2.26], + original: [0, 0], + anchor: null, + mode: 'absolute', + localCenter: [1.3, 0.5, 0.2], + snapPoint: ([x, z]) => [snapHalf(x), snapHalf(z)], + resolveAttachment: () => null, + }) + + expect(result.point).toEqual([2.7, 2.3]) + expect(result.attachmentSnapped).toBe(false) + }) + test('wall attachment receives the raw proposal and wins over grid snapping', () => { const attachmentProposals: [number, number][] = [] const result = resolvePrioritizedPlanarCursorPosition({ diff --git a/packages/editor/src/lib/planar-cursor-placement.ts b/packages/editor/src/lib/planar-cursor-placement.ts index 2bd61203f4..df1a077aa5 100644 --- a/packages/editor/src/lib/planar-cursor-placement.ts +++ b/packages/editor/src/lib/planar-cursor-placement.ts @@ -7,6 +7,8 @@ type ResolvePlanarCursorPositionArgs = { original: PlanarPoint anchor: PlanarPoint | null mode: PlanarCursorPlacementMode + localCenter?: [number, number, number] + rotationY?: number snap?: (value: number) => number snapPoint?: (point: PlanarPoint) => PlanarPoint } @@ -26,18 +28,42 @@ type ResolvePrioritizedPlanarCursorPositionResult = ResolvePlanarCursorPositionR const identity = (value: number) => value +export function offsetPlanPositionByLocalCenter( + position: [number, number, number], + center: [number, number, number], + rotationY: number, +): [number, number, number] { + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + return [ + position[0] + center[0] * cos + center[2] * sin, + position[1] + center[1], + position[2] - center[0] * sin + center[2] * cos, + ] +} + export function resolvePlanarCursorPosition({ cursor, original, anchor, mode, + localCenter, + rotationY = 0, snap = identity, snapPoint, }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult { if (mode === 'absolute') { const proposal: PlanarPoint = [cursor[0], cursor[1]] + const snapped: PlanarPoint = snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])] + const origin: [number, number, number] = localCenter + ? offsetPlanPositionByLocalCenter( + [snapped[0], 0, snapped[1]], + [-localCenter[0], 0, -localCenter[2]], + rotationY, + ) + : [snapped[0], 0, snapped[1]] return { - point: snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])], + point: [origin[0], origin[2]], anchor, } } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 338d872f07..c64d6ef15c 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -116,6 +116,10 @@ export type FirstPersonMovementMode = 'walk' | 'drone' export const CAPTURE_FOV_MIN = 15 export const CAPTURE_FOV_MAX = 110 +function clampCaptureFov(fov: number): number { + return Math.min(Math.max(Math.round(fov), CAPTURE_FOV_MIN), CAPTURE_FOV_MAX) +} + export type Phase = 'site' | 'structure' | 'furnish' /** @@ -1485,14 +1489,12 @@ const useEditor = create()( captureFovBaseline: null, setCaptureFov: (fov) => set({ - captureFov: Math.min(Math.max(Math.round(fov), CAPTURE_FOV_MIN), CAPTURE_FOV_MAX), + captureFov: clampCaptureFov(fov), }), - armCaptureFov: (fov) => - set( - fov === null - ? { captureFov: null, captureFovBaseline: null } - : { captureFov: fov, captureFovBaseline: fov }, - ), + armCaptureFov: (fov) => { + const captureFov = fov === null ? null : clampCaptureFov(fov) + set({ captureFov, captureFovBaseline: captureFov }) + }, captureShutterHold: false, setCaptureShutterHold: (hold) => set({ captureShutterHold: hold }), workspaceMode: 'edit' as WorkspaceMode, diff --git a/packages/nodes/bunfig.toml b/packages/nodes/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/nodes/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 699131dd7b..d8a633e574 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -30,7 +30,7 @@ "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.185", + "three": "^0.186", "zustand": "^5" }, "devDependencies": { diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 87d420ebfe..7d326ce0b5 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -3453,7 +3453,7 @@ function BlockEditor({ } else if (actions.hasSelection) { actions.beginKeyboardTransformModal('rotate') } - } else if (key === 's') { + } else if (key === 's' && !(event.ctrlKey || event.metaKey)) { if (actions.hasSelection) { if (!actions.beginUniformScaleModal()) { playBlockSfx('tool-select') diff --git a/packages/nodes/src/elevator/panel.tsx b/packages/nodes/src/elevator/panel.tsx index f716e1b3c9..49964283c9 100644 --- a/packages/nodes/src/elevator/panel.tsx +++ b/packages/nodes/src/elevator/panel.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type ElevatorNode, ElevatorNode as ElevatorNodeSchema, + getLevelDisplayName, type LevelNode, requestElevatorLevel, useInteractive, @@ -596,7 +597,7 @@ export default function ElevatorPanel() { > {levels.map((level) => ( ))} @@ -613,7 +614,7 @@ export default function ElevatorPanel() { > {levels.map((level) => ( ))} @@ -631,7 +632,7 @@ export default function ElevatorPanel() { > {defaultLevelOptions.map((level) => ( ))} @@ -810,9 +811,7 @@ export default function ElevatorPanel() { className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2" key={level.id} > - - {level.name || `Level ${level.level}`} - + {getLevelDisplayName(level)}
- {attachedDeck ? ( -
-
- Rise +
+
+ Rise +
+ + handleUpdate( + value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, + ) + } + options={[ + { label: attachedDeck ? 'Follows deck' : 'Follows level', value: 'follows' }, + { label: 'Custom rise', value: 'custom' }, + ]} + value={node.totalRise == null ? 'follows' : 'custom'} + /> + {node.totalRise == null ? ( +
+ Currently {resolvedRise} m
- - handleUpdate( - value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, - ) - } - options={[ - { label: 'Follows deck', value: 'follows' }, - { label: 'Custom rise', value: 'custom' }, - ]} - value={node.totalRise == null ? 'follows' : 'custom'} + ) : ( + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={resolvedRise} /> - {node.totalRise == null ? ( -
- Currently {resolvedRise} m -
- ) : ( - handleUpdate({ totalRise: value })} - precision={2} - step={0.05} - unit="m" - value={resolvedRise} - /> - )} -
- ) : null} + )} +
{attachedDeck ? null : ( <> @@ -461,16 +463,6 @@ export default function StairPanel() { unit="m" value={Math.round((node.width ?? 1) * 100) / 100} /> - handleUpdate({ totalRise: value })} - precision={2} - step={0.05} - unit="m" - value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100} - /> = {}, segmentHeights: number[] = []) { + const segments = segmentHeights.map((height, index) => + StairSegmentNode.parse({ + id: `sseg_${index}`, + type: 'stair-segment', + segmentType: 'stair', + height, + parentId: STAIR_ID, + }), + ) + const stair = StairNode.parse({ + id: STAIR_ID, + type: 'stair', + parentId: LEVEL_ID, + position: [9.5, 0, -3.5], + stairType: 'curved', + width: 0.9, + stepCount: 16, + thickness: 0.16, + fillToFloor: false, + totalRise: 3.81, + children: segments.map((segment) => segment.id), + ...overrides, + }) + const level = LevelNode.parse({ + id: LEVEL_ID, + type: 'level', + level: 0, + height: 3.5, + children: [STAIR_ID], + }) + const nodes: Record = { [level.id]: level, [stair.id]: stair } + for (const segment of segments) nodes[segment.id] = segment + return { nodes, stair } +} + +describe('getStairTypeChange', () => { + it('materializes a flight when a segment-less stair becomes straight', () => { + const { nodes, stair } = buildScene() + const change = getStairTypeChange(stair, 'straight', nodes) + + expect(change.updates.stairType).toBe('straight') + expect(change.segment?.type).toBe('stair-segment') + expect(change.segment?.segmentType).toBe('stair') + expect(change.segment?.width).toBe(0.9) + expect(change.segment?.stepCount).toBe(16) + expect(change.segment?.thickness).toBe(0.16) + expect(change.segment?.fillToFloor).toBe(false) + // Rise from the stair, run from the `StairSegmentNode` schema default. + expect(change.segment?.height).toBe(3.81) + expect(change.segment?.length).toBe(3) + }) + + it('follows the storey height when the stair has no explicit rise', () => { + const { nodes, stair } = buildScene({ totalRise: undefined }) + const change = getStairTypeChange(stair, 'straight', nodes) + + expect(change.segment?.height).toBe(3.5) + }) + + it('leaves an existing flight alone', () => { + const { nodes, stair } = buildScene({ stairType: 'curved' }, [2.5]) + const change = getStairTypeChange(stair, 'straight', nodes) + + expect(change.updates.stairType).toBe('straight') + expect(change.segment).toBeNull() + }) + + it('keeps the segments when a straight stair becomes curved', () => { + const { nodes, stair } = buildScene({ stairType: 'straight' }, [2.5]) + const change = getStairTypeChange(stair, 'curved', nodes) + + expect(change.updates).toEqual({ stairType: 'curved' }) + expect(change.segment).toBeNull() + expect(nodes[STAIR_ID]).toBe(stair) + }) + + it('seeds the sweep and drops the Y offset when switching to spiral', () => { + const { nodes, stair } = buildScene() + const change = getStairTypeChange(stair, 'spiral', nodes) + + expect(change.updates.stairType).toBe('spiral') + expect(change.updates.sweepAngle).toBeCloseTo((400 * Math.PI) / 180, 10) + expect(change.updates.position).toEqual([9.5, 0, -3.5]) + expect(change.segment).toBeNull() + }) +}) diff --git a/packages/nodes/src/stair/stair-type.ts b/packages/nodes/src/stair/stair-type.ts new file mode 100644 index 0000000000..718fd8a815 --- /dev/null +++ b/packages/nodes/src/stair/stair-type.ts @@ -0,0 +1,47 @@ +import { + type AnyNode, + type AnyNodeId, + createStairFlightFromStair, + type StairNode, + type StairSegmentNode, + type StairType, +} from '@pascal-app/core' +import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '@pascal-app/editor' + +export type StairTypeChange = { + updates: Partial + /** A flight to create under the stair, or null when the stair already has segments. */ + segment: StairSegmentNode | null +} + +/** + * Computes the stair patch for a type switch. + * + * Straight stairs are drawn from their `stair-segment` children while curved + * and spiral stairs are drawn parametrically from the stair's own fields, so a + * stair that reaches `straight` without segments has nothing to draw at all. + * Switching to straight therefore materializes the flight the stair already + * describes. Switching away keeps the segments — they simply go unused until + * the stair comes back, which makes the round trip lossless. + */ +export function getStairTypeChange( + stair: StairNode, + nextType: StairType, + nodes: Record, +): StairTypeChange { + const updates: Partial = + nextType === 'spiral' && stair.stairType !== 'spiral' + ? { + stairType: nextType, + sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, + position: [stair.position[0], 0, stair.position[2]], + } + : { stairType: nextType } + + if (nextType !== 'straight') return { updates, segment: null } + + const hasSegment = (stair.children ?? []).some( + (childId) => nodes[childId as AnyNodeId]?.type === 'stair-segment', + ) + return { updates, segment: hasSegment ? null : createStairFlightFromStair(stair, nodes) } +} diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index e79a4da50d..6c04844856 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -139,7 +139,7 @@ test('curved wall roof builder creates a matching conical sector above it', () = const segment = created.find((entry) => entry.node.type === 'roof-segment')?.node expect(wallDefinition.quickActions).toBeUndefined() - expect(roof).toMatchObject({ position: [0, 3, 0] }) + expect(roof).toMatchObject({ position: [0, 3, 0], support: { kind: 'walls' } }) expect(segment).toMatchObject({ roofType: 'conical', width: 4, @@ -190,7 +190,7 @@ test('curved wall roof builder parents the roof to the active level', () => { const createdRoof = created.find((entry) => entry.node.type === 'roof') expect(createdRoof?.parentId).toBe(activeLevel.id) - expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0] }) + expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0], support: { kind: 'walls' } }) }) test('curved wall roof builder reuses its existing hosted roof', () => { @@ -242,7 +242,7 @@ test('curved wall roof builder reuses its existing hosted roof', () => { expect(created).toHaveLength(0) }) -test('curved wall roof builder clamps a lower-floor wall to the active floor', () => { +test('curved wall roof builder follows a lower-floor wall top below the active floor', () => { const sourceLevel = { object: 'node', id: 'level_source', @@ -280,7 +280,7 @@ test('curved wall roof builder clamps a lower-floor wall to the active floor', ( createConicalRoofSectorAboveWall(wall, nodes, sceneApi, activeLevel.id as AnyNodeId) const createdRoof = created.find((entry) => entry.node.type === 'roof') - expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0] }) + expect(createdRoof?.node).toMatchObject({ position: [0, -2, 0] }) }) test('curved wall roof builder rejects walls more than one level below', () => { diff --git a/packages/viewer/bunfig.toml b/packages/viewer/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/viewer/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 4097c35b9a..0c135fd6c5 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -28,7 +28,7 @@ "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.185" + "three": "^0.186" }, "dependencies": { "three-bvh-csg": "^0.0.18", diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6dd57a2723..f6b3489384 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, nodeRegistry, + RoofElevationSystem, StairOpeningSystem, sceneRegistry, useScene, @@ -625,6 +626,7 @@ const Viewer = forwardRef(function Viewer( {/* Automated stair opening sync — updates slab/ceiling cutouts whenever stairs, slabs, or levels change. */} + {/* Mounts systems contributed by registry-backed kinds. Each kind's `def.system` is loaded via lazy() and rendered here, ordered by `system.priority`. */} diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index a528fda857..0bf90e6967 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -152,7 +152,9 @@ export async function initializeGpuRenderer { + test('keeps a zero-height gable wall shell exactly on its base', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0, + wallThickness: 0.1, + pitch: 40, + }) + const brushes = getRoofSegmentBrushes(segment) + expect(brushes).not.toBeNull() + if (!brushes) return + + const shell = new Evaluator().evaluate(brushes.wallBrush, brushes.innerBrush, SUBTRACTION) + try { + brushes.wallBrush.geometry.computeBoundingBox() + expect(brushes.wallBrush.geometry.boundingBox!.min.y).toBe(0) + expect(shell.geometry.getAttribute('position').count).toBeGreaterThan(0) + shell.geometry.computeBoundingBox() + expect(shell.geometry.boundingBox!.min.y).toBeCloseTo(0, 12) + } finally { + shell.geometry.dispose() + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.rakeBoards?.dispose() + } + }) + + test('lifts the inner cutter with the shell so a flat zero-height roof stays hollow', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + wallHeight: 0, + wallThickness: 0.1, + pitch: 0, + }) + const brushes = getRoofSegmentBrushes(segment) + expect(brushes).not.toBeNull() + if (!brushes) return + try { + brushes.wallBrush.geometry.computeBoundingBox() + brushes.innerBrush.geometry.computeBoundingBox() + expect(brushes.wallBrush.geometry.boundingBox!.min.y).toBe(0) + expect(brushes.wallBrush.geometry.boundingBox!.max.y).toBeCloseTo(0.05, 6) + expect(brushes.innerBrush.geometry.boundingBox!.max.y).toBeCloseTo( + brushes.wallBrush.geometry.boundingBox!.max.y, + 6, + ) + } finally { + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.rakeBoards?.dispose() + } + }) +}) describe('roof system shed geometry', () => { function inspectShedGeometry(segment: RoofSegmentNode) { @@ -179,9 +242,11 @@ describe('roof system shed geometry', () => { shedInsetEndPanels: true, wallShell: 'omit', }) - const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) + const { geometry, roofSideX, sideInfillNormals, sideInfillX, wallVertexYs } = + inspectShedGeometry(segment) expect(sideInfillNormals).toHaveLength(2) + expect(Math.min(...wallVertexYs)).toBeCloseTo(0.05, 5) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeCloseTo(infillHalfWidth, 5) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeGreaterThan(span / 2) expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeLessThan(span / 2 + leftOverhang) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 3c4b966c0a..e2d651e662 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1380,7 +1380,10 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe const dV = Math.max(0.01, depth + 2 * wExt) const autoDrop = wExt * tanTheta - const whV = Math.max(0.01, wallHeight - autoDrop + vOffset) + // Floor every prism at 5 cm so CSG never sees a degenerate volume — by + // raising the top, never by sinking the base (the base is the wall top). + // One floor for all volumes keeps each cutter level with the shell it carves. + const whV = Math.max(0.05, wallHeight - autoDrop + vOffset) let rhV = activeRh if (activeRh > 0) { @@ -1388,8 +1391,6 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe if (roofType === 'shed') rhV = activeRh + 2 * autoDrop } - const safeBaseY = Math.min(baseY, whV - 0.05) - let structuralI = baseI if (isVoid) { structuralI += deckThickness @@ -1401,7 +1402,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe d: dV, wh: whV, rh: rhV, - baseY: safeBaseY, + baseY, insets: { dutchI: structuralI }, baseW: width, baseD: depth, @@ -3589,7 +3590,7 @@ function createShedInsetEndPanelGeometry(node: RoofSegmentNode): THREE.BufferGeo }) const wallOuterOffset = node.wallThickness / 2 const autoDrop = wallOuterOffset * tanTheta - const wh = Math.max(0.01, node.wallHeight - autoDrop) + const wh = Math.max(0.05, node.wallHeight - autoDrop) const rh = activeRh > 0 ? activeRh + 2 * autoDrop : activeRh const faces = getRoofModuleFaces({ diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index e63b75fe13..ef3ab9abba 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + createStairFlightFromStair, getEffectiveNode, getFloorStackedPosition, type StairNode, @@ -319,18 +320,20 @@ function updateMergedStairGeometry( .filter((n): n is StairSegmentNode => n?.type === 'stair-segment') .map((n) => getEffectiveNode(n)) - if (segments.length === 0) { - replaceMeshGeometry(mergedMesh, createEmptyGeometry()) - return - } + // A straight stair with no segments has nothing to merge and would render as + // nothing at all — the state a stair authored as curved lands in the moment + // it is switched to straight. Draw the flight its own fields describe + // instead of vanishing; it is the same flight the panel materializes. + const bodySegments = + segments.length > 0 ? segments : [createStairFlightFromStair(stairNode, nodes)] // Compute chained transforms for segments - const transforms = computeSegmentTransforms(segments) + const transforms = computeSegmentTransforms(bodySegments) const geometries: THREE.BufferGeometry[] = [] - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]! + for (let i = 0; i < bodySegments.length; i++) { + const segment = bodySegments[i]! const transform = transforms[i]! const absoluteHeight = transform.position[1] diff --git a/scripts/bun-preload-three.ts b/scripts/bun-preload-three.ts new file mode 100644 index 0000000000..25f5243f7d --- /dev/null +++ b/scripts/bun-preload-three.ts @@ -0,0 +1,17 @@ +import { resolveSync } from 'bun' + +// three r186's CommonJS entry is `require('./three.module.js')`. Bun cannot +// require() an ES module that is still loading, and the R3F ecosystem (fiber, +// drei, maath, meshline, troika) ships CJS mains that require("three") while +// our sources import it as ESM — so a test file that imports both races and +// dies with "require() async module is unsupported". Evaluating three first +// turns the later require() into a cache hit. Resolve from the package under +// test, not from this file: with the isolated linker each package has its own +// link and this directory would walk up to a different copy. Packages that do +// not depend on three have nothing to pre-evaluate. +process.noDeprecation = true +let threePath: string | null = null +try { + threePath = resolveSync('three', process.cwd()) +} catch {} +if (threePath) await import(threePath) diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 521441d61d..bb475da808 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -2,7 +2,7 @@ *How buildings stack: stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, and the clamp rules that keep it all coherent.* -Applies to: anything that reads or writes vertical geometry — levels, walls, slabs, ceilings, stairs, fences, floor-placed items. +Applies to: anything that reads or writes vertical geometry — levels, walls, slabs, ceilings, roofs, stairs, fences, floor-placed items. The invariant, in one sentence: @@ -21,6 +21,7 @@ The invariant, in one sentence: | `level.baseElevation` | Additive offset from the computed stack position. It shifts this level and cumulatively shifts every higher level in the same building; negative offsets are valid. | Zero (the schema default). | | `wall.height` | Explicit body height (half wall, parapet, or a raised-support draft whose ghost height must remain invariant). Ground-hosted walls always resolve top = elected base + height, including below datum; other legacy sunken supports retain their absolute-top constraint. | **Plane-bound** (the default for ordinary datum placement): the top follows `getWallPlaneTop` — `min(level height, lowest covering-slab underside over the span)`. | | `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) − 0.01`. | +| `roof.support.kind` | `walls` follows the highest spatially matched wall top in the roof’s level frame; `level` keeps custom Y; `roof` retains its roof-surface attachment rule. Room and curved-wall creation write `walls`; free-drawn rectangles write `level` at Y 0. | Existing roofs remain custom (`level`); no load migration enables following. | | `slab.elevation` | The walking surface (top), level-local. | Default 0.05. | | `slab.thickness` | Grows **downward**: the solid occupies `[elevation − thickness, elevation]`. | Default 0.05. | | `slab.recessed` | Recess intent: open shell whose floor is `elevation` and whose rim is `recessedRimElevation`. Excluded from "covering" queries and wall-face adoption. | Solid slab. | @@ -31,7 +32,7 @@ The invariant, in one sentence: | `fence.supportOffset` | Optional level-local delta from the fence's slab host or level plane. It translates the complete fence while preserving height. | Zero offset: the fence sits directly on its host or level plane. | | `wall.fillToTerrain` | Extends the wall downward from its authored base to the terrain with independently sampled left/right faces. The wall body height and top stay unchanged. | Fixed base with no terrain infill. | | `stair.deckSlabId` | Destination deck: rise follows `deck.elevation − the stair's own elected base` live; cutout sync disabled while attached. | Destination is a level. | -| `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: derived from the deck or the containing level; `syncStairRises` converges straight-stair segments to the resolved rise. | +| `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: the deck's `elevation`, else the containing level's floor-to-floor height — each **minus the stair's own elected base**, so a slab under the stair shortens the rise the way it shortens a plane-bound wall; `syncStairRises` converges straight-stair segments to the resolved rise. | Two schema rules protect these semantics: @@ -49,6 +50,7 @@ Two schema rules protect these semantics: | `getCeilingClampBound`, `getCoveringSlabUndersideAt` | `services/storey.ts` | Ceiling bound; the cross-level covering query (level above, non-recessed slabs) | | `resolveCeilingHeight` | `services/level-height.ts` | A ceiling's effective height (explicit or follows) | | `resolveStairTotalRise`, `syncStairRises` | `systems/stair/stair-rise.ts` | Stair rise precedence + straight-flight convergence | +| `resolveRoofElevation`, `resolveRoofWallTopElevation` | `systems/roof/roof-elevation.ts` | Highest spatially matched wall top for `walls` support, including explicit heights and elected bases, converted to the roof's level frame | | `computeWallSlabSupport`, `getSlabSupportForItem`, `getSupportCandidatesForFootprint` | `systems/slab/slab-support.ts` + spatial-grid manager | Support election (rendered polygons, host-preferring, optional `maxElevation` cap) | | `resolveSlabPlacementElevation` | `systems/slab/slab-placement.ts` | Translates a solid slab's authored top/thickness interval onto a captured base plane; recessed slabs stay level-relative | | `getSlabBaseElevation`, `applySlabBaseElevationChange`, `applySlabThicknessChange` | `nodes/slab/elevation-limit.ts` | Separates whole-body underside placement from fixed-base thickness editing | @@ -134,6 +136,7 @@ Because community autosave only persists after the first post-load edit, the mig ## Gotchas +- **Only `support.kind: 'walls'` roofs follow walls.** The resolver projects the roof’s XZ centre onto its parent level’s lower neighbour, selected by `findLevelBelowId` from `getLevelElevations` in the same building stack (ordinals need not be consecutive). It uses the smallest enclosing room at that point and takes the highest resolved wall top without clamping to the storey. Conical roofs match curved walls by arc centre and radius against their transformed segment footprint, without a wall-ID binding. No matching enclosure or arc freezes Y and preserves follow intent, so redrawing walls resumes following. Negative level-local Y is valid: 2.5 m walls under a 3 m storey put the roof at −0.5 m. `RoofElevationSystem` re-derives Y only for following roofs on wall, slab, level, building, site, and roof edits, history-paused and one microtask after store updates so the spatial grid has settled. Settled updates publish a separate scene commit without adding an undo step; an outer gesture’s history pause retains commit ownership. The panel’s “Follows walls” choice enables this rule; “Custom” writes `level` and keeps Y. An explicit Y change exceeding 1e-4 m in the panel or 3D move handle switches to `level` in the same patch; XZ-only moves retain `walls` and re-resolve spatially. Undo/redo restores mode and Y together. Roof-surface attachments hide this mode control and retain their own rule. Schema version stays 3 and load never opts existing roofs into following. - **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. - **Boundary geometry.** Auto slabs derive polygons from wall centerlines, so wall/ceiling clamp samples sit exactly on polygon edges — always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. - **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled).