From cd7f7032619d9b55ceb9f9ce069251296d6160e9 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 4 Aug 2026 19:22:56 -0400 Subject: [PATCH] fix(editor): scope slab room paint to the clicked slab's level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Space polygons are per-level footprints and stacked storeys share a footprint, so the upper slab's centroid sits inside the ground floor's space polygon. Room-scope paint on a ground-floor slab therefore also repainted the slab directly above it. Every other branch in resolvePaintScopeTargets already gates on space.levelId (resolveWallPaintSpace, connectedExteriorBoundaries, wallTargetsForBoundaries); the slab branch was the one that did not. Gate both the space lookup and the fan-out, matching how space-detection itself defines level membership (node.parentId === levelId). The existing slab fixture omitted parentId, which is why no test caught this — real slabs always have one, from both the slab tool and reconciliation. Co-Authored-By: Claude Opus 5 --- packages/editor/src/lib/paint-scope.test.ts | 60 ++++++++++++++++++++- packages/editor/src/lib/paint-scope.ts | 10 +++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/lib/paint-scope.test.ts b/packages/editor/src/lib/paint-scope.test.ts index 71ec48498..768fdbe11 100644 --- a/packages/editor/src/lib/paint-scope.test.ts +++ b/packages/editor/src/lib/paint-scope.test.ts @@ -81,8 +81,8 @@ describe('paintScopeLabel', () => { function item(id: string, assetId: string): ItemNode { return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode } -function slab(id: string, polygon: Array<[number, number]>): SlabNode { - return { id, type: 'slab', polygon } as unknown as SlabNode +function slab(id: string, polygon: Array<[number, number]>, levelId = 'l1'): SlabNode { + return { id, type: 'slab', polygon, parentId: levelId } as unknown as SlabNode } function wall( id: string, @@ -451,4 +451,60 @@ describe('resolvePaintScopeTargets', () => { }) expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface']) }) + + it('slab room stops at the level boundary', () => { + // Stacked storeys share a footprint, so the upper slab's centroid sits inside + // the ground floor's space polygon. Painting downstairs must not reach it. + const ground = slab( + 'ground', + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ], + 'l1', + ) + const upstairs = slab( + 'upstairs', + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ], + 'l2', + ) + const footprint: Array<[number, number]> = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ] + const result = resolve({ + node: ground, + role: 'surface', + scope: 'room', + nodes: [ground, upstairs], + spaces: [ + { + id: 's1', + levelId: 'l1', + polygon: footprint, + wallIds: [], + boundaryFaces: [], + isExterior: false, + }, + { + id: 's2', + levelId: 'l2', + polygon: footprint, + wallIds: [], + boundaryFaces: [], + isExterior: false, + }, + ], + }) + expect(keys(result)).toEqual(['ground:surface']) + }) }) diff --git a/packages/editor/src/lib/paint-scope.ts b/packages/editor/src/lib/paint-scope.ts index 7feacf03c..eb7c89392 100644 --- a/packages/editor/src/lib/paint-scope.ts +++ b/packages/editor/src/lib/paint-scope.ts @@ -355,13 +355,19 @@ export function resolvePaintScopeTargets(args: { if (node.type === 'slab' && scope === 'room') { const centroid = polygonCentroid((node as SlabNode).polygon) if (!centroid) return single - const space = Object.values(spaces).find((candidate) => - pointInPolygon2D(centroid, candidate.polygon), + // Space polygons are per-level footprints, and stacked storeys share a + // footprint — so the level has to gate both the space lookup and the fan-out + // or one click paints the floor above too. + const levelId = node.parentId ?? resolveLevelId(node, nodes) + if (!levelId) return single + const space = Object.values(spaces).find( + (candidate) => candidate.levelId === levelId && pointInPolygon2D(centroid, candidate.polygon), ) if (!space) return single return Object.values(nodes) .filter((other) => { if (other.type !== 'slab') return false + if ((other.parentId ?? resolveLevelId(other, nodes)) !== levelId) return false const otherCentroid = polygonCentroid((other as SlabNode).polygon) return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon) })