diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index a3ffb678fc..f3f3fd67f3 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -352,36 +352,28 @@ export class SpatialGridManager { return this.wallGrids.get(levelId)! } + private getWall(wallId: string): WallNode | undefined { + const fromScene = useScene.getState().nodes[wallId as AnyNodeId] + if (fromScene && fromScene.type === 'wall') { + this.walls.set(wallId, fromScene as WallNode) + return fromScene as WallNode + } + return this.walls.get(wallId) + } + private getWallLength(wallId: string): number { - const wall = this.walls.get(wallId) + const wall = this.getWall(wallId) if (!wall) return 0 const dx = wall.end[0] - wall.start[0] const dy = wall.end[1] - wall.start[1] - return Math.sqrt(dx * dx + dy * dy) + return Math.hypot(dx, dy) } - private getWallHeight(wallId: string): number { - const wall = this.walls.get(wallId) + private getWallHeight(wallId: string, t?: number): number { + const wall = this.getWall(wallId) if (!wall) return 0 - if (wall.height != null) return wall.height - - const nodes = useScene.getState().nodes - const levelId = resolveNodeLevelId(wall, nodes) - const support = this.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId ?? null, - undefined, - wall.supportOffset, - ) - return resolveWallEffectiveHeight( - wall, - getWallPlaneTop(wall, levelId, nodes), - support.elevation, - ) + const nodes = useScene.getState().nodes as Record + return getWallEffectiveHeightForNodes(wall, nodes, t) } private getCeilingGrid(ceilingId: string): SpatialGrid { @@ -772,10 +764,17 @@ export class SpatialGridManager { if (wallLength === 0) { return { valid: false, conflictIds: [] } } - const wallHeight = this.getWallHeight(wallId) + const [itemWidth, itemHeight] = dimensions // Convert local X position to parametric t (0-1) const tCenter = localX / wallLength - const [itemWidth, itemHeight] = dimensions + const halfW = itemWidth / wallLength / 2 + const tStart = Math.max(0, Math.min(1, tCenter - halfW)) + const tEnd = Math.max(0, Math.min(1, tCenter + halfW)) + const hStart = this.getWallHeight(wallId, tStart) + const hEnd = this.getWallHeight(wallId, tEnd) + const hCenter = this.getWallHeight(wallId, tCenter) + const wallHeight = Math.min(hStart, hEnd, hCenter) + const baseResult = this.getWallGrid(levelId).canPlaceOnWall( wallId, wallLength, @@ -1312,8 +1311,9 @@ export function getWallBaseElevationForNodes( export function getWallEffectiveHeightForNodes( wall: WallNode, nodes: Record, + t = 0, ): number { const levelId = resolveNodeLevelId(wall, nodes) const baseElevation = getWallBaseElevationForNodes(wall, nodes) - return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation, t) } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d05d0d68e4..009d2a1132 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -201,11 +201,15 @@ export function initSpatialGridSync(): () => void { node.start !== prev.start || node.end !== prev.end || node.curveOffset !== prev.curveOffset || - node.thickness !== prev.thickness + node.thickness !== prev.thickness || + node.height !== prev.height || + node.endHeightOffset !== prev.endHeightOffset || + node.supportSlabId !== prev.supportSlabId || + node.supportOffset !== prev.supportOffset ) { - // Rendered slab polygons adopt wall bands, so a wall reshape - // must reach the manager to refresh its wall map and drop the - // level's rendered-polygon cache. + // Rendered slab polygons adopt wall bands, and wall height/slope + // queries must see the latest node state — reach the manager to + // refresh its wall map and drop the level's rendered-polygon cache. spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) } } diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts index 0e3580c7ad..94384ad86b 100644 --- a/packages/core/src/hooks/spatial-grid/support-host.test.ts +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -620,8 +620,8 @@ describe('persisted support hosts (walls, via the manager)', () => { // Wall-top inversion: no stored height → the top stays at the storey // plane, so the extruded body is the plane minus the deck base. const storeyHeight = 2.7 - expect(resolveWallTop(wall, storeyHeight, support.elevation)).toBeCloseTo(storeyHeight) - expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation)).toBeCloseTo( + expect(resolveWallTop(wall, storeyHeight, support.elevation, 0)).toBeCloseTo(storeyHeight) + expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation, 0)).toBeCloseTo( storeyHeight - DECK_ELEVATION, ) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5d2720fa12..a8dd3b8f7e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -442,6 +442,8 @@ export { type WallPlanPoint, } from './systems/wall/wall-move' export { + clampWallEndHeightOffset, + MIN_WALL_END_HEIGHT, MIN_WALL_HEIGHT, resolveWallEffectiveHeight, resolveWallTop, diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 3594b0c6cc..01bf7ebfd2 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -445,9 +445,10 @@ function autoRoomVerticalPlacements( const base = roomFloorPlane(wallBases) if (base === undefined) continue - const wallTops = boundaryWalls.map((wall, index) => - resolveWallTop(wall, storeyHeight, wallBases[index] ?? base), - ) + const wallTops = boundaryWalls.flatMap((wall, index) => { + const b = wallBases[index] ?? base + return [resolveWallTop(wall, storeyHeight, b, 0), resolveWallTop(wall, storeyHeight, b, 1)] + }) const top = roomCeilingPlane(wallTops) if (top === undefined) continue @@ -1207,6 +1208,7 @@ function wallGeometrySignature(wall: WallNode, nodes: Record, level // value: it resolves to the storey plane, so it must not alias an // explicit height of the same magnitude in the trigger signature. wall.height == null ? 'plane' : wall.height.toFixed(4), + (wall.endHeightOffset ?? 0).toFixed(4), wall.supportSlabId ?? 'elected', (wall.supportOffset ?? 0).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index b1830696d3..b1a4e4fdb6 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -482,7 +482,7 @@ export function deriveZoneQuantityReport( const wallEffectiveHeight = (wall: WallNode) => { const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT - return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation, 0.5) } const edgeLengths = zone.polygon.map((start, index) => { const end = zone.polygon[(index + 1) % zone.polygon.length] diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index c04406f7fe..55245129de 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -158,6 +158,11 @@ export const WallNode = BaseNode.extend({ slots: z.record(z.string(), z.string()).optional(), thickness: z.number().optional(), height: z.number().optional(), + // Added to the wall's top only at its `end` point (`start` is unaffected), + // tilting the top edge along the wall's length so one side is taller than + // the other — e.g. a knee wall following a single-pitch roof slope. + /** Height offset at the end point (default 0). */ + endHeightOffset: z.number().optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), @@ -182,6 +187,7 @@ export const WallNode = BaseNode.extend({ Wall node - used to represent a wall in the building - thickness: thickness in meters - height: height in meters + - endHeightOffset: added to the top only at the wall's end point, tilting the top edge so one side is taller than the other - fillToTerrain: extends the wall downward to the terrain without changing its authored height - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system @@ -216,10 +222,11 @@ export const WALL_SLOT_DEFAULT: Record = { } export function getWallFaceBandConfig( - wall: Pick, + wall: Pick, effectiveWallHeight: number, ) { - const wallHeight = Math.max(0, effectiveWallHeight) + const maxWallHeight = effectiveWallHeight + Math.max(0, wall.endHeightOffset ?? 0) + const wallHeight = Math.max(0, maxWallHeight) const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1 const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0 @@ -241,7 +248,7 @@ export function getWallFaceBandConfig( } export function getWallFaceBandForHeight( - wall: Pick, + wall: Pick, y: number, effectiveWallHeight: number, ): WallFaceBand { diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 871303c41b..52581029d7 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -62,7 +62,14 @@ export function deriveLegacyLevelHeight( slabs, walls, ).elevation - const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) + const topStart = resolveWallTop( + wall, + level.height ?? DEFAULT_LEVEL_HEIGHT, + electedElevation, + 0, + ) + const topEnd = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 1) + const top = Math.max(topStart, topEnd) if (top > maxTop) maxTop = top } } diff --git a/packages/core/src/systems/wall/wall-top.test.ts b/packages/core/src/systems/wall/wall-top.test.ts index a743dcad21..d375c77b18 100644 --- a/packages/core/src/systems/wall/wall-top.test.ts +++ b/packages/core/src/systems/wall/wall-top.test.ts @@ -1,52 +1,154 @@ import { describe, expect, test } from 'bun:test' -import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top' +import { + clampWallEndHeightOffset, + MIN_WALL_END_HEIGHT, + resolveWallEffectiveHeight, + resolveWallTop, +} from './wall-top' + +describe('clampWallEndHeightOffset', () => { + test('returns 0 when offset is undefined or 0', () => { + expect(clampWallEndHeightOffset(undefined, 3)).toBe(0) + expect(clampWallEndHeightOffset(0, 3)).toBe(0) + }) + + test('preserves positive offsets without modification', () => { + expect(clampWallEndHeightOffset(1.5, 3)).toBe(1.5) + expect(clampWallEndHeightOffset(10, 3)).toBe(10) + }) + + test('allows safe negative offsets that leave at least minEndHeight', () => { + expect(clampWallEndHeightOffset(-1.5, 3)).toBe(-1.5) + expect(clampWallEndHeightOffset(-2.99, 3)).toBeCloseTo(-2.99) + }) + + test('clamps steep negative offsets to maintain minEndHeight (0.01m)', () => { + // Body height 3m -> minimum end height 0.01m -> max negative offset -2.99m + expect(clampWallEndHeightOffset(-5, 3)).toBeCloseTo(-2.99) + expect(clampWallEndHeightOffset(-100, 2.5)).toBeCloseTo(-2.49) + }) + + test('clamps negative offsets when bodyHeight is already at or below minEndHeight', () => { + expect(clampWallEndHeightOffset(-1, 0.01)).toBe(0) + expect(clampWallEndHeightOffset(-1, 0.005)).toBe(0) + }) + + test('respects custom minEndHeight argument', () => { + expect(clampWallEndHeightOffset(-2.5, 3, 1.0)).toBe(-2.0) + }) +}) describe('resolveWallTop', () => { test('explicit height on zero base keeps the stored top', () => { - expect(resolveWallTop({ height: 2.5 }, 3, 0)).toBe(2.5) + expect(resolveWallTop({ height: 2.5 }, 3, 0, 0)).toBe(2.5) }) test('explicit height on raised base rides the base', () => { - expect(resolveWallTop({ height: 2.5 }, 3, 0.6)).toBeCloseTo(3.1) + expect(resolveWallTop({ height: 2.5 }, 3, 0.6, 0)).toBeCloseTo(3.1) }) test('explicit height on sunken base keeps the absolute top', () => { - expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5) + expect(resolveWallTop({ height: 2.5 }, 3, -0.4, 0)).toBe(2.5) }) test('ground-hosted explicit height remains body-relative in a terrain depression', () => { - expect(resolveWallTop({ height: 2.5, supportSlabId: 'ground' }, 3, -0.4)).toBeCloseTo(2.1) + expect(resolveWallTop({ height: 2.5, supportSlabId: 'ground' }, 3, -0.4, 0)).toBeCloseTo(2.1) expect( - resolveWallEffectiveHeight({ height: 2.5, supportSlabId: 'ground' }, 3, -0.4), + resolveWallEffectiveHeight({ height: 2.5, supportSlabId: 'ground' }, 3, -0.4, 0), ).toBeCloseTo(2.5) }) test('plane-bound wall tops out at the storey plane regardless of base', () => { - expect(resolveWallTop({}, 3, 0)).toBe(3) - expect(resolveWallTop({}, 3, 0.6)).toBe(3) - expect(resolveWallTop({}, 3, -0.4)).toBe(3) + expect(resolveWallTop({}, 3, 0, 0)).toBe(3) + expect(resolveWallTop({}, 3, 0.6, 0)).toBe(3) + expect(resolveWallTop({}, 3, -0.4, 0)).toBe(3) + }) + + test('positive endHeightOffset slopes the top upwards linearly from start to end', () => { + const wall = { height: 2.5, endHeightOffset: 1.0 } + expect(resolveWallTop(wall, 3, 0, 0)).toBe(2.5) + expect(resolveWallTop(wall, 3, 0, 0.5)).toBeCloseTo(3.0) + expect(resolveWallTop(wall, 3, 0, 1)).toBeCloseTo(3.5) + }) + + test('negative endHeightOffset slopes the top downwards linearly', () => { + const wall = { height: 3.0, endHeightOffset: -1.0 } + expect(resolveWallTop(wall, 3, 0, 0)).toBe(3.0) + expect(resolveWallTop(wall, 3, 0, 0.5)).toBeCloseTo(2.5) + expect(resolveWallTop(wall, 3, 0, 1)).toBeCloseTo(2.0) + }) + + test('excessive negative endHeightOffset clamps so top at end stays at base + MIN_WALL_END_HEIGHT', () => { + const wall = { height: 2.5, endHeightOffset: -5.0 } + expect(resolveWallTop(wall, 3, 0, 0)).toBe(2.5) + // Clamped offset = -2.49 -> top at t=1 is 2.5 - 2.49 = 0.01 (MIN_WALL_END_HEIGHT) + expect(resolveWallTop(wall, 3, 0, 1)).toBeCloseTo(MIN_WALL_END_HEIGHT) + }) + + test('plane-bound sloped wall on raised base computes body relative to electedBase', () => { + // storeyHeight = 3, base = 0.6 -> unsloped top = 3, bodyHeight = 2.4 + const wall = { endHeightOffset: 1.0 } + expect(resolveWallTop(wall, 3, 0.6, 0)).toBe(3.0) + expect(resolveWallTop(wall, 3, 0.6, 0.5)).toBeCloseTo(3.5) + expect(resolveWallTop(wall, 3, 0.6, 1)).toBeCloseTo(4.0) + }) + + test('explicit sloped wall on raised base rides the base across t', () => { + // height = 2.0, base = 0.5 -> unsloped top = 2.5, bodyHeight = 2.0 + const wall = { height: 2.0, endHeightOffset: 1.0 } + expect(resolveWallTop(wall, 3, 0.5, 0)).toBeCloseTo(2.5) + expect(resolveWallTop(wall, 3, 0.5, 1)).toBeCloseTo(3.5) + }) + + test('ground-hosted sloped wall in depression applies slope relative to base top', () => { + // height = 2.5, ground base = -0.5 -> unsloped top = 2.0, bodyHeight = 2.5 + const wall = { height: 2.5, supportSlabId: 'ground', endHeightOffset: 1.0 } + expect(resolveWallTop(wall, 3, -0.5, 0)).toBeCloseTo(2.0) + expect(resolveWallTop(wall, 3, -0.5, 0.5)).toBeCloseTo(2.5) + expect(resolveWallTop(wall, 3, -0.5, 1)).toBeCloseTo(3.0) }) }) describe('resolveWallEffectiveHeight', () => { test('explicit on raised base extrudes the stored height', () => { - expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6)).toBeCloseTo(2.5) + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6, 0)).toBeCloseTo(2.5) }) test('explicit on zero base extrudes the stored height', () => { - expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0)).toBe(2.5) + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0, 0)).toBe(2.5) }) test('plane-bound on raised base gets shorter, never taller', () => { - expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeCloseTo(2.4) - expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeLessThan(3) + expect(resolveWallEffectiveHeight({}, 3, 0.6, 0)).toBeCloseTo(2.4) + expect(resolveWallEffectiveHeight({}, 3, 0.6, 0)).toBeLessThan(3) }) test('plane-bound on zero base spans the full storey', () => { - expect(resolveWallEffectiveHeight({}, 3, 0)).toBe(3) + expect(resolveWallEffectiveHeight({}, 3, 0, 0)).toBe(3) }) test('plane-bound on sunken base fills down while the top stays at the plane', () => { - expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4) + expect(resolveWallEffectiveHeight({}, 3, -0.4, 0)).toBeCloseTo(3.4) + }) + + test('sloped explicit wall computes effective height at any parametric t', () => { + const wall = { height: 2.5, endHeightOffset: 0.8 } + expect(resolveWallEffectiveHeight(wall, 3, 0.6, 0)).toBeCloseTo(2.5) + expect(resolveWallEffectiveHeight(wall, 3, 0.6, 0.5)).toBeCloseTo(2.9) + expect(resolveWallEffectiveHeight(wall, 3, 0.6, 1)).toBeCloseTo(3.3) + }) + + test('sloped plane-bound wall computes effective height with positive tilt', () => { + const wall = { endHeightOffset: 1.2 } + expect(resolveWallEffectiveHeight(wall, 3, 0, 0)).toBeCloseTo(3.0) + expect(resolveWallEffectiveHeight(wall, 3, 0, 0.5)).toBeCloseTo(3.6) + expect(resolveWallEffectiveHeight(wall, 3, 0, 1)).toBeCloseTo(4.2) + }) + + test('sloped plane-bound wall computes effective height with clamped negative tilt', () => { + const wall = { endHeightOffset: -4.0 } + expect(resolveWallEffectiveHeight(wall, 3, 0, 0)).toBeCloseTo(3.0) + // Clamped offset = -2.99 -> effective height at t=1 is 0.01 (MIN_WALL_END_HEIGHT) + expect(resolveWallEffectiveHeight(wall, 3, 0, 1)).toBeCloseTo(MIN_WALL_END_HEIGHT) }) }) diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 2bbf475da7..af421edb31 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -8,6 +8,22 @@ import type { WallNode } from '../../schema/nodes/wall' * would collapse below this minimum. */ export const MIN_WALL_HEIGHT = 0.5 +export const MIN_WALL_END_HEIGHT = 0.01 + +/** + * Clamps a wall's end-height offset so the lower end of the wall never + * collapses below `minEndHeight` (0.01 m by default). + */ +export function clampWallEndHeightOffset( + endHeightOffset: number | undefined, + bodyHeight: number, + minEndHeight = MIN_WALL_END_HEIGHT, +): number { + if (!endHeightOffset) return 0 + const maxNegativeOffset = -(Math.max(minEndHeight, bodyHeight) - minEndHeight) + const clamped = Math.max(endHeightOffset, maxNegativeOffset) + return clamped === 0 ? 0 : clamped +} /** * Wall-top inversion (vertical building model): a wall with no stored @@ -21,16 +37,29 @@ export const MIN_WALL_HEIGHT = 0.5 * ground-hosted walls are the terrain exception: `height` is always body * height, including below datum, so sculpting cannot stretch the wall. * - * Returns the top in level-local Y (same frame as `electedBase`). + * Returns the top in level-local Y (same frame as `electedBase`) at normalized position + * `t` along the wall ($t = 0$ is start, $t = 1$ is end). */ export function resolveWallTop( - wall: Pick, + wall: Pick, storeyHeight: number, electedBase: number, + t: number, ): number { - if (wall.height == null) return storeyHeight - if (wall.supportSlabId === 'ground') return electedBase + wall.height - return electedBase > 0 ? electedBase + wall.height : wall.height + let top: number + if (wall.height == null) { + top = storeyHeight + } else if (wall.supportSlabId === 'ground') { + top = electedBase + wall.height + } else { + top = electedBase > 0 ? electedBase + wall.height : wall.height + } + if (wall.endHeightOffset) { + const bodyHeight = top - electedBase + const clampedOffset = clampWallEndHeightOffset(wall.endHeightOffset, bodyHeight) + top += clampedOffset * t + } + return top } /** @@ -48,9 +77,10 @@ export function resolveWallTop( * policy. */ export function resolveWallEffectiveHeight( - wall: Pick, + wall: Pick, storeyHeight: number, electedBase: number, + t: number, ): number { - return resolveWallTop(wall, storeyHeight, electedBase) - electedBase + return resolveWallTop(wall, storeyHeight, electedBase, t) - electedBase } diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index fb84eb4e0e..6c906a70b0 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -315,16 +315,25 @@ function buildMeasurementGuide( const measurementPoints = measurementLine ?? fallbackMiddlePoints if (!measurementPoints) return null - const height = getWallEffectiveHeightForNodes(wall, nodes) + const heightStart = getWallEffectiveHeightForNodes(wall, nodes, 0) + const heightEnd = getWallEffectiveHeightForNodes(wall, nodes, 1) const startLocal = worldPointToWallLocal(wall, measurementPoints.start) const endLocal = worldPointToWallLocal(wall, measurementPoints.end) const curvedMeasurementPath = isCurvedWall(wall) ? getCurvedWallMeasurementPath(wall, miterData, levelWalls) : null + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const wallChordLength = Math.hypot(dx, dz) + const getChordT = (localX: number) => + wallChordLength > 1e-6 ? Math.max(0, Math.min(1, localX / wallChordLength)) : 0 + const guidePath: Vec3[] = curvedMeasurementPath ? curvedMeasurementPath.map((point) => { const localPoint = worldPointToWallLocal(wall, point) - return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]] + const t = getChordT(localPoint[0]) + const h = getWallEffectiveHeightForNodes(wall, nodes, t) + return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]] }) : isCurvedWall(wall) ? sampleWallCenterline(wall, 24).map((point, index, points) => { @@ -334,12 +343,14 @@ function buildMeasurementGuide( : index === points.length - 1 ? endLocal : worldPointToWallLocal(wall, point) + const t = getChordT(localPoint[0]) + const h = getWallEffectiveHeightForNodes(wall, nodes, t) - return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]] + return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]] }) : [ - [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]], - [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]], + [startLocal[0], heightStart + GUIDE_Y_OFFSET, startLocal[2]], + [endLocal[0], heightEnd + GUIDE_Y_OFFSET, endLocal[2]], ] if (guidePath.length < 2) return null @@ -397,26 +408,30 @@ function buildMeasurementGuide( ], }) const bottomHeightTick = getHorizontalHeightTick(0) - const topHeightTick = getHorizontalHeightTick(height) + const topHeightTick = getHorizontalHeightTick(heightEnd) return { guidePath, - extStartStart: [extensionStartBase[0], height, extensionStartBase[2]], + extStartStart: [extensionStartBase[0], heightStart, extensionStartBase[2]], extStartEnd: [ extensionStartBase[0], - height + GUIDE_Y_OFFSET + extOvershoot, + heightStart + GUIDE_Y_OFFSET + extOvershoot, extensionStartBase[2], ], - extEndStart: [extensionEndBase[0], height, extensionEndBase[2]], - extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]], + extEndStart: [extensionEndBase[0], heightEnd, extensionEndBase[2]], + extEndEnd: [ + extensionEndBase[0], + heightEnd + GUIDE_Y_OFFSET + extOvershoot, + extensionEndBase[2], + ], labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]], heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]], - heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]], + heightEnd: [heightGuidePosition[0], heightEnd, heightGuidePosition[2]], heightBottomTickStart: bottomHeightTick.start, heightBottomTickEnd: bottomHeightTick.end, heightTopTickStart: topHeightTick.start, heightTopTickEnd: topHeightTick.end, - heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]], + heightLabelPosition: [heightGuidePosition[0], heightEnd / 2, heightGuidePosition[2]], } } @@ -530,7 +545,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { return total }, [guide, wall]) const label = formatLinearMeasurement(length, unit, metricNotation) - const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) + // Height annotation uses t=1 (wall end) because the vertical guide is drawn at the endpoint + const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes, 1), [nodes, wall]) const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}` if (!(guide && Number.isFinite(length) && length >= 0.01)) return null diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 6e6165d33f..e0664976e2 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -268,7 +268,11 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: const corner = endpoint === 'start' ? wall.start : wall.end const x = corner[0] const z = corner[1] - const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) + const wallHeight = getWallEffectiveHeightForNodes( + wall, + useScene.getState().nodes, + endpoint === 'start' ? 0 : 1, + ) const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) @@ -606,7 +610,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const wallAngle = Math.atan2(-dirZ, dirX) // `wall` is the override-merged effective wall (see // WallMoveSideHandlesForWall), so this height is already live during a drag. - const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes, 0.5) const handleY = wallHeight + HEIGHT_HANDLE_OFFSET const activateHeightResize = (event: ThreeEvent) => { @@ -949,7 +953,7 @@ function getWallMoveHandles(wall: WallNode, nodes: Record): Wal const midpoint: [number, number] = frame ? [frame.point.x, frame.point.y] : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const wallHeight = getWallEffectiveHeightForNodes(wall, nodes) + const wallHeight = getWallEffectiveHeightForNodes(wall, nodes, 0.5) const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index 7ef0ee9b1d..2a401f28e7 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -144,9 +144,10 @@ type WallTopHighlightSegment = { angle: number center: [number, number] length: number + tCenter: number } -function getWallTopY(wall: WallNode, nodes: Readonly>) { +function getWallTopY(wall: WallNode, nodes: Readonly>, t = 0.5): number { const levelId = resolveLevelId(wall, nodes as Record) const support = spatialGridManager.getSlabSupportForWall( levelId, @@ -157,10 +158,14 @@ function getWallTopY(wall: WallNode, nodes: Readonly>) { wall.supportSlabId, ) const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) - return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT + return resolveWallTop(wall, planeTop, support.elevation, t) + WALL_TOP_HIGHLIGHT_LIFT } -function buildHighlightSegment(start: [number, number], end: [number, number]) { +function buildHighlightSegment( + start: [number, number], + end: [number, number], + tCenter = 0.5, +): WallTopHighlightSegment | null { const dx = end[0] - start[0] const dz = end[1] - start[1] const length = Math.hypot(dx, dz) @@ -170,15 +175,20 @@ function buildHighlightSegment(start: [number, number], end: [number, number]) { angle: -Math.atan2(dz, dx), center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number], length, + tCenter, } } function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] { if (!isCurvedWall(wall)) { - const segment = buildHighlightSegment(wall.start, wall.end) + const segment = buildHighlightSegment(wall.start, wall.end, 0.5) return segment ? [segment] : [] } + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const chordLenSq = dx * dx + dz * dz + const sampleCount = Math.max( 8, Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH), @@ -187,7 +197,16 @@ function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[ let previous = getWallCurveFrameAt(wall, 0).point for (let index = 1; index <= sampleCount; index += 1) { const current = getWallCurveFrameAt(wall, index / sampleCount).point - const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y]) + const midX = (previous.x + current.x) / 2 + const midY = (previous.y + current.y) / 2 + const tCenter = + chordLenSq > 1e-12 + ? Math.max( + 0, + Math.min(1, ((midX - wall.start[0]) * dx + (midY - wall.start[1]) * dz) / chordLenSq), + ) + : 0.5 + const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y], tCenter) if (segment) segments.push(segment) previous = current } @@ -202,38 +221,40 @@ function WallTopHighlight({ wall: WallNode }) { const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall]) - const y = getWallTopY(wall, nodes) const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24) const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42) return ( <> - {segments.map((segment, index) => ( - - - - - ))} + {segments.map((segment, index) => { + const y = getWallTopY(wall, nodes, segment.tCenter) + return ( + + + + + ) + })} ) } diff --git a/packages/editor/src/lib/elevation-guides.ts b/packages/editor/src/lib/elevation-guides.ts index b828be9ba7..cf619c7b1b 100644 --- a/packages/editor/src/lib/elevation-guides.ts +++ b/packages/editor/src/lib/elevation-guides.ts @@ -151,12 +151,27 @@ export function collectElevationSnapTargets( anchor: center, label: 'Wall base', }) - targets.push({ - id: `${node.id}:top`, - elevation: base + getWallEffectiveHeightForNodes(node, nodes), - anchor: center, - label: 'Wall top', - }) + if (node.endHeightOffset) { + targets.push({ + id: `${node.id}:top-start`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 0), + anchor: node.start, + label: 'Wall top start', + }) + targets.push({ + id: `${node.id}:top-end`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 1), + anchor: node.end, + label: 'Wall top end', + }) + } else { + targets.push({ + id: `${node.id}:top`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 0.5), + anchor: center, + label: 'Wall top', + }) + } continue } diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index d9a6721bbd..106ae3a35e 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -84,6 +84,8 @@ describe('snapContextOf (profile-driven, node-declared)', () => { roof: 'structural', zone: 'structural', block: 'structural', + door: 'item', + window: 'item', } const profileOf = (t: string) => declared[t] const profileOfNode = (id: string) => @@ -146,7 +148,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => { }) it('an undeclared kind (no snapProfile) gets no snap context', () => { - expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull() + expect(ctx({ kind: 'moving', nodeType: 'unknown_plugin_kind' })).toBeNull() expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull() }) @@ -178,4 +180,26 @@ describe('snapContextOf (profile-driven, node-declared)', () => { }), ).toBe('polygon') }) + it('movingNodeType resolves active snap context when moving a node', () => { + expect( + snapContextOf({ + scope: { kind: 'idle' }, + movingNodeType: 'window', + mode: 'select', + tool: null, + profileOf, + profileOfNode, + }), + ).toBe('item') + expect( + snapContextOf({ + scope: { kind: 'idle' }, + movingNodeType: 'door', + mode: 'select', + tool: null, + profileOf, + profileOfNode, + }), + ).toBe('item') + }) }) diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index ecd5869374..3a1ae24870 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -150,6 +150,7 @@ export function snapContextOf(args: { handle?: string operator?: string } + movingNodeType?: string | null mode: string tool: string | null profileOf: (typeOrTool: string) => SnapProfile | undefined @@ -159,7 +160,12 @@ export function snapContextOf(args: { // to `true` (the structural draw default) when not supplied. draftDirectionalOf?: (typeOrTool: string) => boolean }): SnapContext | null { - const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args + const { scope, movingNodeType, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args + // Direct node movement (e.g. moving a window, door, or item) activates the + // kind's snapping profile so Shift/Ctrl keyboard cycling and HUD chips are active. + if (movingNodeType) { + return contextForProfile(profileOf(movingNodeType), false) + } // The group-move gizmo translates the whole selection — same no-angle // treatment as a single-node move, so Shift cycles the 'item' modes and the // HUD shows the item snapping chips for the drag. diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 52ba85e977..f0c67d7f19 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -56,7 +56,7 @@ import { snappingModesFor, } from '../lib/snapping-mode' import { publishNavigationSyncPoseToStore } from './navigation-sync-pose-store' -import useInteractionScope from './use-interaction-scope' +import useInteractionScope, { getMovingNode } from './use-interaction-scope' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build' const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 @@ -1481,6 +1481,9 @@ export function getActiveSnapContext(): SnapContext | null { const editor = useEditor.getState() return snapContextOf({ scope: useInteractionScope.getState().scope, + // Active node movement (e.g. dragging a window, door, or item) activates + // the kind's snapping profile so Shift/Ctrl keyboard cycling works mid-drag. + movingNodeType: getMovingNode()?.type, mode: editor.mode, tool: editor.tool, profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 51de4fcbab..8e9e2a02fe 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -139,7 +139,7 @@ export function resolveReportedWallHeight( (node): node is Extract => node.type === 'wall', ) const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) - return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation, 0) } function metadataRecord(node: AnyNode): Record | null { diff --git a/packages/nodes/src/door/definition.test.ts b/packages/nodes/src/door/definition.test.ts new file mode 100644 index 0000000000..bf605611ee --- /dev/null +++ b/packages/nodes/src/door/definition.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test' +import { doorDefinition } from './definition' +import { DoorNode } from './schema' + +describe('door resize handles on non-wall hosts', () => { + it('does not throw when host is not a standard wall node', () => { + const door = DoorNode.parse({ + id: 'door_on_dormer', + parentId: 'dormer_123', + wallId: 'dormer_123', + width: 1.0, + height: 2.1, + position: [1, 1.05, 0], + }) + + const dormerMock = { + id: 'dormer_123', + type: 'dormer', + } + + const scene = { + get: (id: string) => (id === 'dormer_123' ? dormerMock : undefined), + nodes: () => ({ [dormerMock.id]: dormerMock as any }), + } + + const handles = + typeof doorDefinition.handles === 'function' + ? doorDefinition.handles(door, scene as any) + : (doorDefinition.handles ?? []) + + for (const handle of handles as any[]) { + if (typeof handle.max === 'function') { + expect(() => handle.max(door, scene as any)).not.toThrow() + } + } + }) +}) diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 9c7025d05e..884016c3db 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -14,7 +14,7 @@ import { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' -import { readHostWallCeiling } from '../shared/wall-opening-ceiling' +import { readHostWallCeiling, readHostWallCeilingMaxWidth } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildDoorContextualDimensions } from './contextual-dimensions' import { scaleHandleHeight } from './door-math' @@ -35,9 +35,10 @@ const MIN_DOOR_WIDTH = 0.3 const MOVE_HANDLE_LIFT = 0.12 function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!door.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined - if (!wall) return Number.POSITIVE_INFINITY + const hostId = door.wallId || door.parentId + if (!hostId) return Number.POSITIVE_INFINITY + const wall = scene.get(hostId as AnyNodeId) as WallNode | undefined + if (wall?.type !== 'wall' || !wall.start || !wall.end) return Number.POSITIVE_INFINITY return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } @@ -54,11 +55,39 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor anchor: side === 'right' ? 'min' : 'max', min: MIN_DOOR_WIDTH, max: (n, scene) => { - // Roof-hosted doors clamp against the face profile (the wall-based - // limits read Infinity when wallId is unset). + // Roof-hosted doors clamp against the face profile. const roofMax = readRoofFaceWidthMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_DOOR_WIDTH, roofMax) - return readWallLength(n, scene) + + const length = readWallLength(n, scene) + // armX accounts for door rotation (rotation[1]=π flips the door + // so its visual right points toward LOWER wall-local S, not higher). + const armX = Math.cos(n.rotation[1]) + const effectiveDirection = sign * armX + + const anchorLeft = n.position[0] - n.width / 2 + const anchorRight = n.position[0] + n.width / 2 + + let fixedEdgeS: number + let growSign: number + let maxWallBound: number + + if (effectiveDirection > 0) { + fixedEdgeS = anchorLeft + growSign = 1 + maxWallBound = length - anchorLeft + } else { + fixedEdgeS = anchorRight + growSign = -1 + maxWallBound = anchorRight + } + + const topY = n.position[1] + n.height / 2 + const hostId = n.wallId || n.parentId + return Math.max( + MIN_DOOR_WIDTH, + readHostWallCeilingMaxWidth(hostId, scene as any, fixedEdgeS, growSign, topY, maxWallBound), + ) }, currentValue: (n) => n.width, onDrag: (node) => publishOpeningResizeGuides(node, false), @@ -101,10 +130,22 @@ function doorHeightHandle(): HandleDescriptor { const roofMax = readRoofFaceHeightMax(n, scene, 1) if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) const bottom = n.position[1] - n.height / 2 - return Math.max(MIN_DOOR_HEIGHT, readHostWallCeiling(n.wallId, scene) - bottom) + const hostId = n.wallId || n.parentId + + // A sloped wall's ceiling varies across the door's width. To prevent corners + // poking out above the slope, the height limit must be the lowest ceiling + // point across the entire span of the door. + const leftS = n.position[0] - n.width / 2 + const rightS = n.position[0] + n.width / 2 + const wallHLeft = readHostWallCeiling(hostId, scene as any, leftS) + const wallHRight = readHostWallCeiling(hostId, scene as any, rightS) + const wallHCenter = readHostWallCeiling(hostId, scene as any, n.position[0]) + const wallH = Math.min(wallHLeft, wallHRight, wallHCenter) + + return Math.max(MIN_DOOR_HEIGHT, wallH - bottom) }, currentValue: (n) => n.height, - onDrag: (node) => publishOpeningResizeGuides(node, false), + onDrag: (node) => publishOpeningResizeGuides(node, true), apply: (initial, newHeight) => { const bottom = initial.position[1] - initial.height / 2 // Scale the handle so it tracks the door instead of staying glued to a diff --git a/packages/nodes/src/door/door-math.test.ts b/packages/nodes/src/door/door-math.test.ts new file mode 100644 index 0000000000..ff693f4619 --- /dev/null +++ b/packages/nodes/src/door/door-math.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' +import { WallNode } from '@pascal-app/core' +import { clampToWall } from './door-math' + +describe('clampToWall for doors', () => { + test('centers at wallLength / 2 when door is wider than wall', () => { + const wall = WallNode.parse({ + id: 'wall_short', + start: [0, 0], + end: [2, 0], + height: 3, + }) + const nodes = { [wall.id]: wall } + const result = clampToWall(wall, 1, 3, 2.1, nodes) + expect(result.clampedX).toBe(1) // wallLength / 2 = 2 / 2 = 1 + expect(result.clampedY).toBe(1.05) // height / 2 = 2.1 / 2 = 1.05 + expect(result.fits).toBe(false) + }) + + test('clamps within horizontal bounds on a standard wall', () => { + const wall = WallNode.parse({ + id: 'wall_standard', + start: [0, 0], + end: [5, 0], + height: 3, + }) + const nodes = { [wall.id]: wall } + const leftClamp = clampToWall(wall, 0.1, 1, 2.1, nodes) + expect(leftClamp.clampedX).toBe(0.5) + expect(leftClamp.fits).toBe(true) + + const rightClamp = clampToWall(wall, 4.9, 1, 2.1, nodes) + expect(rightClamp.clampedX).toBe(4.5) + expect(rightClamp.fits).toBe(true) + }) + + test('evaluates fits and slides on a sloped wall', () => { + const wall = WallNode.parse({ + id: 'wall_sloped', + start: [0, 0], + end: [10, 0], + height: 3, + endHeightOffset: -2, // Slopes from 3m down to 1m (slope = -0.2) + }) + const nodes = { [wall.id]: wall } + + // At X = 1 (near start), ceiling is ~2.8m -> 2.1m door fits + const startResult = clampToWall(wall, 1, 1, 2.1, nodes) + expect(startResult.fits).toBe(true) + expect(startResult.clampedX).toBe(1) + + // At X = 9 (near end), ceiling is ~1.2m -> 2.1m door cannot fit + // Exact analytical boundary: (2.1 - 3) / -0.2 - 0.5 = 4.0m + const endResult = clampToWall(wall, 9, 1, 2.1, nodes) + expect(endResult.fits).toBe(true) + expect(endResult.clampedX).toBeCloseTo(4, 5) + + // Door taller than wall maximum height does not fit anywhere + const tooTall = clampToWall(wall, 1, 1, 3.5, nodes) + expect(tooTall.fits).toBe(false) + }) + + test('evaluates fits and slides on an upward sloped wall', () => { + const wall = WallNode.parse({ + id: 'wall_upward', + start: [0, 0], + end: [10, 0], + height: 1, + endHeightOffset: 2, // Slopes from 1m up to 3m (slope = +0.2) + }) + const nodes = { [wall.id]: wall } + + // At X = 1 (near start), ceiling is ~1.2m -> 2.1m door cannot fit + // Exact analytical boundary: (2.1 - 1) / 0.2 + 0.5 = 6.0m + const startResult = clampToWall(wall, 1, 1, 2.1, nodes) + expect(startResult.fits).toBe(true) + expect(startResult.clampedX).toBe(6) + + // At X = 8 (near end), ceiling is ~2.6m -> fits without sliding + const endResult = clampToWall(wall, 8, 1, 2.1, nodes) + expect(endResult.fits).toBe(true) + expect(endResult.clampedX).toBe(8) + }) +}) diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index b5517c9dcf..d4fe7c3caa 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,9 @@ -import type { WallNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' +import { + readHostWallCeiling, + toWallCeilingSceneReader, + type WallCeilingSceneReader, +} from '../shared/wall-opening-ceiling' /** * Keep the door handle at the same relative height when the door is resized: @@ -46,14 +51,48 @@ export function clampToWall( localX: number, width: number, height: number, -): { clampedX: number; clampedY: number } { + sceneOrNodes: WallCeilingSceneReader | Readonly>, +): { clampedX: number; clampedY: number; fits: boolean } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) + const wallLength = Math.hypot(dx, dz) - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const minX = width / 2 + const maxX = wallLength - width / 2 const clampedY = height / 2 // Doors always sit at floor level - return { clampedX, clampedY } + + if (width > wallLength) { + return { clampedX: wallLength / 2, clampedY, fits: false } + } + + const scene = toWallCeilingSceneReader(sceneOrNodes) + const startCeiling = readHostWallCeiling(wallNode.id, scene, 0) + const endCeiling = readHostWallCeiling(wallNode.id, scene, wallLength) + const slope = (endCeiling - startCeiling) / wallLength + + let fitMinX = minX + let fitMaxX = maxX + + if (Math.abs(slope) < 1e-6) { + if (startCeiling < height - 1e-4) { + return { clampedX: Math.max(minX, Math.min(maxX, localX)), clampedY, fits: false } + } + } else if (slope > 0) { + // Upward slope: lowest point is the left edge (x - width/2) + const minCenterForHeight = (height - startCeiling) / slope + width / 2 + fitMinX = Math.max(minX, minCenterForHeight) + } else { + // Downward slope: lowest point is the right edge (x + width/2) + const maxCenterForHeight = (height - startCeiling) / slope - width / 2 + fitMaxX = Math.min(maxX, maxCenterForHeight) + } + + if (fitMinX > fitMaxX + 1e-4) { + return { clampedX: Math.max(minX, Math.min(maxX, localX)), clampedY, fits: false } + } + + const clampedX = Math.max(fitMinX, Math.min(fitMaxX, localX)) + return { clampedX, clampedY, fits: true } } // Wall-child overlap is shared by door + window placement (one source of diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 570ce29e06..1d135a9fd3 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -98,6 +98,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // the cursor as a ghost (like the 3D move) and is NOT committable — a door // needs a wall. Starts true so a click before any move keeps the door put. let onWall = true + let lastFits = true // Alt force-place (last apply's modifier) — lets `canCommit` allow an // overlapping placement, matching the 3D move. Read in `canCommit` so an Alt- // held commit over a collision lands instead of reverting. @@ -209,7 +210,18 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) nodes, }) const snappedLocalX = neighborX ?? snapToHalf(hit.localX) - const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) + const sceneReader = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + } + const { clampedX, clampedY, fits } = clampToWall( + hit.wall, + snappedLocalX, + node.width, + node.height, + sceneReader, + ) + lastFits = fits // One click per real position step, keyed on the SNAPPED along-wall value // so it ticks only when the door actually moves to a new cell. @@ -254,17 +266,19 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) if (!onWall || !lastValid) return false const live = useScene.getState().nodes[nodeId] as DoorNode | undefined if (live?.type !== 'door') return false - // Block commit if the door overlaps another wall child — UNLESS Alt - // force-places (same `placeable` rule as the 3D move + the shared - // `resolveOpeningPlacement`). - const collides = hasWallChildOverlap( - lastValid.parentId, - lastValid.position[0], - lastValid.position[1], - live.width, - live.height, - live.id, - ) + // Block commit if the door does not fit the wall's sloped ceiling or overlaps + // another wall child — UNLESS Alt force-places (same `placeable` rule as + // the 3D move + the shared `resolveOpeningPlacement`). + const collides = + !lastFits || + hasWallChildOverlap( + lastValid.parentId, + lastValid.position[0], + lastValid.position[1], + live.width, + live.height, + live.id, + ) return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index ac6d4f343e..4a4ad61550 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -149,10 +149,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } let currentHostId: string | null = movingDoorNode.parentId - let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null - // The wall the door was grabbed from. Nulled the first time the anchor - // seeds on any other host: the grab offset is then forgotten for good. - let grabWallId: string | null = movingDoorNode.parentId let committed = false // Off-wall free-follow: when the cursor is over empty floor (no wall under // the ray) the door is parented to the level and tracks the cursor like an @@ -313,24 +309,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (event.node.parentId !== getLevelId()) return const { side, itemRotation } = getPlacementOrientation(event) - const rawLocalX = event.localPosition[0] - if (!dragAnchor || dragAnchor.wallId !== event.node.id) { - // Grab offset survives only on the original wall and only until the - // door anchors on any other host — after that every wall (the - // original included) centers the door under the cursor. - const preserveGrab = event.node.id === grabWallId - if (!preserveGrab) grabWallId = null - dragAnchor = { - wallId: event.node.id, - rawX: rawLocalX, - startX: preserveGrab ? original.position[0] : rawLocalX, - } - } - const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) const localX = resolveWallSlideAlignment({ wallNode: event.node, - rawLocalX: targetLocalX, + rawLocalX, width: movingDoorNode.width, candidates: alignmentCandidates, // Along-wall alignment guides display in every snapping mode; the @@ -338,21 +320,28 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // component lives in `snapToHalf` (itself mode-aware). applySnap: isMagneticSnapActive(), }) - const { clampedX, clampedY } = clampToWall( + const sceneReader = { + get: (id: AnyNodeId) => useScene.getState().nodes[id], + nodes: () => useScene.getState().nodes, + } + const { clampedX, clampedY, fits } = clampToWall( event.node, localX, movingDoorNode.width, movingDoorNode.height, + sceneReader, ) - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingDoorNode.width, - movingDoorNode.height, - movingDoorNode.id, - ) + const valid = + fits && + !hasWallChildOverlap( + event.node.id, + clampedX, + clampedY, + movingDoorNode.width, + movingDoorNode.height, + movingDoorNode.id, + ) return { wallNode: event.node, @@ -628,7 +617,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // back to the building origin between a wall and open floor. hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) - dragAnchor = null lastTarget = null lastRoofEvent = null } @@ -746,12 +734,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // Valid roof hit owns the pointer for the next few frames; the floor // free-follow stands down until the cursor genuinely leaves the roof. markWallOwnedPointer() - // Wall-frame drag anchor / live transform don't apply on a roof face — - // and anchoring here counts as "elsewhere", so the original wall's grab - // offset is forgotten for good. + // Wall-frame live transform doesn't apply on a roof face. freeFollowing = false - dragAnchor = null - grabWallId = null lastTarget = null lastRoofEvent = event useLiveTransforms.getState().clear(movingDoorNode.id) @@ -868,7 +852,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // over on the same pointermove (snap to a nearby wall or free-follow). hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) - dragAnchor = null lastTarget = null lastRoofEvent = null } diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 9851baeaf4..ecf19ecbc8 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -290,8 +290,13 @@ const DoorTool: React.FC = () => { candidates: alignmentCandidates, applySnap, }) - const { clampedX, clampedY } = clampToWall(wall, localX, width, height) - const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + const sceneReader = { + get: (id: AnyNodeId) => useScene.getState().nodes[id], + nodes: () => useScene.getState().nodes, + } + const { clampedX, clampedY, fits } = clampToWall(wall, localX, width, height, sceneReader) + const valid = + fits && !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) return { clampedX, clampedY, valid } } diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 151cf20fc3..27d43a6eca 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -95,7 +95,14 @@ export function publishOpeningGuides3D(args: { }): void { const { wall, centerS, centerY, width, toWorld } = args const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) - const wallHeight = resolveWallOpeningCeiling(wall, args.nodes) + const halfW = width / 2 + const tLeft = wallLength > 1e-4 ? Math.max(0, Math.min(1, (centerS - halfW) / wallLength)) : 0.5 + const tRight = wallLength > 1e-4 ? Math.max(0, Math.min(1, (centerS + halfW) / wallLength)) : 0.5 + const tCenter = wallLength > 1e-4 ? Math.max(0, Math.min(1, centerS / wallLength)) : 0.5 + const hLeft = resolveWallOpeningCeiling(wall, args.nodes, tLeft) + const hRight = resolveWallOpeningCeiling(wall, args.nodes, tRight) + const hCenter = resolveWallOpeningCeiling(wall, args.nodes, tCenter) + const wallHeight = Math.min(hLeft, hRight, hCenter) const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes) const guides = computeOpeningGuides({ moving: { id: args.movingId, centerS, width, centerY, height: args.height }, diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 807728a1cf..d508774efb 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -101,7 +101,11 @@ export function buildOpeningPlacementDimensions( siblings, wall: { length: wallLength, - height: resolveWallOpeningCeiling(wall, useScene.getState().nodes), + height: resolveWallOpeningCeiling( + wall, + useScene.getState().nodes, + wallLength > 1e-4 ? Math.max(0, Math.min(1, opening.position[0] / wallLength)) : 0.5, + ), }, // The 2D plan is top-down: sill/head height and vertical alignment aren't // representable here — those belong to the 3D viewport. diff --git a/packages/nodes/src/shared/wall-opening-ceiling.test.ts b/packages/nodes/src/shared/wall-opening-ceiling.test.ts new file mode 100644 index 0000000000..4b3e9fd860 --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-ceiling.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import { WallNode } from '@pascal-app/core' +import { + readHostWallCeiling, + readHostWallCeilingMaxWidth, + toWallCeilingSceneReader, +} from './wall-opening-ceiling' + +describe('toWallCeilingSceneReader', () => { + const wall = WallNode.parse({ + id: 'wall_test', + start: [0, 0], + end: [4, 0], + height: 3, + }) + const nodes = { [wall.id]: wall } + + test('normalizes a raw nodes record', () => { + const reader = toWallCeilingSceneReader(nodes) + expect(reader.get(wall.id)).toEqual(wall) + expect(reader.nodes()).toEqual(nodes) + }) + + test('passes through an existing WallCeilingSceneReader untouched', () => { + const existingReader = { + get: (id: any) => nodes[id], + nodes: () => nodes, + } + const reader = toWallCeilingSceneReader(existingReader) + expect(reader).toBe(existingReader) + expect(reader.get(wall.id)).toEqual(wall) + }) + + test('readHostWallCeiling works identically with both formats', () => { + const fromRecord = readHostWallCeiling(wall.id, toWallCeilingSceneReader(nodes)) + const fromReader = readHostWallCeiling(wall.id, { + get: (id: any) => nodes[id], + nodes: () => nodes, + }) + expect(fromRecord).toBe(3) + expect(fromReader).toBe(3) + }) +}) + +describe('readHostWallCeilingMaxWidth', () => { + const wallSloped = WallNode.parse({ + id: 'wall_sloped', + start: [0, 0], + end: [10, 0], + height: 3, + endHeightOffset: -2, // Slopes from 3m at start to 1m at end (slope = -0.2) + }) + const nodes = { [wallSloped.id]: wallSloped } + const reader = toWallCeilingSceneReader(nodes) + + test('calculates exact analytical max width growing towards low end', () => { + // At anchorS = 2.0 (ceiling = 3 - 0.4 = 2.6m), growing right (growSign = +1) + // topY = 2.0m. Limit where ceiling drops to 2.0m is s = (2.0 - 3.0)/(-0.2) = 5.0m + // Allowed width = 5.0 - 2.0 = 3.0m + const maxWidth = readHostWallCeilingMaxWidth(wallSloped.id, reader, 2.0, 1, 2.0, 10) + expect(maxWidth).toBe(3.0) + }) + + test('allows full maxLength when growing towards high end', () => { + // At anchorS = 5.0, growing left (growSign = -1) towards higher start + const maxWidth = readHostWallCeilingMaxWidth(wallSloped.id, reader, 5.0, -1, 2.0, 4.0) + expect(maxWidth).toBe(4.0) + }) + + test('returns 0 when anchor itself is below topY', () => { + // At anchorS = 8.0, ceiling is 3 - 0.2*8 = 1.4m < topY (2.0m) + const maxWidth = readHostWallCeilingMaxWidth(wallSloped.id, reader, 8.0, 1, 2.0, 5) + expect(maxWidth).toBe(0) + }) +}) diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index 23f58da7eb..39944b07cc 100644 --- a/packages/nodes/src/shared/wall-opening-ceiling.ts +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -15,6 +15,22 @@ export type WallCeilingSceneReader = { nodes: () => Readonly> } +/** + * Normalizes either a `WallCeilingSceneReader` or a raw nodes record into a + * `WallCeilingSceneReader`. + */ +export function toWallCeilingSceneReader( + sceneOrNodes: WallCeilingSceneReader | Readonly>, +): WallCeilingSceneReader { + if (typeof (sceneOrNodes as WallCeilingSceneReader).nodes === 'function') { + return sceneOrNodes as WallCeilingSceneReader + } + return { + get: (id: AnyNodeId) => (sceneOrNodes as Readonly>)[id], + nodes: () => sceneOrNodes as Readonly>, + } +} + /** * Available wall-local Y span for an opening hosted on `wall`: the wall's * resolved top (storey plane for plane-bound walls, stored height for @@ -30,8 +46,9 @@ export type WallCeilingSceneReader = { export function resolveWallOpeningCeiling( wall: WallNode, nodes: Readonly>, + t?: number, ): number { - return getWallEffectiveHeightForNodes(wall, nodes as Record) + return getWallEffectiveHeightForNodes(wall, nodes as Record, t) } /** @@ -42,9 +59,61 @@ export function resolveWallOpeningCeiling( export function readHostWallCeiling( wallId: string | null | undefined, scene: WallCeilingSceneReader, + positionS?: number, ): number { if (!wallId) return Number.POSITIVE_INFINITY const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined - if (!wall) return Number.POSITIVE_INFINITY - return resolveWallOpeningCeiling(wall, scene.nodes()) + if (wall?.type !== 'wall' || !wall.start || !wall.end) return Number.POSITIVE_INFINITY + if (positionS !== undefined) { + // When positionS is given, convert it to a parametric t in the chord frame + // (0 → wall start, 1 → wall end) to match the slope evaluation in + // applyWallEndHeightSlope (WallSystem). + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length > 1e-4) { + const localT = Math.max(0, Math.min(1, positionS / length)) + return Math.max(0.01, resolveWallOpeningCeiling(wall, scene.nodes(), localT)) + } + } + return Math.max(0.01, resolveWallOpeningCeiling(wall, scene.nodes())) +} + +export function readHostWallCeilingMaxWidth( + wallId: string | null | undefined, + scene: WallCeilingSceneReader, + anchorS: number, + growSign: number, + topY: number, + maxLength: number, +): number { + if (!wallId) return maxLength + const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined + if (wall?.type !== 'wall' || !wall.start || !wall.end) return maxLength + + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dz) + if (wallLength < 1e-4) return 0 + + const startHeight = resolveWallOpeningCeiling(wall, scene.nodes(), 0) + const endHeight = resolveWallOpeningCeiling(wall, scene.nodes(), 1) + const slope = (endHeight - startHeight) / wallLength + + // At anchorS, check if the anchor itself is below topY + const clampedAnchorS = Math.max(0, Math.min(wallLength, anchorS)) + const anchorCeiling = startHeight + slope * clampedAnchorS + if (anchorCeiling < topY - 1e-4) { + return 0 + } + + // If slope grows in direction of growSign (or is flat), height stays >= topY + if (slope * growSign >= -1e-6) { + return maxLength + } + + // Analytical intersection: startHeight + slope * s = topY => s = (topY - startHeight) / slope + const limitS = (topY - startHeight) / slope + const allowedLength = (limitS - anchorS) / growSign + return Math.max(0, Math.min(maxLength, allowedLength)) } diff --git a/packages/nodes/src/wall/measurement.ts b/packages/nodes/src/wall/measurement.ts index 0d918f9023..cb08adc229 100644 --- a/packages/nodes/src/wall/measurement.ts +++ b/packages/nodes/src/wall/measurement.ts @@ -13,8 +13,19 @@ import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' const point = (x: number, y: number, z: number) => [x, y, z] as [number, number, number] +function getWallChordT(wall: WallNode, worldX: number, worldZ: number): number { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const lenSq = dx * dx + dz * dz + if (lenSq < 1e-8) return 0.5 + const px = worldX - wall.start[0] + const pz = worldZ - wall.start[1] + return Math.max(0, Math.min(1, (px * dx + pz * dz) / lenSq)) +} + export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] { - const height = resolveWallOpeningCeiling(wall, useScene.getState().nodes) + const nodes = useScene.getState().nodes + const midHeight = resolveWallOpeningCeiling(wall, nodes, 0.5) const arc = getWallArcData(wall) const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y)) const midpoint = getWallCurveFrameAt(wall, 0.5).point @@ -98,7 +109,7 @@ export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] { geometry: { kind: 'segment', start: point(midpoint.x, 0, midpoint.y), - end: point(midpoint.x, height, midpoint.y), + end: point(midpoint.x, midHeight, midpoint.y), }, }, { @@ -108,7 +119,11 @@ export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] { priority: 75, geometry: { kind: 'path', - points: centerline.map(([x, , z]) => point(x, height, z)), + points: centerline.map(([x, , z]) => { + const chordT = getWallChordT(wall, x, z) + const h = resolveWallOpeningCeiling(wall, nodes, chordT) + return point(x, h, z) + }), }, }, ] @@ -182,9 +197,10 @@ export function matchWallMeasurementFeature( const faceDistance = Math.hypot(hit[0] - faceX, hit[2] - faceZ) const threshold = Math.max(maxDistance, halfThickness + 0.03) if (faceDistance <= threshold && (!best || faceDistance < best.distance)) { + const chordT = getWallChordT(wall, faceX, faceZ) const height = Math.max( 0, - Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), hit[1]), + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes, chordT), hit[1]), ) best = { featureId: side > 0 ? 'wall:face:left' : 'wall:face:right', @@ -221,9 +237,10 @@ export function resolveWallMeasurementFeature( if (typeof heightValue !== 'number' || feature.geometry.kind !== 'path') { return normal ? { ...feature, normal } : feature } + const chordT = getWallChordT(wall, frame.point.x, frame.point.y) const height = Math.max( 0, - Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), heightValue), + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes, chordT), heightValue), ) return { ...feature, diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 199df4fdbe..761b4c5496 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -669,7 +669,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ end: previewEnd, curveOffset: target.wall.curveOffset, }) - const wallHeight = resolveWallOpeningCeiling(effectiveWall, nodes) + const wallHeight = resolveWallOpeningCeiling(effectiveWall, nodes, 0.5) const dimMidX = (previewStart[0] + previewEnd[0]) / 2 const dimMidZ = (previewStart[1] + previewEnd[1]) / 2 diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 754d563406..6ed5a27ba8 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -231,17 +231,19 @@ export default function WallPanel() { const followsTerrain = node.fillToTerrain === true const isPlaneBound = node.height == null const height = node.height ?? resolvedHeightMeters ?? 2.5 + const endHeightOffset = node.endHeightOffset ?? 0 const thickness = node.thickness ?? 0.1 const curveOffset = getClampedWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node) const unitLabel = getLinearUnitLabel(unit) const displayLength = metersToLinearUnit(length, unit) const displayHeight = metersToLinearUnit(height, unit) + const displayEndHeightOffset = metersToLinearUnit(endHeightOffset, unit) const displayThickness = metersToLinearUnit(thickness, unit) const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) - const wallHeightMeters = height + const wallHeightMeters = resolvedHeightMeters ?? height const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } @@ -300,6 +302,22 @@ export default function WallPanel() { value={Math.round(displayHeight * 100) / 100} /> )} + { + const minMeters = -(wallHeightMeters - 0.01) + handleUpdate({ + endHeightOffset: linearControlValueToMeters(v, unit, { + minMeters, + }), + }) + }} + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayEndHeightOffset * 100) / 100} + />
Bottom
@@ -425,6 +443,7 @@ function WallFaceBandSection({ wallHeightMeters: number }) { const bandConfig = getWallFaceBandConfig(node, wallHeightMeters) + const maxWallHeight = wallHeightMeters + Math.max(0, node.endHeightOffset ?? 0) const bandCount = bandConfig.count const lowerHeight = bandConfig.lowerHeight const middleHeight = bandConfig.middleHeight @@ -454,12 +473,12 @@ function WallFaceBandSection({ {bandCount >= 2 && ( updateBands({ lowerHeight: linearControlValueToMeters(value, unit, { - maxMeters: wallHeightMeters, + maxMeters: maxWallHeight, minMeters: 0, }), }) @@ -473,12 +492,12 @@ function WallFaceBandSection({ {bandCount >= 3 && ( updateBands({ middleHeight: linearControlValueToMeters(value, unit, { - maxMeters: Math.max(0, wallHeightMeters - lowerHeight), + maxMeters: Math.max(0, maxWallHeight - lowerHeight), minMeters: 0, }), }) @@ -492,12 +511,12 @@ function WallFaceBandSection({ {bandCount >= 4 && ( updateBands({ upperHeight: linearControlValueToMeters(value, unit, { - maxMeters: Math.max(0, wallHeightMeters - lowerHeight - middleHeight), + maxMeters: Math.max(0, maxWallHeight - lowerHeight - middleHeight), minMeters: 0, }), }) diff --git a/packages/nodes/src/wall/quick-measurement.ts b/packages/nodes/src/wall/quick-measurement.ts index c7f97f03c9..33ccbce474 100644 --- a/packages/nodes/src/wall/quick-measurement.ts +++ b/packages/nodes/src/wall/quick-measurement.ts @@ -10,7 +10,7 @@ import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' export function wallQuickMeasurement(node: WallNode): QuickMeasurementReport { const length = getWallCurveLength(node) - const height = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const height = resolveWallOpeningCeiling(node, useScene.getState().nodes, 0.5) const frame = getWallCurveFrameAt(node, 0.5) return { diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index 6fe6856bd9..0d28c5dfe2 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -1,6 +1,7 @@ 'use client' import { + clampWallEndHeightOffset, getWallCurveFrameAt, getWallMiterBoundaryPoints, getWallThickness, @@ -409,18 +410,38 @@ function trimOpeningRanges( childrenNodes: OpeningLike[], yBottom: number, height: number, + kind?: TrimKind, ) { - const yTop = yBottom + height + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const wallLength = Math.hypot(dx, dz) + const wallHeight = yBottom + height + const endHeightOffset = clampWallEndHeightOffset(node.endHeightOffset, wallHeight) + const slope = + kind === 'crown' && endHeightOffset && wallLength > EPS ? endHeightOffset / wallLength : 0 + return childrenNodes .filter((child) => child.type === 'door' || child.type === 'window') .flatMap((child) => { const width = child.width ?? 0 const childHeight = child.height ?? 0 const position = child.position ?? [0, 0, 0] + const childX = position[0] const childBottom = position[1] - childHeight / 2 const childTop = childBottom + childHeight - if (childTop <= yBottom + EPS || childBottom >= yTop - EPS) return [] - return [[position[0] - width / 2, position[0] + width / 2] as [number, number]] + + const xMin = childX - width / 2 + const xMax = childX + width / 2 + const slopeOffsetMin = slope * (slope >= 0 ? xMin : xMax) + const slopeOffsetMax = slope * (slope >= 0 ? xMax : xMin) + + const localYBottom = yBottom + slopeOffsetMin + const localYTop = yBottom + height + slopeOffsetMax + + if (childTop <= localYBottom + EPS || childBottom >= localYTop - EPS) { + return [] + } + return [[xMin, xMax] as [number, number]] }) } @@ -496,6 +517,34 @@ function mergeGeometries(geometries: THREE.BufferGeometry[]) { return null } +/** + * Tilts crown molding trims along the wall slope. Evaluates the linear slope + * equation `slope * localX` continuously across all vertices (including + * mitered corner extensions) to maintain coplanar trim surfaces without creases. + */ +function applyTrimSlope(geometry: THREE.BufferGeometry, node: WallNode, wallHeight: number) { + const rawOffset = node.endHeightOffset + if (!rawOffset) return + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const wallLength = Math.hypot(dx, dz) + if (wallLength < 1e-6) return + + const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined + if (!position) return + + const endHeightOffset = clampWallEndHeightOffset(rawOffset, wallHeight) + const slope = endHeightOffset / wallLength + + for (let index = 0; index < position.count; index += 1) { + const x = position.getX(index) + const y = position.getY(index) + position.setY(index, y + slope * x) + } + position.needsUpdate = true + geometry.computeVertexNormals() +} + export function buildTrimGeometry( node: WallNode, side: WallSide, @@ -506,14 +555,20 @@ export function buildTrimGeometry( ) { const wallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) const height = trim.height + const endHeightOffset = clampWallEndHeightOffset(node.endHeightOffset, wallHeight) + const minWallHeight = Math.min(wallHeight, wallHeight + endHeightOffset) + + const chairRailOffsetY = trim.offsetY ?? WALL_CHAIR_RAIL_DEFAULT.offsetY ?? 0.9 + const requiredWallHeight = kind === 'chairRail' ? chairRailOffsetY + height : height + if (minWallHeight < requiredWallHeight - EPS) { + return null + } + const yBottom = kind === 'crown' - ? Math.max(0, wallHeight - height) + ? wallHeight - height : kind === 'chairRail' - ? Math.max( - 0, - Math.min(wallHeight - height, trim.offsetY ?? WALL_CHAIR_RAIL_DEFAULT.offsetY ?? 0.9), - ) + ? Math.max(0, Math.min(minWallHeight - height, chairRailOffsetY)) : 0 const thickness = getWallThickness(node) @@ -521,8 +576,10 @@ export function buildTrimGeometry( if (inner.length < 2) return null const wallLength = Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1]) - const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height) + if (wallLength < EPS) return null + const fullRanges: Array<[number, number]> = [[0, wallLength]] + const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height, kind) const runs = subtractOpeningRanges(fullRanges, openingRanges) if (runs.length === 0) return null @@ -555,6 +612,9 @@ export function buildTrimGeometry( if (slices.length === 0) return null const merged = mergeGeometries(slices) for (const slice of slices) slice.dispose() + if (merged && kind === 'crown' && node.endHeightOffset) { + applyTrimSlope(merged, node, wallHeight) + } return merged } diff --git a/packages/nodes/src/window/definition.test.ts b/packages/nodes/src/window/definition.test.ts new file mode 100644 index 0000000000..1577c917ac --- /dev/null +++ b/packages/nodes/src/window/definition.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test' +import { windowDefinition } from './definition' +import { WindowNode } from './schema' + +describe('window resize handles on non-wall hosts', () => { + it('does not throw when host is not a standard wall node', () => { + const window = WindowNode.parse({ + id: 'window_on_dormer', + parentId: 'dormer_123', + wallId: 'dormer_123', + width: 1.5, + height: 1.2, + position: [1, 1.5, 0], + }) + + const dormerMock = { + id: 'dormer_123', + type: 'dormer', + } + + const scene = { + get: (id: string) => (id === 'dormer_123' ? dormerMock : undefined), + nodes: () => ({ [dormerMock.id]: dormerMock as any }), + } + + const handles = + typeof windowDefinition.handles === 'function' + ? windowDefinition.handles(window, scene as any) + : (windowDefinition.handles ?? []) + + for (const handle of handles as any[]) { + if (typeof handle.max === 'function') { + expect(() => handle.max(window, scene as any)).not.toThrow() + } + } + }) +}) diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index ef050209fa..421f0829ee 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -14,7 +14,7 @@ import { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' -import { readHostWallCeiling } from '../shared/wall-opening-ceiling' +import { readHostWallCeiling, readHostWallCeilingMaxWidth } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildWindowContextualDimensions } from './contextual-dimensions' import { buildWindowFloorplan } from './floorplan' @@ -34,9 +34,10 @@ const MIN_WINDOW_WIDTH = 0.3 const MOVE_HANDLE_LIFT = 0.12 function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!w.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined - if (!wall) return Number.POSITIVE_INFINITY + const hostId = w.wallId || w.parentId + if (!hostId) return Number.POSITIVE_INFINITY + const wall = scene.get(hostId as AnyNodeId) as WallNode | undefined + if (wall?.type !== 'wall' || !wall.start || !wall.end) return Number.POSITIVE_INFINITY return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } @@ -51,11 +52,44 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor { - // Roof-hosted windows clamp against the face profile (the - // wall-based limits read Infinity when wallId is unset). + // Roof-hosted windows clamp against the face profile. const roofMax = readRoofFaceWidthMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_WINDOW_WIDTH, roofMax) - return readWallLength(n, scene) + + const length = readWallLength(n, scene) + // armX accounts for window rotation (rotation[1]=π flips the window + // so its visual right points toward LOWER wall-local S, not higher). + const armX = Math.cos(n.rotation[1]) + // effectiveDirection: +1 = moving edge goes toward higher S (wall end) + // -1 = moving edge goes toward lower S (wall start) + const effectiveDirection = sign * armX + + const anchorLeft = n.position[0] - n.width / 2 + const anchorRight = n.position[0] + n.width / 2 + + // fixedEdgeS: the wall-local S of the edge that stays put. + // growSign: direction the MOVING edge travels in wall-local S. + // maxWallBound: max width before the moving edge hits the wall boundary. + let fixedEdgeS: number + let growSign: number + let maxWallBound: number + + if (effectiveDirection > 0) { + fixedEdgeS = anchorLeft + growSign = 1 + maxWallBound = length - anchorLeft + } else { + fixedEdgeS = anchorRight + growSign = -1 + maxWallBound = anchorRight + } + + const topY = n.position[1] + n.height / 2 + const hostId = n.wallId || n.parentId + return Math.max( + MIN_WINDOW_WIDTH, + readHostWallCeilingMaxWidth(hostId, scene as any, fixedEdgeS, growSign, topY, maxWallBound), + ) }, currentValue: (n) => n.width, onDrag: (node) => publishOpeningResizeGuides(node, true), @@ -96,10 +130,20 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { const roofMax = readRoofFaceHeightMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax) - // Maximum: distance from the anchored edge to the wall's allowed Y - // bounds. Top arrow caps at the wall's resolved ceiling - bottom; + // Maximum: distance from the anchored edge to the wall's allowed bounds. Top arrow caps at the wall's resolved ceiling - bottom; // bottom arrow caps at top (positive Y room above the floor). - const wallH = readHostWallCeiling(n.wallId, scene) + const hostId = n.wallId || n.parentId + + // A sloped wall's ceiling varies across the window's width. To prevent corners + // poking out above the slope, the height limit must be the lowest ceiling + // point across the entire span of the window. + const leftS = n.position[0] - n.width / 2 + const rightS = n.position[0] + n.width / 2 + const wallHLeft = readHostWallCeiling(hostId, scene as any, leftS) + const wallHRight = readHostWallCeiling(hostId, scene as any, rightS) + const wallHCenter = readHostWallCeiling(hostId, scene as any, n.position[0]) + const wallH = Math.min(wallHLeft, wallHRight, wallHCenter) + const anchored = edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2 return edge === 'top' ? Math.max(MIN_WINDOW_HEIGHT, wallH - anchored) diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 9fa359cbdd..7477907515 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -93,6 +93,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor // as a ghost and isn't committable (it needs a wall). Starts true. let onWall = true + let lastFits = true // Alt force-place (last apply's modifier) — lets `canCommit` allow an // overlapping placement, matching the 3D move. let forcePlace = false @@ -196,14 +197,19 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod nodes, }) const snappedLocalX = neighborX ?? snapToHalf(hit.localX) - const { clampedX, clampedY } = clampToWall( + const sceneReader = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + } + const { clampedX, clampedY, fits } = clampToWall( hit.wall, snappedLocalX, startLocalY, node.width, node.height, - nodes, + sceneReader, ) + lastFits = fits // One click per real position step, keyed on the SNAPPED along-wall value // so it ticks only when the window actually moves to a new cell. @@ -249,16 +255,18 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod if (!onWall || !lastValid) return false const live = useScene.getState().nodes[nodeId] as WindowNode | undefined if (live?.type !== 'window') return false - // Block on overlap UNLESS Alt force-places — same `placeable` rule as - // the 3D move + the shared `resolveOpeningPlacement`. - const collides = hasWallChildOverlap( - lastValid.parentId, - lastValid.position[0], - lastValid.position[1], - live.width, - live.height, - live.id, - ) + // Block on overlap or slope height breach UNLESS Alt force-places — same + // `placeable` rule as the 3D move + the shared `resolveOpeningPlacement`. + const collides = + !lastFits || + hasWallChildOverlap( + lastValid.parentId, + lastValid.position[0], + lastValid.position[1], + live.width, + live.height, + live.id, + ) return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 432510c2d5..e272cadfaf 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -223,16 +223,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // The window's chosen facing side. R flips it mid-placement (front ↔ back), // matching the committed-selected R flip. Initialised from the moving node. let sideOverride: WindowNode['side'] = movingWindowNode.side - let dragAnchor: { - wallId: string - rawX: number - rawY: number - startX: number - startY: number - } | null = null - // The wall the window was grabbed from. Nulled the first time the anchor - // seeds on any other host: the grab offset is then forgotten for good. - let grabWallId: string | null = movingWindowNode.parentId let lastTarget: { wallNode: WallEvent['node'] wallId: string @@ -347,22 +337,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const rawLocalX = event.localPosition[0] const rawLocalY = event.localPosition[1] - if (!dragAnchor || dragAnchor.wallId !== event.node.id) { - // Grab offset survives only on the original wall and only until the - // window anchors on any other host — after that every wall (the - // original included) centers the window under the cursor. - const preserveGrab = event.node.id === grabWallId - if (!preserveGrab) grabWallId = null - dragAnchor = { - wallId: event.node.id, - rawX: rawLocalX, - rawY: rawLocalY, - startX: preserveGrab ? original.position[0] : rawLocalX, - startY: preserveGrab ? original.position[1] : snapToHalf(rawLocalY), - } - } - const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) - const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY) // Vertical sill alignment (snap + guide) is the magnetic ("lines") // component for Y: a sibling's sill/centre/top wins over the grid when // within threshold, so it runs only when magnetic snap is on; otherwise @@ -371,17 +345,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode ? resolveSillSnap({ wall: event.node, movingId: movingWindowNode.id, - localX: targetLocalX, - localY: targetRawLocalY, + localX: rawLocalX, + localY: rawLocalY, width: movingWindowNode.width, height: movingWindowNode.height, nodes: useScene.getState().nodes, }) : null - const targetLocalY = sillSnapped ?? snapToHalf(targetRawLocalY) + const targetLocalY = sillSnapped ?? snapToHalf(rawLocalY) const localX = resolveWallSlideAlignment({ wallNode: event.node, - rawLocalX: targetLocalX, + rawLocalX, width: movingWindowNode.width, candidates: alignmentCandidates, // Along-wall alignment guides display in every snapping mode; the @@ -389,7 +363,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // component lives in `snapToHalf` (itself mode-aware). applySnap: isMagneticSnapActive(), }) - const { clampedX, clampedY } = clampToWall( + const { clampedX, clampedY, fits } = clampToWall( event.node, localX, targetLocalY, @@ -398,14 +372,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useScene.getState().nodes, ) - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingWindowNode.width, - movingWindowNode.height, - movingWindowNode.id, - ) + const valid = + fits && + !hasWallChildOverlap( + event.node.id, + clampedX, + clampedY, + movingWindowNode.width, + movingWindowNode.height, + movingWindowNode.id, + ) return { wallNode: event.node, @@ -671,7 +647,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // open floor. Revert is left to free-follow / cancel / commit. hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) - dragAnchor = null lastTarget = null lastRoofEvent = null } @@ -788,12 +763,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Valid roof hit owns the pointer for the next few frames; the floor // free-follow stands down until the cursor genuinely leaves the roof. markWallOwnedPointer() - // Wall-frame drag anchor / live transform don't apply on a roof face — - // and anchoring here counts as "elsewhere", so the original wall's grab - // offset is forgotten for good. + // Wall-frame live transform doesn't apply on a roof face. freeFollowing = false - dragAnchor = null - grabWallId = null lastTarget = null lastRoofEvent = event useLiveTransforms.getState().clear(movingWindowNode.id) @@ -910,7 +881,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // over on the same pointermove (snap to a nearby wall or free-follow). hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) - dragAnchor = null lastTarget = null lastRoofEvent = null } diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 90482adda1..4a0381a760 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -347,7 +347,7 @@ const WindowTool: React.FC = () => { width, height, }) - const { clampedX, clampedY } = clampToWall( + const { clampedX, clampedY, fits } = clampToWall( wall, localX, localY, @@ -355,7 +355,8 @@ const WindowTool: React.FC = () => { height, useScene.getState().nodes, ) - const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + const valid = + fits && !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) return { clampedX, clampedY, valid } } diff --git a/packages/nodes/src/window/window-math.test.ts b/packages/nodes/src/window/window-math.test.ts new file mode 100644 index 0000000000..c09701a7c0 --- /dev/null +++ b/packages/nodes/src/window/window-math.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' +import { WallNode } from '@pascal-app/core' +import { clampToWall } from './window-math' + +describe('clampToWall for windows', () => { + test('centers at wallLength / 2 when window is wider than wall', () => { + const wall = WallNode.parse({ + id: 'wall_short', + start: [0, 0], + end: [2, 0], + height: 3, + }) + const nodes = { [wall.id]: wall } + const result = clampToWall(wall, 1, 1.5, 3, 1.2, nodes) + expect(result.clampedX).toBe(1) // wallLength / 2 = 2 / 2 = 1 + expect(result.clampedY).toBe(0.6) // height / 2 = 1.2 / 2 = 0.6 + expect(result.fits).toBe(false) + }) + + test('clamps within horizontal bounds on a standard wall', () => { + const wall = WallNode.parse({ + id: 'wall_standard', + start: [0, 0], + end: [5, 0], + height: 3, + }) + const nodes = { [wall.id]: wall } + const leftClamp = clampToWall(wall, 0.1, 1.5, 1, 1.2, nodes) + expect(leftClamp.clampedX).toBe(0.5) + expect(leftClamp.clampedY).toBe(1.5) + expect(leftClamp.fits).toBe(true) + + const rightClamp = clampToWall(wall, 4.9, 1.5, 1, 1.2, nodes) + expect(rightClamp.clampedX).toBe(4.5) + expect(rightClamp.clampedY).toBe(1.5) + expect(rightClamp.fits).toBe(true) + }) + + test('clamps Y against sloped ceiling while preserving sill height', () => { + const wall = WallNode.parse({ + id: 'wall_sloped', + start: [0, 0], + end: [10, 0], + height: 3, + endHeightOffset: -1.5, // Slopes from 3m down to 1.5m + }) + const nodes = { [wall.id]: wall } + + // At X = 8, window span is [7.5, 8.5]. + // Ceiling at lowest right edge (t = 8.5/10) is 3 - 1.5 * 0.85 = 1.725m. + // Window ceiling top is clamped to 1.725m -> clamped Y = 1.725 - 0.5 = 1.225m. + const result = clampToWall(wall, 8, 2.0, 1.0, 1.0, nodes) + expect(result.fits).toBe(true) + expect(result.clampedX).toBe(8) + expect(result.clampedY).toBeCloseTo(1.225, 3) + + // Window of height 2.0m cannot fit at X = 9 (ceiling only ~1.65m) + // Wall slope = -0.15. Required ceiling >= 2.0m. + // Exact analytical boundary: (2.0 - 3.0) / -0.15 - 0.5 = 6.666 - 0.5 = 6.166m + const slideResult = clampToWall(wall, 9, 1.0, 1.0, 2.0, nodes) + expect(slideResult.fits).toBe(true) + expect(slideResult.clampedX).toBeCloseTo(6.1667, 3) + }) +}) diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 08ff4cb329..dd74732446 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,5 +1,9 @@ import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' -import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' +import { + readHostWallCeiling, + toWallCeilingSceneReader, + type WallCeilingSceneReader, +} from '../shared/wall-opening-ceiling' /** * Default sill height (metres from the floor to the BOTTOM of a window) for a @@ -36,11 +40,14 @@ export function wallLocalToWorld( } /** - * Clamps window center position so it stays fully within wall bounds. The Y - * ceiling is the wall's RESOLVED top (storey plane for plane-bound walls, - * stored height for explicit ones, minus the elected slab base) — `nodes` is - * required because a plane-bound wall's top lives on its level, not on the - * wall record. + * Clamps window center (localX, localY) within wall bounds. + * + * Y is bounded to keep the window's bottom above 0 (floor level) AND its top + * below the wall's effective ceiling, sampled at both edges of the opening + * span (left and right). The ceiling is the wall's RESOLVED top (storey plane + * for plane-bound walls, stored height for explicit ones, minus the elected + * slab base) — `nodes` is required because a plane-bound wall's top lives on + * its level, not on the wall record. */ export function clampToWall( wallNode: WallNode, @@ -48,16 +55,52 @@ export function clampToWall( localY: number, width: number, height: number, - nodes: Readonly>, -): { clampedX: number; clampedY: number } { + sceneOrNodes: Readonly> | WallCeilingSceneReader, +): { clampedX: number; clampedY: number; fits: boolean } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) - const wallHeight = resolveWallOpeningCeiling(wallNode, nodes) + const wallLength = Math.hypot(dx, dz) - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) - const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) - return { clampedX, clampedY } + const minX = width / 2 + const maxX = wallLength - width / 2 + + if (width > wallLength) { + return { clampedX: wallLength / 2, clampedY: height / 2, fits: false } + } + + const sceneReader = toWallCeilingSceneReader(sceneOrNodes) + const startCeiling = readHostWallCeiling(wallNode.id, sceneReader, 0) + const endCeiling = readHostWallCeiling(wallNode.id, sceneReader, wallLength) + const slope = (endCeiling - startCeiling) / wallLength + + let fitMinX = minX + let fitMaxX = maxX + + if (Math.abs(slope) < 1e-6) { + if (startCeiling < height - 1e-4) { + return { clampedX: Math.max(minX, Math.min(maxX, localX)), clampedY: height / 2, fits: false } + } + } else if (slope > 0) { + // Upward slope: lowest ceiling point for window span is at left edge (x - width/2) + const minCenterForHeight = (height - startCeiling) / slope + width / 2 + fitMinX = Math.max(minX, minCenterForHeight) + } else { + // Downward slope: lowest ceiling point for window span is at right edge (x + width/2) + const maxCenterForHeight = (height - startCeiling) / slope - width / 2 + fitMaxX = Math.min(maxX, maxCenterForHeight) + } + + if (fitMinX > fitMaxX + 1e-4) { + return { clampedX: Math.max(minX, Math.min(maxX, localX)), clampedY: height / 2, fits: false } + } + + const clampedX = Math.max(fitMinX, Math.min(fitMaxX, localX)) + const leftCeil = startCeiling + slope * (clampedX - width / 2) + const rightCeil = startCeiling + slope * (clampedX + width / 2) + const ceilingAtX = Math.min(leftCeil, rightCeil) + + const clampedY = Math.max(height / 2, Math.min(ceilingAtX - height / 2, localY)) + return { clampedX, clampedY, fits: true } } /** diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 081f4f1ac3..7ec49f3a4f 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -238,6 +238,7 @@ export const WallCutout = () => { wallNode, getWallPlaneTop(wallNode, levelId, sceneState.nodes), support.elevation, + 0, ) const shouldSelectionHighlight = isSelectionHighlighted && !getWallFaceBandConfig(wallNode, effectiveWallHeight).enabled diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 49ea7a9c94..8a2dbd5fe7 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + clampWallEndHeightOffset, DEFAULT_LEVEL_HEIGHT, type DoorNode, getAdjacentWallIds, @@ -452,9 +453,9 @@ function getWallBandSplitPlanes(wall: WallNode, effectiveWallHeight: number): nu const planes = [bands.lowerTop] if (bands.count >= 3) planes.push(bands.middleTop) if (bands.count >= 4) planes.push(bands.upperTop) + const maxWallHeight = effectiveWallHeight + Math.max(0, wall.endHeightOffset ?? 0) return planes.filter( - (plane) => - plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON, + (plane) => plane > WALL_BAND_SPLIT_EPSILON && plane < maxWallHeight - WALL_BAND_SPLIT_EPSILON, ) } @@ -961,6 +962,39 @@ function mergeWallTerrainFill( return merged } +/** + * Tilts a wall's top edge along its length so the `end` side sits taller (or + * shorter) than the `start` side — e.g. a knee wall following a single-pitch + * roof slope — instead of requiring a non-rectangular footprint. Only + * vertices sitting exactly at the flat extruded top (`topY`) move. + * + * Evaluates the linear plane equation `slope * localX` continuously across + * all top vertices (including mitered corner vertices extending beyond [0, L]) + * so the extruded top face remains a single coplanar surface without corner + * creases or triangulation folds. + */ +function applyWallEndHeightSlope( + geometry: THREE.BufferGeometry, + wallNode: WallNode, + wallLength: number, + topY: number, + bodyHeight: number, +): void { + const rawOffset = wallNode.endHeightOffset + if (!rawOffset || wallLength < 1e-9) { + return + } + const endHeightOffset = clampWallEndHeightOffset(rawOffset, bodyHeight) + const slope = endHeightOffset / wallLength + const position = geometry.getAttribute('position') as THREE.BufferAttribute + + for (let i = 0; i < position.count; i++) { + if (Math.abs(position.getY(i) - topY) > 1e-4) continue + position.setY(i, topY + slope * position.getX(i)) + } + position.needsUpdate = true +} + export function generateExtrudedWall( wallNode: WallNode, childrenNodes: AnyNode[], @@ -975,7 +1009,7 @@ export function generateExtrudedWall( ): THREE.BufferGeometry { const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } - const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation) + const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation, 0) const effectiveWallHeight = topElevation - slabElevation const effectiveBaseElevation = Math.min(baseElevation, slabElevation) const localBottom = effectiveBaseElevation - slabElevation @@ -1044,9 +1078,9 @@ export function generateExtrudedWall( bevelEnabled: false, }) - // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) + applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height, effectiveWallHeight) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry)