Skip to content
Closed
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
3731eb3
Add roof surface placement support for items
sudhir9297 May 18, 2026
ed53bc2
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
fd8e02c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
7c1e383
fixed conflict
sudhir9297 May 20, 2026
b3377da
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
f177a65
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
9af7491
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
fd27524
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 27, 2026
b516298
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 28, 2026
ebfc8ce
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 3, 2026
b7b313b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
b2ad645
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
bffdb4a
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 8, 2026
ee7b10c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 9, 2026
7d4b474
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 10, 2026
3a3318c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 13, 2026
26df69f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 17, 2026
5376e07
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 22, 2026
d2204aa
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 23, 2026
f2a5186
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 29, 2026
5841052
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 1, 2026
a6acaa3
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 8, 2026
e0fec5b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 10, 2026
7fa9276
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 13, 2026
c3ff9d6
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 14, 2026
00d84d5
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 19, 2026
2c2dabc
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 22, 2026
29f914f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 22, 2026
1cbf910
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 23, 2026
8f59da7
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 24, 2026
650abd6
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 27, 2026
1f8dc8b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 28, 2026
b408c80
fix(editor): split crossing walls and room surfaces
sudhir9297 Jul 28, 2026
a93cab1
fix(core): preserve room surfaces when splitting
sudhir9297 Jul 28, 2026
fb37342
fix(core): preserve room surfaces when merging
sudhir9297 Jul 28, 2026
6943a6c
fix(editor): handle curved and extreme room boundaries
sudhir9297 Jul 28, 2026
b010bb1
fix room surface deletion and wall draft completion
sudhir9297 Jul 28, 2026
7ed39c2
fix: reconcile surfaces across wall topology changes
sudhir9297 Jul 29, 2026
e5f83c1
perf(core): reconcile rooms with local topology index
sudhir9297 Jul 29, 2026
9031917
refactor wall topology into core
sudhir9297 Jul 29, 2026
7e6bd1d
fix wall topology review regressions
sudhir9297 Jul 29, 2026
092ab81
fix: prevent duplicate 2d ceiling creation
sudhir9297 Jul 30, 2026
58808f9
fix scene refresh framing and wall junction snapping
sudhir9297 Jul 30, 2026
f018494
fix space sync boundary and snapping
sudhir9297 Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
440 changes: 440 additions & 0 deletions packages/core/src/lib/space-detection.test.ts

Large diffs are not rendered by default.

548 changes: 491 additions & 57 deletions packages/core/src/lib/space-detection.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/core/src/store/history-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ function beginSceneCommitTransaction(): void {
sceneCommitTransactionDepth += 1
}

export function isSceneCommitTransactionActive(): boolean {
return sceneCommitTransactionDepth > 0
}

function pendingSceneCommitIsNoOp(): boolean {
return Boolean(
pendingSceneCommit &&
Expand Down
17 changes: 12 additions & 5 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ import {
chainEndJoinsExistingWall,
createWallOnCurrentLevel,
isSegmentLongEnough,
shouldStopWallDraftAfterCommit,
snapWallDraftPoint,
snapWallDraftPointDetailed,
snapPointToGrid as snapWallPointToGrid,
Expand Down Expand Up @@ -9764,11 +9765,17 @@ export function FloorplanPanel({
setCursorPoint(null)
return
}
} else if (!(viewIs2DOnly || publishedNextStart)) {
// Split view: the 3D tool owns both the commit and the continuation
// decision, and it clears the published chain start whenever it stops
// drafting (room close, T-junction, single). Mirror that here instead
// of chaining the 2D draft from a dead point.
} else if (
shouldStopWallDraftAfterCommit({
locallyCreatedWall: createdWall,
publishedNextStart,
})
) {
// The mounted wall tool owns both the commit and the continuation
// decision in split and 2D-only views. It clears the published chain
// start whenever it stops drafting (loop close, room close,
// T-junction, single). Mirror that here instead of chaining the 2D
// draft from a dead point.
Comment thread
cursor[bot] marked this conversation as resolved.
clearWallPlacementDraft()
setCursorPoint(null)
return
Expand Down
289 changes: 288 additions & 1 deletion packages/editor/src/components/tools/wall/wall-drafting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
CeilingNode,
DoorNode as DoorSchema,
detectSpacesForLevel,
getWallCurveFrameAt,
getWallCurveLength,
initSpaceDetectionSync,
isCurvedWall,
runAsSingleSceneHistoryStep,
SlabNode,
useScene,
type WallNode,
WallNode as WallSchema,
Expand All @@ -14,6 +21,7 @@ import useInteractionScope from '../../../store/use-interaction-scope'
import {
createWallOnCurrentLevel,
resolveEndpointWallSplit,
shouldStopWallDraftAfterCommit,
snapWallDraftPointDetailed,
} from './wall-drafting'
import type { WallPlanPoint } from './wall-snap-geometry'
Expand All @@ -29,6 +37,35 @@ if (typeof globalThis.requestAnimationFrame === 'undefined') {

const LEVEL_ID = 'level_test' as AnyNodeId

describe('shouldStopWallDraftAfterCommit', () => {
test('stops the 2D draft when the mounted wall tool closes the chain', () => {
expect(
shouldStopWallDraftAfterCommit({
locallyCreatedWall: null,
publishedNextStart: null,
}),
).toBe(true)
})

test('continues from a next start published by the mounted wall tool', () => {
expect(
shouldStopWallDraftAfterCommit({
locallyCreatedWall: null,
publishedNextStart: [4, 0],
}),
).toBe(false)
})

test('lets the 2D fallback committer decide from its created wall', () => {
expect(
shouldStopWallDraftAfterCommit({
locallyCreatedWall: makeWall([0, 0], [4, 0], 'wall_created'),
publishedNextStart: null,
}),
).toBe(false)
})
})

function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNode {
return {
...WallSchema.parse({ start, end, name: id }),
Expand All @@ -49,7 +86,12 @@ function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) {
parentId: null,
visible: true,
metadata: {},
children: walls.map((wall) => wall.id),
children: [
...walls.map((wall) => wall.id),
...extraNodes
.filter((node) => node.parentId === LEVEL_ID)
.map((node) => node.id as AnyNodeId),
],
level: 0,
} as AnyNode,
],
Expand Down Expand Up @@ -177,6 +219,251 @@ describe('createWallOnCurrentLevel', () => {
expect(created).not.toBeNull()
expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
})

test('a divider commit splits the room slab and ceiling into two scene nodes', () => {
const walls = [
makeWall([0, 0], [4, 0], 'wall_bottom'),
makeWall([4, 0], [4, 3], 'wall_right'),
makeWall([4, 3], [0, 3], 'wall_top'),
makeWall([0, 3], [0, 0], 'wall_left'),
]
const slab = SlabNode.parse({
id: 'slab_main',
parentId: LEVEL_ID,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
autoFromWalls: true,
})
const ceiling = CeilingNode.parse({
id: 'ceiling_main',
parentId: LEVEL_ID,
polygon: slab.polygon,
autoFromWalls: true,
})
seedLevel(walls, [slab, ceiling])
const editorState = {
spaces: {},
setSpaces(spaces: Record<string, unknown>) {
editorState.spaces = spaces
},
}
const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState })

try {
const created = createWallOnCurrentLevel([2, 0], [2, 3])

expect(created).not.toBeNull()
const nodes = Object.values(useScene.getState().nodes)
const postCommitWalls = nodes.filter((node): node is WallNode => node.type === 'wall')
const { roomPolygons } = detectSpacesForLevel(String(LEVEL_ID), postCommitWalls)
const slabs = nodes.filter((node) => node.type === 'slab').map((node) => SlabNode.parse(node))
const ceilings = nodes
.filter((node) => node.type === 'ceiling')
.map((node) => CeilingNode.parse(node))

expect(roomPolygons).toHaveLength(2)
expect(slabs).toHaveLength(2)
expect(ceilings).toHaveLength(2)
expect(slabs.every((surface) => surface.autoFromWalls)).toBe(true)
expect(ceilings.every((surface) => surface.autoFromWalls)).toBe(true)
const committedLevel = useScene.getState().nodes[LEVEL_ID]
expect(committedLevel?.type).toBe('level')
if (committedLevel?.type !== 'level') return
const treeChildren = committedLevel.children.map((id) => useScene.getState().nodes[id])
expect(treeChildren.filter((node) => node?.type === 'slab')).toHaveLength(2)
expect(treeChildren.filter((node) => node?.type === 'ceiling')).toHaveLength(2)
} finally {
unsubscribe()
}
})

test('repeated divider deletion rejoins its split boundary walls', () => {
const walls = [
makeWall([0, 0], [4, 0], 'wall_bottom'),
makeWall([4, 0], [4, 3], 'wall_right'),
makeWall([4, 3], [0, 3], 'wall_top'),
makeWall([0, 3], [0, 0], 'wall_left'),
]
seedLevel(walls)

const divider = createWallOnCurrentLevel([2, 0], [2, 3])

expect(divider).not.toBeNull()
expect(levelWalls()).toHaveLength(7)

useScene.getState().deleteNode(divider!.id as AnyNodeId)

expect(levelWalls()).toHaveLength(4)
expect(
levelWalls().some(
(wall) =>
(wall.start[0] === 0 && wall.end[0] === 4) || (wall.start[0] === 4 && wall.end[0] === 0),
),
).toBe(true)

const secondDivider = createWallOnCurrentLevel([0, 1.5], [4, 1.5])

expect(secondDivider).not.toBeNull()
expect(levelWalls()).toHaveLength(7)

useScene.getState().deleteNode(secondDivider!.id as AnyNodeId)

expect(levelWalls()).toHaveLength(4)
})

test('a divider ending on a curved wall splits the curve and the room surfaces', () => {
const curvedWall = {
...makeWall([0, 0], [4, 0], 'wall_curve'),
curveOffset: 1,
}
const curveMidpoint = getWallCurveFrameAt(curvedWall, 0.5).point
const walls = [
curvedWall,
makeWall([4, 0], [4, 3], 'wall_right'),
makeWall([4, 3], [0, 3], 'wall_top'),
makeWall([0, 3], [0, 0], 'wall_left'),
]
seedLevel(walls)
const editorState = {
spaces: {},
setSpaces(spaces: Record<string, unknown>) {
editorState.spaces = spaces
},
}
const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState })

try {
const divider = createWallOnCurrentLevel([curveMidpoint.x, curveMidpoint.y], [2, 3])
const nodes = Object.values(useScene.getState().nodes)
const postCommitWalls = nodes.filter((node): node is WallNode => node.type === 'wall')
const curvedSegments = postCommitWalls.filter(isCurvedWall)
const { roomPolygons } = detectSpacesForLevel(String(LEVEL_ID), postCommitWalls)

expect(divider).not.toBeNull()
expect(useScene.getState().nodes[curvedWall.id]).toBeUndefined()
expect(curvedSegments).toHaveLength(2)
expect(
curvedSegments.every(
(wall) =>
(wall.start[0] === curveMidpoint.x && wall.start[1] === curveMidpoint.y) ||
(wall.end[0] === curveMidpoint.x && wall.end[1] === curveMidpoint.y),
),
).toBe(true)
const firstCurve = curvedSegments.find(
(wall) => wall.start[0] === curvedWall.start[0] && wall.start[1] === curvedWall.start[1],
)
const secondCurve = curvedSegments.find(
(wall) => wall.end[0] === curvedWall.end[0] && wall.end[1] === curvedWall.end[1],
)
const originalQuarter = getWallCurveFrameAt(curvedWall, 0.25).point
const originalThreeQuarter = getWallCurveFrameAt(curvedWall, 0.75).point
const firstMidpoint = getWallCurveFrameAt(firstCurve!, 0.5).point
const secondMidpoint = getWallCurveFrameAt(secondCurve!, 0.5).point
expect(firstMidpoint.x).toBeCloseTo(originalQuarter.x, 6)
expect(firstMidpoint.y).toBeCloseTo(originalQuarter.y, 6)
expect(secondMidpoint.x).toBeCloseTo(originalThreeQuarter.x, 6)
expect(secondMidpoint.y).toBeCloseTo(originalThreeQuarter.y, 6)
expect(roomPolygons).toHaveLength(2)
expect(nodes.filter((node) => node.type === 'slab')).toHaveLength(2)
expect(nodes.filter((node) => node.type === 'ceiling')).toHaveLength(2)
} finally {
unsubscribe()
}
})

test('splitting a curved wall migrates attachments by arc length', () => {
const curvedWall = {
...makeWall([0, 0], [4, 0], 'wall_curve'),
curveOffset: 1,
}
const curveLength = getWallCurveLength(curvedWall)
const door = DoorSchema.parse({
position: [curveLength * 0.75, 1.05, 0],
parentId: curvedWall.id,
wallId: curvedWall.id,
})
seedLevel([{ ...curvedWall, children: [door.id] }], [door as AnyNode])
const curveMidpoint = getWallCurveFrameAt(curvedWall, 0.5).point

const divider = createWallOnCurrentLevel([curveMidpoint.x, curveMidpoint.y], [2, 3])

expect(divider).not.toBeNull()
const secondCurve = levelWalls().find(
(wall) => isCurvedWall(wall) && wall.end[0] === 4 && wall.end[1] === 0,
)
const migratedDoor = useScene.getState().nodes[door.id as AnyNodeId]
expect(secondCurve).toBeDefined()
expect(migratedDoor?.type).toBe('door')
expect(migratedDoor?.parentId).toBe(secondCurve?.id)
if (migratedDoor?.type === 'door') {
expect(migratedDoor.position[0]).toBeCloseTo(curveLength * 0.25, 2)
}
})

test('crossing divider walls split into four joined segments and four room surfaces', () => {
const walls = [
makeWall([0, 0], [4, 0], 'wall_bottom'),
makeWall([4, 0], [4, 3], 'wall_right'),
makeWall([4, 3], [0, 3], 'wall_top'),
makeWall([0, 3], [0, 0], 'wall_left'),
]
const slab = SlabNode.parse({
id: 'slab_main',
parentId: LEVEL_ID,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
autoFromWalls: true,
})
const ceiling = CeilingNode.parse({
id: 'ceiling_main',
parentId: LEVEL_ID,
polygon: slab.polygon,
autoFromWalls: true,
})
seedLevel(walls, [slab, ceiling])
const editorState = {
spaces: {},
setSpaces(spaces: Record<string, unknown>) {
editorState.spaces = spaces
},
}
const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState })

try {
expect(createWallOnCurrentLevel([0, 0], [4, 3])).not.toBeNull()
const beforeCrossing = useScene.temporal.getState().pastStates.length
expect(createWallOnCurrentLevel([0, 3], [4, 0])).not.toBeNull()
expect(useScene.temporal.getState().pastStates.length - beforeCrossing).toBe(1)

const nodes = Object.values(useScene.getState().nodes)
const postCommitWalls = nodes.filter((node): node is WallNode => node.type === 'wall')
const centerSegments = postCommitWalls.filter(
(wall) =>
(wall.start[0] === 2 && wall.start[1] === 1.5) ||
(wall.end[0] === 2 && wall.end[1] === 1.5),
)
const { roomPolygons } = detectSpacesForLevel(String(LEVEL_ID), postCommitWalls)

expect(postCommitWalls).toHaveLength(8)
expect(centerSegments).toHaveLength(4)
expect(roomPolygons).toHaveLength(4)
expect(nodes.filter((node) => node.type === 'slab')).toHaveLength(4)
expect(nodes.filter((node) => node.type === 'ceiling')).toHaveLength(4)

expect(createWallOnCurrentLevel([0, 3], [4, 0])).toBeNull()
expect(levelWalls()).toHaveLength(8)
} finally {
unsubscribe()
}
})
})

describe('resolveEndpointWallSplit', () => {
Expand Down
Loading
Loading