From bebb95fee074568b94c7c515fe68adf86471d1cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 23:15:58 +0000 Subject: [PATCH 1/2] fix(wall): a cancel that only ends typed entry keeps the draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1249. A first cancel from any source (Escape, ⌘Z mid-draft, a toolbar cancel) while a typed wall session is open now only clears the typed values; the next cancel drops the draft and the tool stays armed. - 3D wall tool: consume the first-stage cancel, as the cabinet tool does. - 2D-only plan: `endFloorplanWallTypingOnCancel` clears the session and keeps the plan's draft; in split view it defers to the 3D wall tool. Regression tests drive `cancelActiveTool()` (the ⌘Z path) through the real WallTool and the plan's cancel handler. Addresses Cursor Bugbot finding on #1249 ("Cancel during typed wall entry exits tool"). Co-authored-by: dkbbdev --- .../src/components/editor/floorplan-panel.tsx | 6 +- .../floorplan-wall-typing-keys.test.tsx | 49 +++++- .../editor/floorplan-wall-typing-keys.ts | 19 +++ .../nodes/src/wall/tool-typed-cancel.test.tsx | 142 ++++++++++++++++++ packages/nodes/src/wall/tool.tsx | 4 +- 5 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 packages/nodes/src/wall/tool-typed-cancel.test.tsx diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 13e7b194db..d61ab417d2 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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' @@ -8099,6 +8102,7 @@ export function FloorplanPanel({ useEffect(() => { const handleCancel = () => { + if (endFloorplanWallTypingOnCancel()) return clearDraft() } diff --git a/packages/editor/src/components/editor/floorplan-wall-typing-keys.test.tsx b/packages/editor/src/components/editor/floorplan-wall-typing-keys.test.tsx index f1724a44ac..1e56153266 100644 --- a/packages/editor/src/components/editor/floorplan-wall-typing-keys.test.tsx +++ b/packages/editor/src/components/editor/floorplan-wall-typing-keys.test.tsx @@ -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' @@ -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) + }) +}) diff --git a/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts b/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts index cba4b959aa..63eae1c374 100644 --- a/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts +++ b/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts @@ -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' @@ -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 +} diff --git a/packages/nodes/src/wall/tool-typed-cancel.test.tsx b/packages/nodes/src/wall/tool-typed-cancel.test.tsx new file mode 100644 index 0000000000..1be44f2898 --- /dev/null +++ b/packages/nodes/src/wall/tool-typed-cancel.test.tsx @@ -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 +let savedEditor: ReturnType +let savedVariant: ReturnType +const DOM_GLOBALS = [ + 'window', + 'HTMLCanvasElement', + 'cancelAnimationFrame', + 'IS_REACT_ACT_ENVIRONMENT', +] as const +let savedGlobals: (PropertyDescriptor | undefined)[] = [] +let htmlLabels: ReturnType +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)[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( + false, + sceneApi: createSceneApi(), + selectNode: () => {}, + unit: 'metric', + }} + > + + + , + ) + 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() + } +}) diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 16e7933af8..a1a697b870 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -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 } From 9236b279b96687f08ceb8715d9a40599e845fb43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 23:26:07 +0000 Subject: [PATCH 2/2] docs(tools): a first cancel from any source ends typed entry Co-authored-by: dkbbdev --- wiki/architecture/tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index c5a27e65b0..60c36fd9fc 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -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.