Skip to content
Open
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
203 changes: 193 additions & 10 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ import useInteractionScope, {
import usePlacementPreview from '../../store/use-placement-preview'
import { expandSessionSelectionForNode } from '../../store/use-session-groups'
import { useStairBuildPreview } from '../../store/use-stair-build-preview'
import {
isWallTypingKey,
resolveTypedCommitEnd,
useWallDraftTyping,
} from '../../store/use-wall-draft-typing'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
Expand Down Expand Up @@ -4677,6 +4682,7 @@ function FloorplanLinearDraftLayer({
}) {
const metricNotation = useViewer((state) => state.metricNotation)
const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
const wallTypingInput = useWallDraftTyping((s) => s.input)
const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn)
Expand Down Expand Up @@ -4749,6 +4755,8 @@ function FloorplanLinearDraftLayer({
// Live length + angle feedback for the wall draft — parity with the 3D
// `WallTool`, ported to 2D plan space.
const draftWallMeasurement = useMemo(() => {
// Typed-length editing (#308): while a buffer is active the HUD shows the
// buffer instead of the live pointer length — parity with the 3D tool.
if (
!(
isWallBuildActive &&
Expand Down Expand Up @@ -4810,15 +4818,23 @@ function FloorplanLinearDraftLayer({
}

return {
lengthLabel: formatMeasurement(length, unit, null, metricNotation),
lengthLabel: wallTypingInput || formatMeasurement(length, unit, null, metricNotation),
Comment thread
cursor[bot] marked this conversation as resolved.
midpoint: [
(wallDraftStart[0] + wallDraftEnd[0]) / 2,
(wallDraftStart[1] + wallDraftEnd[1]) / 2,
] as WallPlanPoint,
direction: [dx / length, dy / length] as WallPlanPoint,
angleLabels,
}
}, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls])
}, [
isWallBuildActive,
metricNotation,
unit,
wallDraftEnd,
wallDraftStart,
wallTypingInput,
walls,
])

// Axis guides for wall and fence drafts — parity with the 3D tools'
// `DraftAxisGuides`: an X/Z cross through the draft start, and a single
Expand Down Expand Up @@ -4965,6 +4981,9 @@ export function FloorplanPanel({
const floorplanSpacePanPressedRef = useRef(false)
const floorplanNavigationClickSuppressedRef = useRef(false)
const guideInteractionRef = useRef<GuideInteractionState | null>(null)
// Late-bound so the window keydown effect can commit the wall draft at the
// typed length without an ordering dependency on `handleWallPlacementPoint`.
const wallPlacementPointRef = useRef<((point: WallPlanPoint) => void) | null>(null)
const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
const pendingFenceDragRef = useRef<PendingFenceDragState | null>(null)
const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
Expand Down Expand Up @@ -5140,10 +5159,33 @@ export function FloorplanPanel({
// Walls committed by the current 2D-only chain — exclusion set for the
// T-junction chain-termination test (mirrors the 3D tool's `chainWallIds`).
const wallChainWallIdsRef = useRef<string[]>([])
// Split view: the 3D wall tool owns the commit and publishes each segment's
// resolved end as the chain start. Re-base this panel's draft onto it so a
// typed Enter in the plan projects from the segment the 3D chain is
// actually drafting — without this, `draftStart` stays on the last point
// the panel itself placed and the typed commit uses a stale origin
// (Bugbot cb434c3d). In 2D-only the panel is the committer and never
// publishes, so this subscription stays dormant there.
useEffect(() => {
if (!(mode === 'build' && tool === 'wall')) return
return useSegmentDraftChain.subscribe((state, previousState) => {
const next = state.wall
if (next === previousState.wall) return
if (!next) return
setDraftStart((current) =>
current && current[0] === next[0] && current[1] === next[1] ? current : next,
)
setWallChainFirstVertex((current) => current ?? next)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale 2D draft after 3D stop

High Severity

The new useSegmentDraftChain subscription rebases draftStart when 3D publishes a chain point, but ignores a null clear and never resets wallChainFirstVertex. After 3D auto-close, T-junction, or single-segment finish, the 2D panel keeps a live draft and keeps consuming typing keys.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43d7a64. Configure here.

}, [mode, tool])
const setDraftEnd = useCallback(
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
const store = useFloorplanDraftPreview.getState()
store.setWallDraftEnd(typeof next === 'function' ? next(store.wallDraftEnd) : next)
const value = typeof next === 'function' ? next(store.wallDraftEnd) : next
store.setWallDraftEnd(value)
// Keep the typing store's projected endpoint in sync with the live
// preview so a click mid-type commits the projected point (#308).
if (value) useWallDraftTyping.getState().setProjectedEnd(value)
},
[],
)
Expand Down Expand Up @@ -7839,6 +7881,7 @@ export function FloorplanPanel({
wallConstructionOptionsRef.current = undefined
wallChainWallIdsRef.current = []
setDraftEnd(null)
useWallDraftTyping.getState().clearInput()
useSegmentDraftChain.getState().clear('wall')
}, [setDraftEnd])
const clearFencePlacementDraft = useCallback(() => {
Expand Down Expand Up @@ -8238,6 +8281,73 @@ export function FloorplanPanel({
setShiftPressed(true)
}

// Typed-length editing for the 2D wall draft (#308) — parity with the
// 3D wall tool's `onKeyDown`: printable keys extend the buffer, Enter
// commits at the typed length, Escape (stage 1) clears it.
if (isWallBuildActive && draftStart) {
const typing = useWallDraftTyping.getState()
const hasInput = typing.input.length > 0
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
if (isWallTypingKey(event.key)) {
typing.append(event.key)
event.preventDefault()
event.stopPropagation()
return
}
if (hasInput) {
if (event.key === 'Backspace') {
typing.backspace()
event.preventDefault()
event.stopPropagation()
return
}
if (event.key === 'Delete') {
typing.clearInput()
event.preventDefault()
event.stopPropagation()
return
}
if (event.key === 'Escape') {
typing.clearInput()
event.preventDefault()
event.stopPropagation()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape cancels typed 2D draft

High Severity

First Escape during a typed 2D wall draft cancels the draft (and can exit the tool) instead of only clearing the buffer. The new handler calls stopPropagation, but use-keyboard also listens on window and still emits tool:cancel; the panel's cancel path then runs clearDraft.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43d7a64. Configure here.

}
if (event.key === 'Enter') {
const value = parseMeasurement(
typing.input,
{ kind: 'length', unitId: 'm' },
{
bareUnit:
unit === 'imperial' ? 'in' : metricNotation === 'millimeters' ? 'mm' : 'm',
system: unit === 'imperial' ? 'imperial' : 'metric',
},
)
typing.clearInput()
if (value === null || value <= 0 || !draftStart) return
const store = useFloorplanDraftPreview.getState()
const previousEnd = store.wallDraftEnd
if (!previousEnd) return
const dx = previousEnd[0] - draftStart[0]
const dz = previousEnd[1] - draftStart[1]
const length = Math.hypot(dx, dz)
if (length <= 1e-6) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enter drops typed length early

Medium Severity

2D Enter calls clearInput before the direction and length guards. A valid typed value with no pointer move (wallDraftEnd missing or still on draftStart) wipes the buffer and commits nothing. The 3D path leaves the buffer in place when the ray is degenerate.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43d7a64. Configure here.

const typedEnd: WallPlanPoint = [
draftStart[0] + (dx / length) * value,
draftStart[1] + (dz / length) * value,
]
// typedEnd is passed as the raw point; handleWallPlacementPoint
// commits it verbatim (no snap) since the buffer is cleared.
setDraftEnd(typedEnd)
wallPlacementPointRef.current?.(typedEnd)
event.preventDefault()
event.stopPropagation()
return
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (
isStairBuildActive &&
useEditor.getState().viewMode === '2d' &&
Expand Down Expand Up @@ -8293,7 +8403,16 @@ export function FloorplanPanel({
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleBlur)
}
}, [isFloorplanOpen, isStairBuildActive, movingNode])
}, [
isFloorplanOpen,
isStairBuildActive,
movingNode,
isWallBuildActive,
draftStart,
unit,
metricNotation,
setDraftEnd,
])

useEffect(() => {
const handleWindowPointerMove = (event: PointerEvent) => {
Expand Down Expand Up @@ -9521,8 +9640,34 @@ export function FloorplanPanel({
// Emit `grid:move` so the registry-driven wall tool's 3D preview
// tracks the cursor. The local draftEnd update below is what
// drives the 2D draft polygon — both views update in parallel.
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint(snappedPoint)
// Typed-length editing (#308): while a buffer is active the draft end
// is the start projected along the draft direction onto the typed
// length; the pointer keeps steering direction only.
let draftEndPoint = snappedPoint
const wallTyping = useWallDraftTyping.getState()
if (draftStart && wallTyping.input) {
const typedLength = parseMeasurement(
wallTyping.input,
{ kind: 'length', unitId: 'm' },
{
bareUnit: unit === 'imperial' ? 'in' : metricNotation === 'millimeters' ? 'mm' : 'm',
system: unit === 'imperial' ? 'imperial' : 'metric',
},
)
if (typedLength !== null && typedLength > 0) {
const dx = snappedPoint[0] - draftStart[0]
const dz = snappedPoint[1] - draftStart[1]
const pointerLength = Math.hypot(dx, dz)
if (pointerLength > 1e-6) {
draftEndPoint = [
draftStart[0] + (dx / pointerLength) * typedLength,
draftStart[1] + (dz / pointerLength) * typedLength,
]
}
}
}
emitFloorplanGridEvent('move', draftEndPoint, event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split view re-snaps typed endpoint

Medium Severity

In split view the 2D move path emits the already-projected typed endpoint as grid:move. The 3D tool treats that as a raw pointer, magnetically snaps it, then re-projects. A nearby corner or edge can rotate the draft ray so the committed wall no longer matches the 2D preview.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43d7a64. Configure here.

setCursorPoint(draftEndPoint)
Comment thread
cursor[bot] marked this conversation as resolved.

if (!draftStart) {
return
Expand All @@ -9531,13 +9676,13 @@ export function FloorplanPanel({
setDraftEnd((previousEnd) => {
if (
!previousEnd ||
previousEnd[0] !== snappedPoint[0] ||
previousEnd[1] !== snappedPoint[1]
previousEnd[0] !== draftEndPoint[0] ||
previousEnd[1] !== draftEndPoint[1]
) {
sfxEmitter.emit('sfx:grid-snap')
}

return snappedPoint
return draftEndPoint
})
},
[
Expand Down Expand Up @@ -9572,6 +9717,11 @@ export function FloorplanPanel({
isRoofBuildActive,
isWallBuildActive,
levelId,
// `unit` / `metricNotation` feed the typed-length projection helpers
// shared with the commit path; listed so a unit change re-binds the
// move preview to the new bare-unit parse.
metricNotation,
unit,
publishFloorplanNavigationPose,
referenceScaleDraft,
roofDraftStart,
Expand Down Expand Up @@ -9758,7 +9908,29 @@ export function FloorplanPanel({
)

const handleWallPlacementPoint = useCallback(
(point: WallPlanPoint) => {
(rawPoint: WallPlanPoint) => {
// Typed-length editing (#308): while a buffer is active, commit the
// projected endpoint the preview shows instead of the raw pointer —
// keeps the 2D-only commit, the chain continuation, and the length
// label in agreement with what the user typed. Re-derived at commit
// time (Bugbot 8497d792): digits arriving after the last pointer move
// leave `projectedEnd` stale; the commit must reflect the full buffer.
const typing = useWallDraftTyping.getState()
const draftPreviewStore = useFloorplanDraftPreview.getState()
const typedEnd =
(draftStart &&
typing.input &&
resolveTypedCommitEnd(
draftStart,
draftPreviewStore.wallDraftEnd ?? rawPoint,
typing.input,
{
bareUnit: unit === 'imperial' ? 'in' : metricNotation === 'millimeters' ? 'mm' : 'm',
system: unit === 'imperial' ? 'imperial' : 'metric',
},
)) ||
(typing.input ? typing.projectedEnd : null)
const point: WallPlanPoint = typedEnd ?? rawPoint
if (!draftStart) {
wallConstructionOptionsRef.current = levelId
? resolveTerrainWallConstructionOptions(
Expand Down Expand Up @@ -9857,16 +10029,27 @@ export function FloorplanPanel({
setDraftStart(nextStart)
setDraftEnd(nextStart)
setCursorPoint(nextStart)
// Typed length applied to the committed segment only — never carry it
// into the next chain segment.
useWallDraftTyping.getState().clearInput()
},
[
clearWallPlacementDraft,
draftStart,
levelId,
metricNotation,
unit,
wallChainFirstVertex,
setDraftEnd,
setCursorPoint,
],
)
// Latest-ref for the Enter-commit path: assigning the ref inside the
// callback itself froze the first-render closure (draftStart === null), so
// a typed Enter re-started the draft instead of committing the wall.
useEffect(() => {
wallPlacementPointRef.current = handleWallPlacementPoint
}, [handleWallPlacementPoint])
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
sceneRef: floorplanSceneRef,
})
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,11 @@ export {
useStairBuildPreview,
} from './store/use-stair-build-preview'
export { useUploadStore } from './store/use-upload'
export {
isWallTypingKey,
resolveTypedCommitEnd,
useWallDraftTyping,
} from './store/use-wall-draft-typing'
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
export {
default as useWallSnapIndicator,
Expand Down
Loading