From d16ca11c76a35540d066b07a933effdd51f7a087 Mon Sep 17 00:00:00 2001
From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com>
Date: Wed, 5 Aug 2026 16:13:48 +0530
Subject: [PATCH] feat: add exact wall length input
Signed-off-by: Aryan Singh K. <70511529+aryansk@users.noreply.github.com>
---
.../src/components/editor/floorplan-panel.tsx | 103 +++++++++++++++---
.../tools/wall/wall-drafting.test.ts | 17 +++
.../components/tools/wall/wall-drafting.ts | 40 +++++++
packages/editor/src/index.tsx | 2 +
.../store/live-draft-preview-stores.test.ts | 16 +++
.../src/store/use-floorplan-draft-preview.ts | 17 +++
.../nodes/src/shared/draft-axis-guides.tsx | 60 +++++++++-
packages/nodes/src/wall/tool.tsx | 58 +++++++++-
8 files changed, 291 insertions(+), 22 deletions(-)
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 99c771931..fb97742c5 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -182,8 +182,10 @@ import {
} from '../tools/stair/stair-defaults'
import {
chainEndJoinsExistingWall,
+ constrainWallDraftLength,
createWallOnCurrentLevel,
isSegmentLongEnough,
+ parseWallDraftLength,
resolveTerrainWallConstructionOptions,
snapWallDraftPoint,
snapWallDraftPointDetailed,
@@ -2374,6 +2376,9 @@ type DraftWallMeasurement = {
}
function FloorplanDraftWallMeasurement({
+ lengthInput,
+ lengthInputError,
+ onLengthInputChange,
measurement,
measurementStroke,
labelBackground,
@@ -2381,6 +2386,9 @@ function FloorplanDraftWallMeasurement({
sceneRotationDeg,
unitsPerPixel,
}: {
+ lengthInput: string
+ lengthInputError: boolean
+ onLengthInputChange: (value: string) => void
measurement: DraftWallMeasurement
measurementStroke: string
labelBackground: string
@@ -2415,7 +2423,8 @@ function FloorplanDraftWallMeasurement({
const cx = measurement.midpoint[0] + perpX * offset
const cy = measurement.midpoint[1] + perpY * offset
- const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
+ const lengthTextWidth =
+ Math.max(measurement.lengthLabel.length, lengthInput.length, 8) * upx * 6.2
const lengthPlateW = lengthTextWidth + padX * 2
const lengthPlateH = fontSize + padY * 2
@@ -2437,18 +2446,46 @@ function FloorplanDraftWallMeasurement({
x={-lengthPlateW / 2}
y={-lengthPlateH / 2}
/>
-
- {measurement.lengthLabel}
-
+
{
+ event.stopPropagation()
+ event.nativeEvent.stopImmediatePropagation()
+ }}
+ onPointerDown={(event) => {
+ event.stopPropagation()
+ event.nativeEvent.stopImmediatePropagation()
+ }}
+ >
+ onLengthInputChange(event.target.value)}
+ onKeyDown={(event) => event.stopPropagation()}
+ placeholder={measurement.lengthLabel}
+ style={{ color: labelText, fontSize, textShadow: `0 0 3px ${labelBg}` }}
+ title={
+ lengthInputError ? 'Enter a positive wall length, such as 3m or 5\'11".' : undefined
+ }
+ type="text"
+ value={lengthInput}
+ />
+
+
{measurement.angleLabels.map((arc) => {
@@ -4717,6 +4754,18 @@ function FloorplanLinearDraftLayer({
const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
+ const wallDraftLengthInput = useFloorplanDraftPreview((s) => s.wallDraftLengthInput)
+ const wallDraftLengthMeters = useFloorplanDraftPreview((s) => s.wallDraftLengthMeters)
+ const wallDraftLengthInputError =
+ wallDraftLengthInput.trim().length > 0 && wallDraftLengthMeters === null
+
+ useEffect(() => {
+ const draftPreview = useFloorplanDraftPreview.getState()
+ draftPreview.setWallDraftLength(
+ draftPreview.wallDraftLengthInput,
+ parseWallDraftLength(draftPreview.wallDraftLengthInput, unit),
+ )
+ }, [unit])
const draftPolygon = useMemo(() => {
if (
@@ -4935,6 +4984,13 @@ function FloorplanLinearDraftLayer({
+ useFloorplanDraftPreview
+ .getState()
+ .setWallDraftLength(value, parseWallDraftLength(value, unit))
+ }
measurement={draftWallMeasurement}
measurementStroke={measurementStroke}
sceneRotationDeg={sceneRotationDeg}
@@ -7845,6 +7901,7 @@ export function FloorplanPanel({
wallConstructionOptionsRef.current = undefined
wallChainWallIdsRef.current = []
setDraftEnd(null)
+ useFloorplanDraftPreview.getState().setWallDraftLength('', null)
useSegmentDraftChain.getState().clear('wall')
}, [setDraftEnd])
const clearFencePlacementDraft = useCallback(() => {
@@ -9493,9 +9550,19 @@ export function FloorplanPanel({
applySnap: isMagneticSnapActive() && !wallAngleSnap,
})
}
+ const exactLengthMeters = draftStart
+ ? useFloorplanDraftPreview.getState().wallDraftLengthMeters
+ : null
+ if (draftStart) {
+ snappedPoint = constrainWallDraftLength(draftStart, snappedPoint, exactLengthMeters)
+ }
useWallSnapIndicator
.getState()
- .set(wallSnap.snap ? { x: snappedPoint[0], z: snappedPoint[1], kind: wallSnap.snap } : null)
+ .set(
+ wallSnap.snap && exactLengthMeters == null
+ ? { x: snappedPoint[0], z: snappedPoint[1], kind: wallSnap.snap }
+ : null,
+ )
// Emit `grid:move` so the registry-driven wall tool's 3D preview
// tracks the cursor. The local draftEnd update below is what
@@ -9749,11 +9816,14 @@ export function FloorplanPanel({
setDraftStart(point)
setWallChainFirstVertex(point)
setDraftEnd(point)
+ useFloorplanDraftPreview.getState().setWallDraftLength('', null)
setCursorPoint(point)
return
}
- if (!isSegmentLongEnough(draftStart, point)) {
+ const exactLengthMeters = useFloorplanDraftPreview.getState().wallDraftLengthMeters
+ const placementPoint = constrainWallDraftLength(draftStart, point, exactLengthMeters)
+ if (!isSegmentLongEnough(draftStart, placementPoint)) {
return
}
@@ -9776,7 +9846,7 @@ export function FloorplanPanel({
if (viewIs2DOnly) {
createdWall = createWallOnCurrentLevel(
draftStart,
- point,
+ placementPoint,
wallConstructionOptionsRef.current,
)
}
@@ -9791,7 +9861,7 @@ export function FloorplanPanel({
const publishedNextStart = useSegmentDraftChain.getState().wall
const nextStart: WallPlanPoint = createdWall
? (createdWall.end as WallPlanPoint)
- : (publishedNextStart ?? point)
+ : (publishedNextStart ?? placementPoint)
if (
useEditor.getState().getContinuation('wall') === 'single' ||
@@ -9834,6 +9904,7 @@ export function FloorplanPanel({
setDraftStart(nextStart)
setDraftEnd(nextStart)
+ useFloorplanDraftPreview.getState().setWallDraftLength('', null)
setCursorPoint(nextStart)
},
[
diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts
index 2d1f3168e..5b0cce0b0 100644
--- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts
+++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts
@@ -18,7 +18,9 @@ import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import {
+ constrainWallDraftLength,
createWallOnCurrentLevel,
+ parseWallDraftLength,
resolveEndpointWallSplit,
resolveTerrainWallConstructionOptions,
snapWallDraftPointDetailed,
@@ -264,6 +266,21 @@ describe('createWallOnCurrentLevel', () => {
})
})
+describe('wall draft length input', () => {
+ test('constrains the endpoint without changing the pointer heading', () => {
+ expect(constrainWallDraftLength([0, 0], [3, 4], 2)).toEqual([1.2, 1.6])
+ expect(constrainWallDraftLength([0, 0], [3, 4], null)).toEqual([3, 4])
+ })
+
+ test('parses bare values in the active unit and preserves explicit units', () => {
+ expect(parseWallDraftLength('2', 'metric')).toBe(2)
+ expect(parseWallDraftLength('5', 'imperial')).toBeCloseTo(1.524, 6)
+ expect(parseWallDraftLength('180cm', 'imperial')).toBeCloseTo(1.8, 6)
+ expect(parseWallDraftLength('5\'11"', 'imperial')).toBeCloseTo(1.8034, 6)
+ expect(parseWallDraftLength('not a length', 'metric')).toBeNull()
+ })
+})
+
describe('resolveEndpointWallSplit', () => {
beforeEach(() => {
seedLevel([makeWall([0, 0], [4, 0], 'wall_host'), makeWall([2, 2], [2, 1], 'wall_moved')])
diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts
index 60d5c44de..8cd75193a 100644
--- a/packages/editor/src/components/tools/wall/wall-drafting.ts
+++ b/packages/editor/src/components/tools/wall/wall-drafting.ts
@@ -18,6 +18,7 @@ import {
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
+import { parseMeasurement } from '../../../lib/measurement-parser'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
@@ -55,6 +56,45 @@ export const WALL_MIN_LENGTH = 0.01
// can ever target again.
const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
+/**
+ * Keep an exact-length wall draft on the pointer's current heading. Snapping
+ * still determines the heading, while the explicit input owns the distance.
+ */
+export function constrainWallDraftLength(
+ start: WallPlanPoint,
+ end: WallPlanPoint,
+ lengthMeters: number | null,
+): WallPlanPoint {
+ if (!(lengthMeters != null && Number.isFinite(lengthMeters) && lengthMeters > 0)) {
+ return end
+ }
+
+ const dx = end[0] - start[0]
+ const dz = end[1] - start[1]
+ const headingLength = Math.hypot(dx, dz)
+ if (headingLength < 1e-6) return end
+
+ return [
+ start[0] + (dx / headingLength) * lengthMeters,
+ start[1] + (dz / headingLength) * lengthMeters,
+ ]
+}
+
+/**
+ * Parse a wall draft's free-text length into the editor's canonical metres.
+ * Bare values use the active display unit; explicit suffixes always win.
+ */
+export function parseWallDraftLength(raw: string, unit: 'metric' | 'imperial'): number | null {
+ const bareUnit = unit === 'imperial' ? 'ft' : 'm'
+ const parsed = parseMeasurement(
+ raw,
+ { kind: 'length', unitId: 'm' },
+ { bareUnit, system: unit === 'imperial' ? 'us' : 'metric' },
+ )
+
+ return parsed != null && parsed >= WALL_MIN_LENGTH ? parsed : null
+}
+
type WallSplitIntersection = {
/** `null` = snap-only outcome: resolve to `point` but split no wall. */
wallId: WallNode['id'] | null
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index 212478ab4..9b14ad76f 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -188,9 +188,11 @@ export {
export { preloadRegistryToolModules, ToolManager } from './components/tools/tool-manager'
export {
chainEndJoinsExistingWall,
+ constrainWallDraftLength,
createWallOnCurrentLevel,
getSegmentGridStep,
isSegmentLongEnough,
+ parseWallDraftLength,
resolveEndpointWallSplit,
snapPointToGrid,
snapScalarToGrid,
diff --git a/packages/editor/src/store/live-draft-preview-stores.test.ts b/packages/editor/src/store/live-draft-preview-stores.test.ts
index 5d9f4c0b0..c4c944cc8 100644
--- a/packages/editor/src/store/live-draft-preview-stores.test.ts
+++ b/packages/editor/src/store/live-draft-preview-stores.test.ts
@@ -33,6 +33,22 @@ describe('live draft preview stores', () => {
unsubscribe()
})
+ test('keeps exact wall length input and parsed metres together, then resets both', () => {
+ useFloorplanDraftPreview.getState().setWallDraftLength('180cm', 1.8)
+
+ expect(useFloorplanDraftPreview.getState()).toMatchObject({
+ wallDraftLengthInput: '180cm',
+ wallDraftLengthMeters: 1.8,
+ })
+
+ useFloorplanDraftPreview.getState().reset()
+
+ expect(useFloorplanDraftPreview.getState()).toMatchObject({
+ wallDraftLengthInput: '',
+ wallDraftLengthMeters: null,
+ })
+ })
+
test('publishes stair point and rotation atomically', () => {
let changes = 0
const unsubscribe = useStairBuildPreview.subscribe(() => {
diff --git a/packages/editor/src/store/use-floorplan-draft-preview.ts b/packages/editor/src/store/use-floorplan-draft-preview.ts
index 8414b82c7..fd58621d9 100644
--- a/packages/editor/src/store/use-floorplan-draft-preview.ts
+++ b/packages/editor/src/store/use-floorplan-draft-preview.ts
@@ -40,6 +40,10 @@ type FloorplanDraftPreviewState = {
wallDraftStart: WallPlanPoint | null
fenceDraftStart: WallPlanPoint | null
roofDraftStart: WallPlanPoint | null
+ /** Exact length entered for the active wall draft, stored in metres. */
+ wallDraftLengthMeters: number | null
+ /** Raw text for the active wall length field; bare values follow the unit toggle. */
+ wallDraftLengthInput: string
polygonDraftType: FloorplanPolygonDraftType | null
polygonDraftPoints: WallPlanPoint[]
/** Set the snapped cursor point. No-ops (skips the store update, so
@@ -54,6 +58,7 @@ type FloorplanDraftPreviewState = {
setWallDraftStart(point: WallPlanPoint | null): void
setFenceDraftStart(point: WallPlanPoint | null): void
setRoofDraftStart(point: WallPlanPoint | null): void
+ setWallDraftLength(input: string, meters: number | null): void
setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void
reset(): void
}
@@ -94,6 +99,8 @@ export const useFloorplanDraftPreview = create((set)
wallDraftStart: null,
fenceDraftStart: null,
roofDraftStart: null,
+ wallDraftLengthMeters: null,
+ wallDraftLengthInput: '',
polygonDraftType: null,
polygonDraftPoints: [],
setCursorPoint: (point) =>
@@ -116,6 +123,12 @@ export const useFloorplanDraftPreview = create((set)
setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)),
setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)),
setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)),
+ setWallDraftLength: (input, meters) =>
+ set((state) =>
+ state.wallDraftLengthInput === input && state.wallDraftLengthMeters === meters
+ ? state
+ : { wallDraftLengthInput: input, wallDraftLengthMeters: meters },
+ ),
setPolygonDraft: (type, points) =>
set((state) =>
state.polygonDraftType === type && planPointsEqual(state.polygonDraftPoints, points)
@@ -132,6 +145,8 @@ export const useFloorplanDraftPreview = create((set)
state.wallDraftStart === null &&
state.fenceDraftStart === null &&
state.roofDraftStart === null &&
+ state.wallDraftLengthMeters === null &&
+ state.wallDraftLengthInput === '' &&
state.polygonDraftType === null &&
state.polygonDraftPoints.length === 0
? state
@@ -144,6 +159,8 @@ export const useFloorplanDraftPreview = create((set)
wallDraftStart: null,
fenceDraftStart: null,
roofDraftStart: null,
+ wallDraftLengthMeters: null,
+ wallDraftLengthInput: '',
polygonDraftType: null,
polygonDraftPoints: [],
},
diff --git a/packages/nodes/src/shared/draft-axis-guides.tsx b/packages/nodes/src/shared/draft-axis-guides.tsx
index 50b52f84d..668e43ecb 100644
--- a/packages/nodes/src/shared/draft-axis-guides.tsx
+++ b/packages/nodes/src/shared/draft-axis-guides.tsx
@@ -7,7 +7,7 @@ import {
type WallPlanPoint,
} from '@pascal-app/editor'
import { Html } from '@react-three/drei'
-import { useMemo } from 'react'
+import { type ChangeEvent, type KeyboardEvent, useMemo } from 'react'
import { BufferGeometry, Vector3 } from 'three'
/**
@@ -251,3 +251,61 @@ export function DraftMeasurementLabel({