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
7 changes: 0 additions & 7 deletions .env.example

This file was deleted.

76 changes: 61 additions & 15 deletions packages/editor/src/components/tools/wall/wall-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { formatMeasurement, parseMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
Expand All @@ -30,18 +32,6 @@ type DraftMeasurementState = {
angleLabels: DraftAngleLabel[]
} | null

function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}

return `${Number.parseFloat(value.toFixed(2))}m`
}

function getDraftAngleLabels(
start: WallPlanPoint,
end: WallPlanPoint,
Expand Down Expand Up @@ -162,6 +152,8 @@ export const WallTool: React.FC = () => {
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const draftInputRef = useRef<string | null>(null)
const [draftInput, setDraftInput] = useState<string | null>(null)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)

useEffect(() => {
Expand Down Expand Up @@ -246,6 +238,45 @@ export const WallTool: React.FC = () => {
if (e.key === 'Shift') {
shiftPressed.current = true
}

if (buildingState.current === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
if (/^[\d.'"-]$/.test(e.key)) {
const next = (draftInputRef.current || '') + e.key
draftInputRef.current = next
setDraftInput(next)
} else if (e.key === 'Backspace') {
if (draftInputRef.current) {
const next = draftInputRef.current.slice(0, -1)
draftInputRef.current = next || null
setDraftInput(draftInputRef.current)
}
} else if (e.key === 'Enter' && draftInputRef.current) {
const parsed = parseMeasurement(
draftInputRef.current,
unit === 'imperial' ? 'imperial' : 'metric',
)
if (parsed !== null && parsed > 0.01) {
const start = startingPoint.current
const end = endingPoint.current
const dx = end.x - start.x
const dz = end.z - start.z
const currentLength = Math.hypot(dx, dz)

if (currentLength > 0.001) {
const newEnd: WallPlanPoint = [
start.x + (dx / currentLength) * parsed,
start.z + (dz / currentLength) * parsed,
]
createWallOnCurrentLevel([start.x, start.z], newEnd)
buildingState.current = 0
wallPreviewRef.current.visible = false
draftInputRef.current = null
setDraftInput(null)
setDraftMeasurement(null)
}
}
}
}
}

const onKeyUp = (e: KeyboardEvent) => {
Expand All @@ -255,7 +286,11 @@ export const WallTool: React.FC = () => {
}

const onCancel = () => {
if (buildingState.current === 1) {
if (draftInputRef.current !== null) {
markToolCancelConsumed()
draftInputRef.current = null
setDraftInput(null)
} else if (buildingState.current === 1) {
markToolCancelConsumed()
buildingState.current = 0
wallPreviewRef.current.visible = false
Expand Down Expand Up @@ -299,8 +334,9 @@ export const WallTool: React.FC = () => {
{draftMeasurement && (
<>
<DraftMeasurementLabel
label={draftMeasurement.lengthLabel}
label={draftInput !== null ? draftInput : draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition}
isTyping={draftInput !== null}
/>
{draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel
Expand All @@ -318,14 +354,24 @@ export const WallTool: React.FC = () => {
function DraftMeasurementLabel({
label,
position,
isTyping,
}: {
label: string
position: [number, number, number]
isTyping?: boolean
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
<div
className={cn(
'whitespace-nowrap rounded-full border border-border px-2 py-1 font-mono font-semibold text-[11px] shadow-lg backdrop-blur-md',
isTyping
? 'bg-primary text-primary-foreground border-transparent ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
: 'bg-background/95 text-foreground',
)}
>
{label}
{isTyping && <span className="animate-pulse">_</span>}
</div>
</Html>
)
Expand Down
80 changes: 53 additions & 27 deletions packages/editor/src/components/ui/controls/slider-control.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client'

import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { formatMeasurement, parseMeasurement } from '../../../lib/measurements'
import { cn } from '../../../lib/utils'

interface SliderControlProps {
Expand Down Expand Up @@ -59,10 +61,25 @@ export function SliderControl({
unit = '',
restoreOnCommit = true,
}: SliderControlProps) {
const viewerUnit = useViewer((state) => state.unit)
const isLengthMeasurement = unit === 'm'
const activeUnit = isLengthMeasurement ? viewerUnit : 'metric'
const multiplier = isLengthMeasurement && activeUnit === 'imperial' ? 3.28084 : 1
const displayUnit = isLengthMeasurement && activeUnit === 'imperial' ? '' : unit

const getDisplayStr = useCallback(
(v: number) => {
return isLengthMeasurement && activeUnit === 'imperial'
? formatMeasurement(v, 'imperial', precision)
: v.toFixed(precision)
},
[isLengthMeasurement, activeUnit, precision],
)

const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const [inputValue, setInputValue] = useState(getDisplayStr(value))

const dragRef = useRef<{
// Original value at drag start — preserved across modifier re-anchors so
Expand All @@ -83,9 +100,9 @@ export function SliderControl({

useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
setInputValue(getDisplayStr(value))
}
}, [value, precision, isEditing])
}, [value, getDisplayStr, isEditing])

// Wheel support on the label
useEffect(() => {
Expand All @@ -95,7 +112,7 @@ export function SliderControl({
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
const s = getAdjustedStep(step, e)
const s = getAdjustedStep(step / multiplier, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
Expand All @@ -114,7 +131,7 @@ export function SliderControl({
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) {
e.preventDefault()
const s = getAdjustedStep(step, e)
const s = getAdjustedStep(step / multiplier, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
Expand Down Expand Up @@ -145,20 +162,20 @@ export function SliderControl({
const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const multiplier = getStepMultiplier(e)
const multiplier_val = getStepMultiplier(e)
// If modifier keys changed mid-drag, re-anchor from the current pointer
// position and value — otherwise the accumulated dx would be applied
// with a new step size and jump the value (e.g. pressing Cmd while
// already far from the starting point would snap back toward it).
if (multiplier !== dragRef.current.stepMultiplier) {
if (multiplier_val !== dragRef.current.stepMultiplier) {
dragRef.current.anchorX = e.clientX
dragRef.current.anchorValue = valueRef.current
dragRef.current.stepMultiplier = multiplier
dragRef.current.stepMultiplier = multiplier_val
return
}
const { anchorX, anchorValue } = dragRef.current
const dx = e.clientX - anchorX
const s = step * multiplier
const s = (step / multiplier) * multiplier_val
// 4 px per step at default sensitivity
const newValue = clamp(
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
Expand Down Expand Up @@ -195,47 +212,54 @@ export function SliderControl({

const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
setInputValue(getDisplayStr(value))
}, [value, getDisplayStr])

const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (Number.isNaN(numValue)) {
setInputValue(value.toFixed(precision))
let nextValue: number | null = null
if (isLengthMeasurement && activeUnit === 'imperial') {
nextValue = parseMeasurement(inputValue, 'imperial')
} else {
const numValue = Number.parseFloat(inputValue)
nextValue = Number.isNaN(numValue) ? null : numValue
}

if (nextValue === null) {
setInputValue(getDisplayStr(value))
} else {
const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
onChange(nextValue)
onCommit?.(nextValue)
const clamped = clamp(Number.parseFloat(nextValue.toFixed(precision + 2)))
onChange(clamped)
onCommit?.(clamped)
}
setIsEditing(false)
}, [inputValue, onChange, onCommit, clamp, precision, value])
}, [inputValue, onChange, onCommit, clamp, precision, value, isLengthMeasurement, activeUnit, getDisplayStr])

const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setInputValue(getDisplayStr(value))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const adjustedStep = getAdjustedStep(step, e)
const adjustedStep = getAdjustedStep(step / multiplier, e)
const newV = clamp(
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
setInputValue(getDisplayStr(newV))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const adjustedStep = getAdjustedStep(step, e)
const adjustedStep = getAdjustedStep(step / multiplier, e)
const newV = clamp(
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
setInputValue(getDisplayStr(newV))
}
},
[submitValue, value, precision, step, clamp, onChange],
[submitValue, value, getDisplayStr, step, multiplier, clamp, onChange],
)

return (
Expand Down Expand Up @@ -288,17 +312,19 @@ export function SliderControl({
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
</>
) : (
<div
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
{Number(value.toFixed(precision)).toFixed(precision)}
{isLengthMeasurement && activeUnit === 'imperial'
? formatMeasurement(value, 'imperial', precision)
: Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
</div>
)}
</div>
Expand Down
46 changes: 46 additions & 0 deletions packages/editor/src/lib/measurements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export function parseMeasurement(input: string, unit: 'metric' | 'imperial'): number | null {
if (unit === 'metric') {
const val = Number.parseFloat(input)
return Number.isNaN(val) ? null : val
}

// Handle imperial format: e.g., 10, 10', 10' 6", 6"
const feetInchesRegex = /^\s*(?:(\d+(?:\.\d+)?)\s*')?\s*(?:(\d+(?:\.\d+)?)\s*")?\s*$/
const match = input.match(feetInchesRegex)

if (match) {
if (!match[1] && !match[2]) {
// It's just a raw number like "10" or "5.5"
const val = Number.parseFloat(input)
if (Number.isNaN(val)) return null
return val / 3.28084
}

const feet = match[1] ? Number.parseFloat(match[1]) : 0
const inches = match[2] ? Number.parseFloat(match[2]) : 0

const totalFeet = feet + inches / 12
return totalFeet / 3.28084
}

return null
}

export function formatMeasurement(
valueInMeters: number,
unit: 'metric' | 'imperial',
precision = 2,
): string {
if (unit === 'metric') {
return Number.parseFloat(valueInMeters.toFixed(precision)).toFixed(precision)
}

const feet = valueInMeters * 3.28084
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)

if (inches === 12) return `${wholeFeet + 1}'0"`
if (wholeFeet === 0) return `${inches}"`
if (inches === 0) return `${wholeFeet}'`
return `${wholeFeet}'${inches}"`
}