Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@ import {
resolveFloorplanPresentationViewBox,
setFloorplanCompassRotation,
} from './floorplan-navigation-presentation'
import { installFloorplanWallTypingKeys } from './floorplan-wall-typing-keys'
import {
endFloorplanWallTypingOnCancel,
installFloorplanWallTypingKeys,
} from './floorplan-wall-typing-keys'
import { swallowNextClick } from './handles/use-handle-drag'
import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
Expand Down Expand Up @@ -8099,6 +8102,7 @@ export function FloorplanPanel({

useEffect(() => {
const handleCancel = () => {
if (endFloorplanWallTypingOnCancel()) return
clearDraft()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { beforeEach, expect, test } from 'bun:test'
import { emitter } from '@pascal-app/core'
import { useKeyboard } from '../../hooks/use-keyboard'
import { cancelActiveTool, useKeyboard } from '../../hooks/use-keyboard'
import useEditor from '../../store/use-editor'
import { placementTypingLengthOptions, usePlacementTyping } from '../../store/use-placement-typing'
import {
PLACEMENT_TYPING_LENGTH_ANGLE,
placementTypingLengthOptions,
usePlacementTyping,
} from '../../store/use-placement-typing'
import { sceneLayout } from '../../store/view-layout'
import { withSelectionHarness } from '../../test-utils/selection-harness'
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
import {
endFloorplanWallTypingOnCancel,
type FloorplanWallTypingDraft,
installFloorplanWallTypingKeys,
} from './floorplan-wall-typing-keys'
Expand Down Expand Up @@ -105,3 +110,43 @@ test('Enter commits the pointer the typed fields re-project, only in 2D-only vie
}
})
})

test('in 2D-only view a cancel from any source first ends the typed session, then the draft', async () => {
await withSelectionHarness(async () => {
useEditor.getState().setViewLayout(sceneLayout('2d'))
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
let draftKept = true
// The plan's own `tool:cancel` handler.
const onCancel = () => {
if (endFloorplanWallTypingOnCancel()) return
draftKept = false
}
emitter.on('tool:cancel', onCancel)
try {
usePlacementTyping.getState().begin({ labels: PLACEMENT_TYPING_LENGTH_ANGLE })
usePlacementTyping.getState().append('3')

// The path ⌘Z takes mid-draft, not a keydown the plan sees itself.
expect(cancelActiveTool()).toBe(true)
expect(usePlacementTyping.getState().isActive).toBe(false)
expect(draftKept).toBe(true)
expect(useEditor.getState().mode).toBe('build')
expect(useEditor.getState().tool).toBe('wall')

cancelActiveTool()
expect(draftKept).toBe(false)
} finally {
emitter.off('tool:cancel', onCancel)
}
})
})

test('in split view the typed session and its cancel stay with the 3D wall tool', async () => {
await withSelectionHarness(async () => {
useEditor.getState().setViewLayout(sceneLayout('split'))
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
usePlacementTyping.getState().begin({ labels: PLACEMENT_TYPING_LENGTH_ANGLE })
expect(endFloorplanWallTypingOnCancel()).toBe(true)
expect(usePlacementTyping.getState().isActive).toBe(true)
})
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { markToolCancelConsumed } from '../../hooks/use-keyboard'
import type { ParseMeasurementOptions } from '../../lib/measurement-parser'
import { isTypingTarget } from '../../lib/typing-target'
import useEditor from '../../store/use-editor'
Expand Down Expand Up @@ -58,3 +59,21 @@ export function installFloorplanWallTypingKeys({
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}

/**
* The plan's `tool:cancel` first stage. A cancel from any source (Escape, ⌘Z,
* a toolbar button) while a typed wall session is open only ends that
* session: in 2D-only view the plan clears it and consumes the cancel. In
* split view the 3D wall tool owns the session and its cancel. Returns true
* when the plan's wall draft must be kept.
*/
export function endFloorplanWallTypingOnCancel(): boolean {
const editor = useEditor.getState()
if (!(editor.mode === 'build' && editor.tool === 'wall')) return false
if (!usePlacementTyping.getState().isActive) return false
if (visibleScene(editor) === '2d') {
markToolCancelConsumed()
usePlacementTyping.getState().clear()
}
return true
}
142 changes: 142 additions & 0 deletions packages/nodes/src/wall/tool-typed-cancel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'
import {
BuildingNode,
createSceneApi,
emitter,
type GridEvent,
LevelNode,
useScene,
} from '@pascal-app/core'
import {
cancelActiveTool,
getWallDrawVariant,
PLACEMENT_TYPING_LENGTH_ANGLE,
RegistryToolProvider,
sceneLayout,
selectWallDrawVariant,
useEditor,
useFloorplanDraftPreview,
usePlacementTyping,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { act, create } from '@react-three/test-renderer'
import type { ReactNode } from 'react'
import WallTool from './tool'

const building = BuildingNode.parse({ id: 'building_typed_cancel' })
const level = LevelNode.parse({ id: 'level_typed_cancel', parentId: building.id })

let canvas: EventTarget | null = null
function CanvasProbe() {
canvas = useThree((state) => state.gl.domElement)
return null
}

function grid(x: number, z: number): GridEvent {
return {
position: [x, 0, z],
localPosition: [x, 0, z],
nativeEvent: { target: canvas, button: 0, detail: 1 } as unknown as GridEvent['nativeEvent'],
}
}

let savedScene: ReturnType<typeof useScene.getState>
let savedEditor: ReturnType<typeof useEditor.getState>
let savedVariant: ReturnType<typeof getWallDrawVariant>
const DOM_GLOBALS = [
'window',
'HTMLCanvasElement',
'cancelAnimationFrame',
'IS_REACT_ACT_ENVIRONMENT',
] as const
let savedGlobals: (PropertyDescriptor | undefined)[] = []
let htmlLabels: ReturnType<typeof spyOn>
beforeEach(() => {
// The draft's labels and the typing HUD are DOM overlays; there is no DOM here.
htmlLabels = spyOn(Html as unknown as { render: () => ReactNode }, 'render').mockImplementation(
() => null,
)
// The tool listens for typed keys on window; there is no DOM here.
savedGlobals = DOM_GLOBALS.map((key) => Object.getOwnPropertyDescriptor(globalThis, key))
Object.defineProperties(globalThis, {
window: { configurable: true, value: new EventTarget() },
HTMLCanvasElement: { configurable: true, value: class {} },
cancelAnimationFrame: { configurable: true, value: () => {} },
IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
})
savedScene = useScene.getState()
savedEditor = useEditor.getState()
useScene.setState({
nodes: {
[building.id]: { ...building, children: [level.id] },
[level.id]: level,
},
rootNodeIds: [building.id],
dirtyNodes: new Set(),
readOnly: false,
})
useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id })
useEditor.getState().setViewLayout(sceneLayout('3d'))
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
savedVariant = getWallDrawVariant()
selectWallDrawVariant('walls')
usePlacementTyping.getState().clear()
})
afterEach(() => {
htmlLabels.mockRestore()
usePlacementTyping.getState().clear()
selectWallDrawVariant(savedVariant)
DOM_GLOBALS.forEach((key, index) => {
const saved = savedGlobals[index]
if (saved) Object.defineProperty(globalThis, key, saved)
else delete (globalThis as Record<string, unknown>)[key]
})
useScene.setState(savedScene)
useEditor.setState(savedEditor)
})

test('a cancel from any source first ends the typed session, then the draft', async () => {
const renderer = await create(
<RegistryToolProvider
value={{
activeLevelId: level.id,
isCameraDragging: () => false,
sceneApi: createSceneApi(),
selectNode: () => {},
unit: 'metric',
}}
>
<CanvasProbe />
<WallTool />
</RegistryToolProvider>,
)
const draftStart = () => useFloorplanDraftPreview.getState().wallDraftStart
try {
await act(async () => emitter.emit('grid:click', grid(0, 0)))
await act(async () => emitter.emit('grid:move', grid(2, 0)))
expect(draftStart()).toEqual([0, 0])
await act(async () => {
usePlacementTyping.getState().begin({ labels: PLACEMENT_TYPING_LENGTH_ANGLE })
usePlacementTyping.getState().append('3')
})

// The path ⌘Z takes mid-draft, not a keydown the tool sees itself.
await act(async () => {
expect(cancelActiveTool()).toBe(true)
})
expect(usePlacementTyping.getState().isActive).toBe(false)
expect(draftStart()).toEqual([0, 0])
expect(useEditor.getState().mode).toBe('build')
expect(useEditor.getState().tool).toBe('wall')

await act(async () => {
expect(cancelActiveTool()).toBe(true)
})
expect(draftStart()).toBeNull()
expect(useEditor.getState().tool).toBe('wall')
} finally {
await renderer.unmount()
}
})
4 changes: 3 additions & 1 deletion packages/nodes/src/wall/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1120,8 +1120,10 @@ const LineWallTool: React.FC = () => {

const onCancel = () => {
if (buildingState.current === 1) {
// A first Escape ends an open typed session; the next cancels the draft.
// A first cancel (Escape, ⌘Z, …) ends an open typed session; the next
// cancels the draft.
if (usePlacementTyping.getState().isActive) {
markToolCancelConsumed()
usePlacementTyping.getState().clear()
return
}
Expand Down
2 changes: 1 addition & 1 deletion wiki/architecture/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Concretely, door/window placement/move keeps these in lockstep across `{door,win
- **R-flip** facing mid-placement, **Alt (hold)** to force-place past snapping and collisions (guides stay visible) and **Shift (tap)** to cycle the snapping mode — the rule above, never a held-Shift bypass — faithful ghost/symbol, deterministic single-undo commit.
- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner (only a fixed window's glass fuses: the other types keep their frames and meet at the corner as at a post, so the choice shows only for fixed windows), and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle, and in the plan each side runs to the walls' mitre, so the corner reads as glass there too. A pair holds only while each side reaches its corner end, both walls end at that corner on one level and the angle stays between 10° and 170° (`cornerPair`, which the viewer, the plan and every tool read). Deleting one side, dragging it off its corner (3D or plan) or onto a roof or dormer leaves the other a plain window, framed at the corner; a window whose frame was built for a pair that has since broken (a wall moved away) is rebuilt. A copy of one side is plain; a level duplicated, or both walls copied and pasted, keeps the copies joined to each other. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge, in the editor's units, from the refusal's code: the refusal's own message is the agents'); two windows that already wrap their corner get *Unwrap the corner* there instead. Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between (or overlapping), become one window spanning both, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`evaluateCorner`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour (in the plan, the walls' hover stroke), the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD's "!" row says why instead of staying silent: no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window; and where a window waits but the join would be refused, why: an opening in the way of sliding them to the corner, or the waiting window taller than this wall. **Alt**, or snapping set to Off, places or moves it plain; releasing Alt brings the snap back without moving the pointer.
- **R** flips the roof tool's ridge chip (`useRoofRidgeMode`, `packages/nodes/src/roof/roof-ridge-mode.ts`): a drawn or room-picked gable runs its ridge along the footprint's long side unless the chip says *Short side*; the 3D ghost, the plan's draft ridge line and the commit all read `ridgeQuarterTurn`, and each arming starts on *Long side*.
- **Typed entry** (`store/use-placement-typing.ts`): one convention for every tool that takes typed coordinates — a digit or `.` opens a session, Tab or comma switches field, Enter commits, the first Escape clears what was typed and the next cancels the draft. `routePlacementTypingKey` is that convention; a tool decides only when a session may open and what its two fields mean. A wall draft types its length and angle (`resolveTypedWallEnd`; Tab with no session opens the angle), a cabinet its distance along the wall and offset from it. A typed wall angle is an absolute heading in the building's plan frame, measured the way the 15° angle snap builds its rays: `atan2(dz, dx)` from +X towards +Z, in [0°, 360°), so on an unrotated plan 0° points right and 90° points down. The angle field's placeholder is the draft's live heading in that same convention (`formatWallDraftHeading`), so typing over it starts from what is on screen; the draft's own angle labels stay relative (to the nearest axis or the connected wall). The HUD (`PlacementCoordinateInput`) only displays the store, in 3D through `LocalizedHtml` and in the plan through `FloorplanPlacementTypingHud`, so split view shows it in both panes. While typing, the plan forwards its snapped pointer on `grid:move`, never the typed end, and each side re-projects that pointer onto what was typed. In 2D-only view the plan takes these keys in a window capture listener (`installFloorplanWallTypingKeys`), so they never reach the global shortcuts.
- **Typed entry** (`store/use-placement-typing.ts`): one convention for every tool that takes typed coordinates — a digit or `.` opens a session, Tab or comma switches field, Enter commits, the first cancel (Escape, ⌘Z mid-draft or a toolbar cancel) clears what was typed and is consumed, and the next cancels the draft. `routePlacementTypingKey` is that convention; a tool decides only when a session may open and what its two fields mean. A wall draft types its length and angle (`resolveTypedWallEnd`; Tab with no session opens the angle), a cabinet its distance along the wall and offset from it. A typed wall angle is an absolute heading in the building's plan frame, measured the way the 15° angle snap builds its rays: `atan2(dz, dx)` from +X towards +Z, in [0°, 360°), so on an unrotated plan 0° points right and 90° points down. The angle field's placeholder is the draft's live heading in that same convention (`formatWallDraftHeading`), so typing over it starts from what is on screen; the draft's own angle labels stay relative (to the nearest axis or the connected wall). The HUD (`PlacementCoordinateInput`) only displays the store, in 3D through `LocalizedHtml` and in the plan through `FloorplanPlacementTypingHud`, so split view shows it in both panes. While typing, the plan forwards its snapped pointer on `grid:move`, never the typed end, and each side re-projects that pointer onto what was typed. In 2D-only view the plan takes these keys in a window capture listener (`installFloorplanWallTypingKeys`), so they never reach the global shortcuts.
- **O / L** cycle the door and window tools' Type and Style chips (`ToolHint.chip`, by key or click): the choices of the panel's Type and Style rows, written from the same core fields (`doorTypeFields`, `DOOR_TYPE_SIZES` and `doorStyleLook` for a door; `windowTypeFields` and the window style presets for a window), so a door placed by hand matches one changed in the panel. A window placed as Bay or Bow has no sill; every other type has one. A window style shapes a Fixed window's panes, which only a Fixed window draws, so the panel's Style row and the tool's L chip show for a Fixed window only (cycling O away from Fixed hides L), and `add_window` refuses a style on another type (`style_needs_fixed_window`). The Style row and the L chip offer one entry per look (`WINDOW_STYLE_CHOICES`); `picture`, which draws what `single` draws, stays an accepted alias for agents.

Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt.
Expand Down
Loading