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({ ) } + +export function DraftMeasurementInput({ + backgroundColor, + color, + invalid, + label, + onChange, + placeholder, + position, + shadowColor, + value, +}: { + backgroundColor: string + color: string + invalid: boolean + label: string + onChange: (value: string) => void + placeholder: string + position: [number, number, number] + shadowColor: string + value: string +}) { + const stopPropagation = (event: { stopPropagation: () => void; nativeEvent?: Event }) => { + event.stopPropagation() + event.nativeEvent?.stopImmediatePropagation() + } + const handleChange = (event: ChangeEvent) => onChange(event.target.value) + const handleKeyDown = (event: KeyboardEvent) => event.stopPropagation() + + return ( + + + + ) +} diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index b376b8155..e2b46e08d 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -21,6 +21,7 @@ import { CursorSphere, chainEndJoinsExistingWall, clearPlacementSurface, + constrainWallDraftLength, createWallOnCurrentLevel, EDITOR_LAYER, formatAngleRadians, @@ -33,6 +34,7 @@ import { isAngleSnapActive, isMagneticSnapActive, markToolCancelConsumed, + parseWallDraftLength, publishHorizontalConstructionPlane, publishPlacementSurface, resampleTerrainConstructionPlane, @@ -59,6 +61,7 @@ import { type DraftAngleLabel, type DraftAxisGuideState, DraftAxisGuides, + DraftMeasurementInput, DraftMeasurementLabel, getNearestAxisAngleLabel, } from '../shared/draft-axis-guides' @@ -483,12 +486,29 @@ export const WallTool: React.FC = () => { const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) + const draftLengthInput = useFloorplanDraftPreview((state) => state.wallDraftLengthInput) + const draftLengthMeters = useFloorplanDraftPreview((state) => state.wallDraftLengthMeters) + const draftLengthInvalid = draftLengthInput.trim().length > 0 && draftLengthMeters === null const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' // Clear preset-seeded defaults on deactivation so a later manual wall draw // isn't built with a stale preset's parameters. Unmount-only. - useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), []) + useEffect( + () => () => { + useEditor.getState().setToolDefaults('wall', null) + useFloorplanDraftPreview.getState().setWallDraftLength('', null) + }, + [], + ) + + useEffect(() => { + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setWallDraftLength( + draftPreview.wallDraftLengthInput, + parseWallDraftLength(draftPreview.wallDraftLengthInput, unit), + ) + }, [unit]) useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] @@ -609,6 +629,7 @@ export const WallTool: React.FC = () => { const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftStart(null) draftPreview.setWallDraftEnd(null) + draftPreview.setWallDraftLength('', null) if (wallPreviewRef.current) { wallPreviewRef.current.visible = false } @@ -657,12 +678,23 @@ export const WallTool: React.FC = () => { magnetic: isMagneticSnapActive(), }) gridPosition = alignPoint(snapResult.point, { applySnap: !angleLocked }) + const exactLengthMeters = + buildingState.current === 1 + ? useFloorplanDraftPreview.getState().wallDraftLengthMeters + : null + if (buildingState.current === 1) { + gridPosition = constrainWallDraftLength( + [startingPoint.current.x, startingPoint.current.z], + gridPosition, + exactLengthMeters, + ) + } // Stand the magnetic beacon at the endpoint when it locked onto an // existing wall corner / wall point; clear it for plain grid/angle moves. useWallSnapIndicator .getState() .set( - snapResult.snap + snapResult.snap && exactLengthMeters == null ? { x: gridPosition[0], z: gridPosition[1], kind: snapResult.snap } : null, ) @@ -763,6 +795,7 @@ export const WallTool: React.FC = () => { const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftStart(snappedStart) draftPreview.setWallDraftEnd(snappedStart) + draftPreview.setWallDraftLength('', null) setAxisGuide({ origin: snappedStart, endOrigin: null, @@ -776,7 +809,7 @@ export const WallTool: React.FC = () => { setDraftMeasurement(null) } else if (buildingState.current === 1) { const angleLocked = isAngleSnapActive() - const snappedEnd = alignPoint( + let snappedEnd = alignPoint( snapWallDraftPointDetailed({ point: localClick, walls: snapWalls, @@ -786,6 +819,11 @@ export const WallTool: React.FC = () => { }).point, { applySnap: !angleLocked }, ) + snappedEnd = constrainWallDraftLength( + [startingPoint.current.x, startingPoint.current.z], + snappedEnd, + useFloorplanDraftPreview.getState().wallDraftLengthMeters, + ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return @@ -848,6 +886,7 @@ export const WallTool: React.FC = () => { draftPreview.setWallDraftEnd(null) draftPreview.setWallDraftStart(nextStart) draftPreview.setWallDraftEnd(nextStart) + draftPreview.setWallDraftLength('', null) cursorRef.current?.position.copy(startingPoint.current) buildingState.current = 1 setAxisGuide({ @@ -912,11 +951,20 @@ export const WallTool: React.FC = () => { {draftMeasurement && ( <> - + useFloorplanDraftPreview + .getState() + .setWallDraftLength(value, parseWallDraftLength(value, unit)) + } + placeholder={draftMeasurement.lengthLabel} position={draftMeasurement.lengthPosition} shadowColor={measurementShadowColor} + value={draftLengthInput} /> {draftMeasurement.angleLabels.map((angleLabel) => (