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..ea3cadd63d 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?: number, ): 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..2661e54807 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/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 3594b0c6cc..b7fc649f86 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -445,9 +445,13 @@ 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 +1211,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/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index d4afd49ffd..3c0f16e0aa 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -150,6 +150,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(), @@ -174,6 +179,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 @@ -208,10 +214,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 @@ -233,7 +240,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..882eb3f87a 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -62,7 +62,9 @@ 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.ts b/packages/core/src/systems/wall/wall-top.ts index 2bbf475da7..2352f6a89d 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -24,13 +24,26 @@ export const MIN_WALL_HEIGHT = 0.5 * Returns the top in level-local Y (same frame as `electedBase`). */ 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 && t !== undefined) { + const bodyHeight = Math.max(0.01, top - electedBase) + const minEndHeight = 0.01 + const clampedOffset = Math.max(wall.endHeightOffset, -(bodyHeight - minEndHeight)) + top += clampedOffset * t + } + return top } /** @@ -48,9 +61,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..3eebbe0d1b 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/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 9c7025d05e..9df617330f 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,8 +35,9 @@ 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 + const hostId = door.wallId || door.parentId + if (!hostId) return Number.POSITIVE_INFINITY + const wall = scene.get(hostId as AnyNodeId) as WallNode | undefined if (!wall) return Number.POSITIVE_INFINITY return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } @@ -54,11 +55,43 @@ 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 +134,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..7eb4456d36 --- /dev/null +++ b/packages/nodes/src/door/door-math.test.ts @@ -0,0 +1,57 @@ +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 + }) + 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) + + // At X = 9 (near end), ceiling is ~1.2m -> 2.1m door cannot fit + // It should slide left toward the taller start until it fits + const endResult = clampToWall(wall, 9, 1, 2.1, nodes) + expect(endResult.fits).toBe(true) + expect(endResult.clampedX).toBeLessThan(5) + }) +}) diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index b5517c9dcf..90c9a67594 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,5 @@ -import type { WallNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' +import { readHostWallCeiling, type WallCeilingSceneReader } from '../shared/wall-opening-ceiling' /** * Keep the door handle at the same relative height when the door is resized: @@ -46,14 +47,56 @@ 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 + + if (width > wallLength) { + return { clampedX: wallLength / 2, clampedY: height / 2, fits: false } + } + + const scene: WallCeilingSceneReader = + typeof (sceneOrNodes as WallCeilingSceneReader).nodes === 'function' + ? (sceneOrNodes as WallCeilingSceneReader) + : { + get: (id: AnyNodeId) => (sceneOrNodes as Readonly>)[id], + nodes: () => sceneOrNodes as Readonly>, + } + + function checkFits(testX: number) { + const leftHeight = readHostWallCeiling(wallNode.id, scene, testX - width / 2) + const rightHeight = readHostWallCeiling(wallNode.id, scene, testX + width / 2) + return leftHeight >= height - 1e-4 && rightHeight >= height - 1e-4 + } + + let clampedX = Math.max(minX, Math.min(maxX, localX)) const clampedY = height / 2 // Doors always sit at floor level - return { clampedX, clampedY } + let fits = checkFits(clampedX) + + if (!fits) { + // Try sliding left/right to find a span where the sloped ceiling fits the door + const step = 0.1 + const maxSearch = wallLength / 2 + for (let offset = step; offset <= maxSearch; offset += step) { + if (clampedX - offset >= minX && checkFits(clampedX - offset)) { + clampedX -= offset + fits = true + break + } + if (clampedX + offset <= maxX && checkFits(clampedX + offset)) { + clampedX += offset + fits = true + break + } + } + } + + return { clampedX, clampedY, fits } } // 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..dc73f5b6dd 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,12 @@ 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,10 +260,12 @@ 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( + // 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], diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index e545dfb38c..8239b27de5 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -306,14 +306,19 @@ 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( + const valid = fits && !hasWallChildOverlap( event.node.id, clampedX, clampedY, diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index bb13193390..6e002d4591 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -289,8 +289,12 @@ 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.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index 23f58da7eb..31a24a0021 100644 --- a/packages/nodes/src/shared/wall-opening-ceiling.ts +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -30,8 +30,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 +43,60 @@ 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 (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) return maxLength + + const anchorCeiling = readHostWallCeiling(wallId, scene, anchorS) + if (anchorCeiling < topY - 1e-4) { + return 0 + } + + // Fast check: if the extreme end is also valid, the entire linear span fits + const endS = anchorS + growSign * maxLength + if (readHostWallCeiling(wallId, scene, endS) >= topY - 1e-4) { + return maxLength + } + + // Binary search for the intersection + let low = 0 + let high = maxLength + for (let i = 0; i < 15; i++) { + const mid = (low + high) / 2 + const testS = anchorS + growSign * mid + if (readHostWallCeiling(wallId, scene, testS) >= topY - 1e-4) { + low = mid + } else { + high = mid + } + } + return low } 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 0d2dc398e6..ddcaf440df 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,24 @@ export default function WallPanel() { value={Math.round(displayHeight * 100) / 100} /> )} + { + const minMeters = -(wallHeightMeters - 0.01) + handleUpdate({ + endHeightOffset: linearControlValueToMeters(v, unit, { + maxMeters: 3, + minMeters, + }), + }) + }} + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayEndHeightOffset * 100) / 100} + />
Bottom
@@ -425,6 +445,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 +475,12 @@ function WallFaceBandSection({ {bandCount >= 2 && ( updateBands({ lowerHeight: linearControlValueToMeters(value, unit, { - maxMeters: wallHeightMeters, + maxMeters: maxWallHeight, minMeters: 0, }), }) @@ -473,12 +494,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 +513,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..1d3d0a83b7 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -409,18 +409,43 @@ 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 minEndHeight = 0.01 + const endHeightOffset = node.endHeightOffset + ? Math.max(node.endHeightOffset, -(wallHeight - minEndHeight)) + : 0 + 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 +521,35 @@ 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 minEndHeight = 0.01 + const endHeightOffset = Math.max(rawOffset, -(wallHeight - minEndHeight)) + 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 +560,22 @@ export function buildTrimGeometry( ) { const wallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) const height = trim.height + const minEndHeight = 0.01 + const rawOffset = node.endHeightOffset ?? 0 + const endHeightOffset = Math.max(rawOffset, -(wallHeight - minEndHeight)) + 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 +583,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 +619,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.ts b/packages/nodes/src/window/definition.ts index ef050209fa..ea73b7b0f8 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,8 +34,9 @@ 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 + const hostId = w.wallId || w.parentId + if (!hostId) return Number.POSITIVE_INFINITY + const wall = scene.get(hostId as AnyNodeId) as WallNode | undefined if (!wall) return Number.POSITIVE_INFINITY return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } @@ -51,11 +52,41 @@ 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 +127,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..089e9d54de 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,9 +255,11 @@ 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( + // 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], diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 115ac2536a..a3e1307053 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -353,7 +353,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, @@ -362,7 +362,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useScene.getState().nodes, ) - const valid = !hasWallChildOverlap( + const valid = fits && !hasWallChildOverlap( event.node.id, clampedX, clampedY, diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 3987bfaadf..62aba46712 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -346,7 +346,7 @@ const WindowTool: React.FC = () => { width, height, }) - const { clampedX, clampedY } = clampToWall( + const { clampedX, clampedY, fits } = clampToWall( wall, localX, localY, @@ -354,7 +354,7 @@ 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..c74f3d75aa --- /dev/null +++ b/packages/nodes/src/window/window-math.test.ts @@ -0,0 +1,57 @@ +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) + }) +}) diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 08ff4cb329..de2d8589fc 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,5 +1,5 @@ import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' -import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' +import { readHostWallCeiling, type WallCeilingSceneReader } from '../shared/wall-opening-ceiling' /** * Default sill height (metres from the floor to the BOTTOM of a window) for a @@ -36,11 +36,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 +51,65 @@ 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: WallCeilingSceneReader = + typeof (sceneOrNodes as WallCeilingSceneReader).nodes === 'function' + ? (sceneOrNodes as WallCeilingSceneReader) + : { + get: (id: AnyNodeId) => (sceneOrNodes as Readonly>)[id], + nodes: () => sceneOrNodes as Readonly>, + } + + function getCeilingAt(testX: number) { + const leftHeight = readHostWallCeiling(wallNode.id, sceneReader, testX - width / 2) + const rightHeight = readHostWallCeiling(wallNode.id, sceneReader, testX + width / 2) + return Math.min(leftHeight, rightHeight) + } + + let clampedX = Math.max(minX, Math.min(maxX, localX)) + let ceilingAtX = getCeilingAt(clampedX) + let fits = ceilingAtX >= height - 1e-4 + + if (!fits) { + const step = 0.1 + const maxSearch = wallLength / 2 + for (let offset = step; offset <= maxSearch; offset += step) { + if (clampedX - offset >= minX) { + const ceiling = getCeilingAt(clampedX - offset) + if (ceiling >= height - 1e-4) { + clampedX -= offset + ceilingAtX = ceiling + fits = true + break + } + } + if (clampedX + offset <= maxX) { + const ceiling = getCeilingAt(clampedX + offset) + if (ceiling >= height - 1e-4) { + clampedX += offset + ceilingAtX = ceiling + fits = true + break + } + } + } + } + + const clampedY = Math.max(height / 2, Math.min(ceilingAtX - height / 2, localY)) + + return { clampedX, clampedY, fits } } /** diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index f84f48c0ff..b4f26492ee 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -464,9 +464,10 @@ 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 > WALL_BAND_SPLIT_EPSILON && plane < maxWallHeight - WALL_BAND_SPLIT_EPSILON, ) } @@ -933,6 +934,40 @@ 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 minEndHeight = 0.01 + const endHeightOffset = Math.max(rawOffset, -(bodyHeight - minEndHeight)) + 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[], @@ -1016,9 +1051,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)