From ddc08f11445ad8fd857b14019ce877f230251ef5 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Wed, 12 Aug 2026 10:54:37 +0200 Subject: [PATCH 1/2] feat(wall): add endHeightOffset for sloped top edge --- packages/core/src/schema/nodes/wall.ts | 5 ++++ packages/nodes/src/wall/panel.tsx | 19 ++++++++++++++ .../viewer/src/systems/wall/wall-system.tsx | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index d4afd49ffd..6dc74970c7 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -150,6 +150,10 @@ 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. + endHeightOffset: z.number().optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), @@ -174,6 +178,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 diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 0841857d8e..cfb22f9a54 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -185,12 +185,14 @@ export default function WallPanel() { const followsTerrain = node.fillToTerrain === true 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) @@ -237,6 +239,23 @@ export default function WallPanel() { unit={unitLabel} value={Math.round(displayHeight * 100) / 100} /> + + handleUpdate({ + endHeightOffset: linearControlValueToMeters(v, unit, { + maxMeters: 3, + minMeters: -3, + }), + }) + } + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayEndHeightOffset * 100) / 100} + />
Base
diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index f84f48c0ff..93993012a2 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -933,6 +933,31 @@ 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; a + * vertex's local X (0 at `start`, `wallLength` at `end`) linearly + * interpolates the offset from 0 to `wallNode.endHeightOffset`. + */ +function applyWallEndHeightSlope( + geometry: THREE.BufferGeometry, + wallNode: WallNode, + wallLength: number, + topY: number, +): void { + const endHeightOffset = wallNode.endHeightOffset + if (!endHeightOffset || wallLength < 1e-9) return + 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 + const t = THREE.MathUtils.clamp(position.getX(i) / wallLength, 0, 1) + position.setY(i, topY + endHeightOffset * t) + } + position.needsUpdate = true +} + export function generateExtrudedWall( wallNode: WallNode, childrenNodes: AnyNode[], @@ -1019,6 +1044,7 @@ export function generateExtrudedWall( // 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) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) From f9213c2bfdc360f89759ba0a68be854dc62e8886 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Wed, 12 Aug 2026 19:08:53 +0200 Subject: [PATCH 2/2] fix(wall): enforce strict zero minimum for endHeightOffset Fixes a bug where negative offset values caused wall geometry to self-intersect and drop below the base. The value is now strictly clamped to 0 at the Zod schema, UI, and geometry generation layers. --- packages/core/src/schema/nodes/wall.ts | 3 ++- packages/nodes/src/wall/panel.tsx | 17 ++++++++++------- .../viewer/src/systems/wall/wall-system.tsx | 9 +++++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 6dc74970c7..07cb8e4c86 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -153,7 +153,8 @@ export const WallNode = BaseNode.extend({ // 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. - endHeightOffset: z.number().optional(), + /** Height offset at the end point (default 0). Must be non-negative. */ + endHeightOffset: z.number().min(0).optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index cfb22f9a54..5fa05b9ca5 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -242,15 +242,18 @@ export default function WallPanel() { + min={0} + onChange={(v) => { handleUpdate({ - endHeightOffset: linearControlValueToMeters(v, unit, { - maxMeters: 3, - minMeters: -3, - }), + endHeightOffset: Math.max( + 0, + linearControlValueToMeters(v, unit, { + maxMeters: 3, + minMeters: 0, + }), + ), }) - } + }} precision={2} step={0.1} unit={unitLabel} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 93993012a2..231239fde2 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -947,8 +947,13 @@ function applyWallEndHeightSlope( wallLength: number, topY: number, ): void { - const endHeightOffset = wallNode.endHeightOffset - if (!endHeightOffset || wallLength < 1e-9) return + const rawOffset = wallNode.endHeightOffset + console.log('[applyWallEndHeightSlope] called', { rawOffset, wallLength, topY, height: wallNode.height }) + if (!rawOffset || wallLength < 1e-9) { + console.log('[applyWallEndHeightSlope] early return', { rawOffset, wallLength }) + return + } + const endHeightOffset = Math.max(0, rawOffset) 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