Skip to content
Draft
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
103 changes: 87 additions & 16 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ import {
} from '../tools/stair/stair-defaults'
import {
chainEndJoinsExistingWall,
constrainWallDraftLength,
createWallOnCurrentLevel,
isSegmentLongEnough,
parseWallDraftLength,
resolveTerrainWallConstructionOptions,
snapWallDraftPoint,
snapWallDraftPointDetailed,
Expand Down Expand Up @@ -2374,13 +2376,19 @@ type DraftWallMeasurement = {
}

function FloorplanDraftWallMeasurement({
lengthInput,
lengthInputError,
onLengthInputChange,
measurement,
measurementStroke,
labelBackground,
labelText,
sceneRotationDeg,
unitsPerPixel,
}: {
lengthInput: string
lengthInputError: boolean
onLengthInputChange: (value: string) => void
measurement: DraftWallMeasurement
measurementStroke: string
labelBackground: string
Expand Down Expand Up @@ -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

Expand All @@ -2437,18 +2446,46 @@ function FloorplanDraftWallMeasurement({
x={-lengthPlateW / 2}
y={-lengthPlateH / 2}
/>
<text
dominantBaseline="middle"
fill={labelText}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={600}
textAnchor="middle"
x={0}
y={0}
<foreignObject
aria-label="Wall length input"
height={lengthPlateH}
overflow="visible"
pointerEvents="auto"
width={lengthPlateW}
x={-lengthPlateW / 2}
y={-lengthPlateH / 2}
>
{measurement.lengthLabel}
</text>
<div
className="flex h-full w-full items-center"
onClick={(event) => {
event.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
}}
onPointerDown={(event) => {
event.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
}}
>
<input
aria-invalid={lengthInputError}
aria-label="Wall length"
className={cn(
'h-full w-full rounded border bg-transparent px-1 text-center font-mono font-semibold outline-none',
lengthInputError ? 'border-red-400/80' : 'border-transparent',
)}
inputMode="text"
onChange={(event) => 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}
/>
</div>
</foreignObject>
</g>

{measurement.angleLabels.map((arc) => {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -4935,6 +4984,13 @@ function FloorplanLinearDraftLayer({
<FloorplanDraftWallMeasurement
labelBackground={isDark ? '#0f172a' : '#ffffff'}
labelText={isDark ? '#e2e8f0' : '#171717'}
lengthInput={wallDraftLengthInput}
lengthInputError={wallDraftLengthInputError}
onLengthInputChange={(value) =>
useFloorplanDraftPreview
.getState()
.setWallDraftLength(value, parseWallDraftLength(value, unit))
}
measurement={draftWallMeasurement}
measurementStroke={measurementStroke}
sceneRotationDeg={sceneRotationDeg}
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -9776,7 +9846,7 @@ export function FloorplanPanel({
if (viewIs2DOnly) {
createdWall = createWallOnCurrentLevel(
draftStart,
point,
placementPoint,
wallConstructionOptionsRef.current,
)
}
Expand All @@ -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' ||
Expand Down Expand Up @@ -9834,6 +9904,7 @@ export function FloorplanPanel({

setDraftStart(nextStart)
setDraftEnd(nextStart)
useFloorplanDraftPreview.getState().setWallDraftLength('', null)
setCursorPoint(nextStart)
},
[
Expand Down
17 changes: 17 additions & 0 deletions packages/editor/src/components/tools/wall/wall-drafting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')])
Expand Down
40 changes: 40 additions & 0 deletions packages/editor/src/components/tools/wall/wall-drafting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,11 @@ export {
export { preloadRegistryToolModules, ToolManager } from './components/tools/tool-manager'
export {
chainEndJoinsExistingWall,
constrainWallDraftLength,
createWallOnCurrentLevel,
getSegmentGridStep,
isSegmentLongEnough,
parseWallDraftLength,
resolveEndpointWallSplit,
snapPointToGrid,
snapScalarToGrid,
Expand Down
16 changes: 16 additions & 0 deletions packages/editor/src/store/live-draft-preview-stores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
17 changes: 17 additions & 0 deletions packages/editor/src/store/use-floorplan-draft-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -94,6 +99,8 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
wallDraftStart: null,
fenceDraftStart: null,
roofDraftStart: null,
wallDraftLengthMeters: null,
wallDraftLengthInput: '',
polygonDraftType: null,
polygonDraftPoints: [],
setCursorPoint: (point) =>
Expand All @@ -116,6 +123,12 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((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)
Expand All @@ -132,6 +145,8 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
state.wallDraftStart === null &&
state.fenceDraftStart === null &&
state.roofDraftStart === null &&
state.wallDraftLengthMeters === null &&
state.wallDraftLengthInput === '' &&
state.polygonDraftType === null &&
state.polygonDraftPoints.length === 0
? state
Expand All @@ -144,6 +159,8 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
wallDraftStart: null,
fenceDraftStart: null,
roofDraftStart: null,
wallDraftLengthMeters: null,
wallDraftLengthInput: '',
polygonDraftType: null,
polygonDraftPoints: [],
},
Expand Down
Loading