) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
- const numValue = Number.parseFloat(inputValue)
- if (Number.isNaN(numValue)) {
- setInputValue(value.toFixed(precision))
+ const numValue = isLengthMeasurement
+ ? parseLengthInput(inputValue, unitSystem)
+ : Number.parseFloat(inputValue)
+ if (numValue === null || Number.isNaN(numValue)) {
+ setInputValue(formattedInputValue)
} else {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
}
setIsEditing(false)
- }, [inputValue, onChange, clamp, precision, value])
+ }, [inputValue, isLengthMeasurement, unitSystem, formattedInputValue, onChange, clamp, precision])
const handleInputBlur = useCallback(() => {
submitValue()
@@ -181,21 +199,25 @@ export function MetricControl({
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
- setInputValue(value.toFixed(precision))
+ setInputValue(formattedInputValue)
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
- setInputValue(newV.toFixed(precision))
+ setInputValue(
+ isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision),
+ )
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
- setInputValue(newV.toFixed(precision))
+ setInputValue(
+ isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision),
+ )
}
},
- [submitValue, value, precision, step, clamp, onChange],
+ [submitValue, formattedInputValue, value, step, clamp, isLengthMeasurement, onChange, unitSystem, precision],
)
return (
@@ -221,7 +243,7 @@ export function MetricControl({
{label}
-
+
{isEditing ? (
- {unit && {unit}}
+ {!isLengthMeasurement && unit && (
+ {unit}
+ )}
) : (
-
- {Number(value.toFixed(precision)).toFixed(precision)}
-
- {unit && {unit}}
+ {formattedDisplayValue}
+ {!isLengthMeasurement && unit && (
+ {unit}
+ )}
)}
diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx
index 4a278949..6623d506 100644
--- a/packages/editor/src/components/ui/controls/slider-control.tsx
+++ b/packages/editor/src/components/ui/controls/slider-control.tsx
@@ -1,7 +1,13 @@
'use client'
import { useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ formatLength,
+ formatLengthInputValue,
+ parseLengthInput,
+} from '../../../lib/measurements'
import { cn } from '../../../lib/utils'
interface SliderControlProps {
@@ -27,6 +33,8 @@ export function SliderControl({
className,
unit = '',
}: SliderControlProps) {
+ const unitSystem = useViewer((state) => state.unitSystem)
+ const isLengthMeasurement = unit === 'm'
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
@@ -42,6 +50,12 @@ export function SliderControl({
const valueRef = useRef(value)
valueRef.current = value
+ const formattedInputValue = isLengthMeasurement
+ ? formatLengthInputValue(value, unitSystem)
+ : value.toFixed(precision)
+ const formattedDisplayValue = isLengthMeasurement
+ ? formatLength(value, unitSystem)
+ : Number(value.toFixed(precision)).toFixed(precision)
const clamp = useCallback(
(val: number) => {
@@ -52,9 +66,9 @@ export function SliderControl({
useEffect(() => {
if (!isEditing) {
- setInputValue(value.toFixed(precision))
+ setInputValue(formattedInputValue)
}
- }, [value, precision, isEditing])
+ }, [formattedInputValue, isEditing])
useEffect(() => {
const container = containerRef.current
@@ -177,22 +191,24 @@ export function SliderControl({
const handleValueClick = useCallback(() => {
setIsEditing(true)
- setInputValue(value.toFixed(precision))
- }, [value, precision])
+ setInputValue(formattedInputValue)
+ }, [formattedInputValue])
const handleInputChange = useCallback((e: React.ChangeEvent
) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
- const numValue = Number.parseFloat(inputValue)
- if (Number.isNaN(numValue)) {
- setInputValue(value.toFixed(precision))
+ const numValue = isLengthMeasurement
+ ? parseLengthInput(inputValue, unitSystem)
+ : Number.parseFloat(inputValue)
+ if (numValue === null || Number.isNaN(numValue)) {
+ setInputValue(formattedInputValue)
} else {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
}
setIsEditing(false)
- }, [inputValue, onChange, clamp, precision, value])
+ }, [inputValue, isLengthMeasurement, unitSystem, formattedInputValue, onChange, clamp, precision])
const handleInputBlur = useCallback(() => {
submitValue()
@@ -203,21 +219,25 @@ export function SliderControl({
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
- setInputValue(value.toFixed(precision))
+ setInputValue(formattedInputValue)
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
- setInputValue(newV.toFixed(precision))
+ setInputValue(
+ isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision),
+ )
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
- setInputValue(newV.toFixed(precision))
+ setInputValue(
+ isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision),
+ )
}
},
- [submitValue, value, precision, step, clamp, onChange],
+ [submitValue, formattedInputValue, value, step, clamp, isLengthMeasurement, onChange, unitSystem, precision],
)
const currentMin = isDragging && dragMin !== null ? dragMin : min
@@ -301,7 +321,7 @@ export function SliderControl({
/>
-
+
{isEditing ? (
- {unit && {unit}}
+ {!isLengthMeasurement && unit && (
+ {unit}
+ )}
) : (
-
- {Number(value.toFixed(precision)).toFixed(precision)}
-
- {unit && {unit}}
+ {formattedDisplayValue}
+ {!isLengthMeasurement && unit && (
+ {unit}
+ )}
)}
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx
index b2b6cd3c..da3d990e 100644
--- a/packages/editor/src/components/ui/helpers/helper-manager.tsx
+++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx
@@ -3,6 +3,7 @@
import useEditor from '../../../store/use-editor'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper'
+import { MeasureHelper } from './measure-helper'
import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
@@ -19,6 +20,8 @@ export function HelperManager() {
switch (tool) {
case 'wall':
return
+ case 'measure':
+ return
case 'item':
return
case 'slab':
diff --git a/packages/editor/src/components/ui/helpers/measure-helper.tsx b/packages/editor/src/components/ui/helpers/measure-helper.tsx
new file mode 100644
index 00000000..c30da50f
--- /dev/null
+++ b/packages/editor/src/components/ui/helpers/measure-helper.tsx
@@ -0,0 +1,18 @@
+export function MeasureHelper() {
+ return (
+
+
+ Shift
+ Allow non-45° angles
+
+
+ Tab
+ Type a measurement distance
+
+
+ Esc
+ Cancel the current measurement draft
+
+
+ )
+}
diff --git a/packages/editor/src/components/ui/helpers/slab-helper.tsx b/packages/editor/src/components/ui/helpers/slab-helper.tsx
index bf9b6aad..6104a7e1 100644
--- a/packages/editor/src/components/ui/helpers/slab-helper.tsx
+++ b/packages/editor/src/components/ui/helpers/slab-helper.tsx
@@ -5,6 +5,10 @@ export function SlabHelper() {
Shift
Allow non-45° angles
+
+ Tab
+ Type an exact segment length
+
Esc
Cancel
diff --git a/packages/editor/src/components/ui/helpers/wall-helper.tsx b/packages/editor/src/components/ui/helpers/wall-helper.tsx
index f15b3ac1..3d04cca4 100644
--- a/packages/editor/src/components/ui/helpers/wall-helper.tsx
+++ b/packages/editor/src/components/ui/helpers/wall-helper.tsx
@@ -5,6 +5,10 @@ export function WallHelper() {
Shift
Allow non-45° angles
+
+ Tab
+ Type an exact wall length
+
Esc
Cancel
diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx
index 178e4945..b33ae9f0 100644
--- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx
+++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx
@@ -4,6 +4,7 @@ import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
+import { formatArea } from '../../../lib/measurements'
import useEditor from '../../../store/use-editor'
import { ActionButton } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
@@ -12,6 +13,7 @@ import { PanelWrapper } from './panel-wrapper'
export function CeilingPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const unitSystem = useViewer((s) => s.unitSystem)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
@@ -139,7 +141,7 @@ export function CeilingPanel() {
Area
- {area.toFixed(2)} m²
+ {formatArea(area, unitSystem)}
@@ -166,7 +168,7 @@ export function CeilingPanel() {
Hole {index + 1} {isEditing && '(Editing)'}
- {holeArea.toFixed(2)} m² · {hole.length} pts
+ {formatArea(holeArea, unitSystem)} · {hole.length} pts
diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx
index 8dd543f2..542c683e 100644
--- a/packages/editor/src/components/ui/panels/panel-manager.tsx
+++ b/packages/editor/src/components/ui/panels/panel-manager.tsx
@@ -2,6 +2,7 @@
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
+import { useEffect } from 'react'
import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { DoorPanel } from './door-panel'
@@ -13,19 +14,58 @@ import { WallPanel } from './wall-panel'
import { WindowPanel } from './window-panel'
export function PanelManager() {
- const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const selection = useViewer((s) => s.selection)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
+ const selectedMeasurementGuideId = useEditor((s) => s.selectedMeasurementGuideId)
+ const setSelectedMeasurementGuideId = useEditor((s) => s.setSelectedMeasurementGuideId)
+ const measurementGuides = useEditor((s) => s.measurementGuides)
const nodes = useScene((s) => s.nodes)
+ useEffect(() => {
+ const selectedMeasurementGuide = measurementGuides.find(
+ (guide) => guide.id === selectedMeasurementGuideId,
+ )
+
+ if (
+ selectedMeasurementGuideId &&
+ (selection.selectedIds.length > 0 ||
+ selection.zoneId ||
+ selectedReferenceId ||
+ !selectedMeasurementGuide ||
+ selectedMeasurementGuide.levelId !== selection.levelId)
+ ) {
+ setSelectedMeasurementGuideId(null)
+ }
+ }, [
+ measurementGuides,
+ selectedMeasurementGuideId,
+ selectedReferenceId,
+ selection,
+ setSelectedMeasurementGuideId,
+ ])
+
// Show reference panel if a reference is selected
if (selectedReferenceId) {
return
}
+ const selectedNodes = selection.selectedIds
+ .map((selectedId) => nodes[selectedId as AnyNodeId])
+ .filter(Boolean)
+
+ if (selectedNodes.length !== selection.selectedIds.length) {
+ return null
+ }
+
+ const selectedTypes = new Set(selectedNodes.map((node) => node.type))
+
+ if (selectedTypes.size === 1 && selectedNodes[0]?.type === 'wall') {
+ return
+ }
+
// Show appropriate panel based on selected node type
- if (selectedIds.length === 1) {
- const selectedNode = selectedIds[0]
- const node = nodes[selectedNode as AnyNodeId]
+ if (selection.selectedIds.length === 1) {
+ const node = selectedNodes[0]
if (node) {
switch (node.type) {
case 'item':
@@ -36,8 +76,6 @@ export function PanelManager() {
return
case 'ceiling':
return
- case 'wall':
- return
case 'door':
return
case 'window':
diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx
index 38bab922..882e3175 100644
--- a/packages/editor/src/components/ui/panels/roof-panel.tsx
+++ b/packages/editor/src/components/ui/panels/roof-panel.tsx
@@ -1,29 +1,64 @@
'use client'
-import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
+import { type AnyNode, type AnyNodeId, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
+import { formatLength } from '../../../lib/measurements'
+import { getRoofDimensions } from '../../../lib/roof-dimensions'
import { ActionButton } from '../controls/action-button'
-import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function RoofPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const unitSystem = useViewer((s) => s.unitSystem)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
- const updateNode = useScene((s) => s.updateNode)
+ const updateNodes = useScene((s) => s.updateNodes)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
const handleUpdate = useCallback(
- (updates: Partial
) => {
+ (
+ updates: Partial & {
+ length?: number
+ height?: number
+ leftWidth?: number
+ rightWidth?: number
+ },
+ ) => {
if (!selectedId) return
- updateNode(selectedId as AnyNode['id'], updates)
+
+ const roof = nodes[selectedId as AnyNode['id']] as RoofNode | undefined
+ if (!roof) return
+
+ const dimensions = getRoofDimensions(roof, nodes)
+ const nextLeftWidth = updates.leftWidth ?? dimensions.leftWidth
+ const nextRightWidth = updates.rightWidth ?? dimensions.rightWidth
+
+ const batchedUpdates: Array<{ id: AnyNodeId; data: Partial }> = [
+ {
+ id: selectedId as AnyNodeId,
+ data: updates as Partial,
+ },
+ ]
+
+ if (dimensions.primarySegment) {
+ batchedUpdates.push({
+ id: dimensions.primarySegment.id as AnyNodeId,
+ data: {
+ width: updates.length ?? dimensions.primarySegment.width,
+ depth: nextLeftWidth + nextRightWidth,
+ roofHeight: updates.height ?? dimensions.primarySegment.roofHeight,
+ } as Partial,
+ })
+ }
+
+ updateNodes(batchedUpdates)
},
- [selectedId, updateNode],
+ [nodes, selectedId, updateNodes],
)
const handleClose = useCallback(() => {
@@ -32,7 +67,7 @@ export function RoofPanel() {
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
- const totalWidth = node.leftWidth + node.rightWidth
+ const { height, leftWidth, length, rightWidth, totalWidth } = getRoofDimensions(node, nodes)
return (
Widths
- Total: {totalWidth.toFixed(1)}m
+ Total: {formatLength(totalWidth, unitSystem)}
diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx
index 5e3a989a..76fcae56 100644
--- a/packages/editor/src/components/ui/panels/slab-panel.tsx
+++ b/packages/editor/src/components/ui/panels/slab-panel.tsx
@@ -4,6 +4,7 @@ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
+import { formatArea } from '../../../lib/measurements'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
@@ -12,6 +13,7 @@ import { PanelWrapper } from './panel-wrapper'
export function SlabPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const unitSystem = useViewer((s) => s.unitSystem)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
@@ -138,7 +140,7 @@ export function SlabPanel() {
Area
- {area.toFixed(2)} m²
+ {formatArea(area, unitSystem)}
@@ -165,7 +167,7 @@ export function SlabPanel() {
Hole {index + 1} {isEditing && '(Editing)'}
- {holeArea.toFixed(2)} m² · {hole.length} pts
+ {formatArea(holeArea, unitSystem)} · {hole.length} pts
diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx
index aa210617..816c5843 100644
--- a/packages/editor/src/components/ui/panels/wall-panel.tsx
+++ b/packages/editor/src/components/ui/panels/wall-panel.tsx
@@ -3,76 +3,158 @@
import { type AnyNode, type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
+import { formatLength, METERS_PER_INCH } from '../../../lib/measurements'
import { PanelSection } from '../controls/panel-section'
+import { MetricControl } from '../controls/metric-control'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
+const DEFAULT_WALL_HEIGHT = 2.5
+const DEFAULT_WALL_THICKNESS = 0.1
+const VALUE_TOLERANCE = 1e-4
+
+const getWallLength = (wall: WallNode) => {
+ const dx = wall.end[0] - wall.start[0]
+ const dz = wall.end[1] - wall.start[1]
+ return Math.sqrt(dx * dx + dz * dz)
+}
+
+const getUniformValue = (values: number[]) => {
+ if (values.length === 0) return null
+
+ const firstValue = values[0]!
+ return values.every((value) => Math.abs(value - firstValue) <= VALUE_TOLERANCE) ? firstValue : null
+}
+
export function WallPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const unitSystem = useViewer((s) => s.unitSystem)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
- const updateNode = useScene((s) => s.updateNode)
+ const updateNodes = useScene((s) => s.updateNodes)
- const selectedId = selectedIds[0]
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
+ const wallNodes = selectedIds
+ .map((selectedId) => nodes[selectedId as AnyNode['id']])
+ .filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
- const handleUpdate = useCallback(
- (updates: Partial
) => {
- if (!selectedId) return
- updateNode(selectedId as AnyNode['id'], updates)
- useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
+ const node = wallNodes[0]
+ const isSingleWall = wallNodes.length === 1
+ const selectionCount = wallNodes.length
+
+ const handleBatchUpdate = useCallback(
+ (getUpdates: (wall: WallNode) => Partial) => {
+ if (wallNodes.length === 0) return
+
+ updateNodes(wallNodes.map((wall) => ({ id: wall.id, data: getUpdates(wall) })))
+ for (const wall of wallNodes) {
+ useScene.getState().dirtyNodes.add(wall.id as AnyNodeId)
+ }
},
- [selectedId, updateNode],
+ [updateNodes, wallNodes],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
- if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
+ if (!node || wallNodes.length !== selectedIds.length) return null
+
+ const height = getUniformValue(wallNodes.map((wall) => wall.height ?? DEFAULT_WALL_HEIGHT))
+ const thickness = getUniformValue(wallNodes.map((wall) => wall.thickness ?? DEFAULT_WALL_THICKNESS))
+ const length = getUniformValue(wallNodes.map(getWallLength))
+ const hasMixedDimensionValues = height === null || thickness === null
+ const title = isSingleWall ? node.name || 'Wall' : `${selectionCount} Walls`
- const dx = node.end[0] - node.start[0]
- const dz = node.end[1] - node.start[1]
- const length = Math.sqrt(dx * dx + dz * dz)
+ const handleLengthChange = useCallback(
+ (nextLength: number) => {
+ const resolvedLength = Math.max(METERS_PER_INCH, nextLength)
- const height = node.height ?? 2.5
- const thickness = node.thickness ?? 0.1
+ handleBatchUpdate((wall) => {
+ const directionX = wall.end[0] - wall.start[0]
+ const directionZ = wall.end[1] - wall.start[1]
+ const currentLength = Math.hypot(directionX, directionZ)
+ const unitX = currentLength > 1e-6 ? directionX / currentLength : 1
+ const unitZ = currentLength > 1e-6 ? directionZ / currentLength : 0
+
+ return {
+ end: [wall.start[0] + unitX * resolvedLength, wall.start[1] + unitZ * resolvedLength],
+ }
+ })
+ },
+ [handleBatchUpdate],
+ )
return (
- handleUpdate({ height: Math.max(0.1, v) })}
- precision={2}
- step={0.1}
- unit="m"
- value={Math.round(height * 100) / 100}
- />
- handleUpdate({ thickness: Math.max(0.05, v) })}
- precision={3}
- step={0.01}
- unit="m"
- value={Math.round(thickness * 1000) / 1000}
- />
+ {height !== null && (
+
+ handleBatchUpdate(() => ({ height: Math.max(0.1, value) }))
+ }
+ precision={2}
+ step={0.1}
+ unit="m"
+ value={Math.round(height * 100) / 100}
+ />
+ )}
+ {thickness !== null && (
+
+ handleBatchUpdate(() => ({ thickness: Math.max(0.05, value) }))
+ }
+ precision={3}
+ step={0.01}
+ unit="m"
+ value={Math.round(thickness * 1000) / 1000}
+ />
+ )}
+ {!isSingleWall && hasMixedDimensionValues && (
+
+ Only shared wall dimensions are editable in bulk. Mixed values stay read-only until the
+ selection matches.
+
+ )}
-
- Length
- {length.toFixed(2)} m
-
+ {length !== null ? (
+
+ ) : (
+
+ Length
+
+ Mixed lengths
+
+
+ )}
+ {!isSingleWall && length !== null && (
+
+ Double-click Length to set every selected wall to {formatLength(length, unitSystem)} and
+ then drag or type a new shared value.
+
+ )}
)
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx
index 3305ebc9..98686e90 100644
--- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx
@@ -19,6 +19,7 @@ import {
} from './../../../../../components/ui/primitives/dialog'
import { Switch } from './../../../../../components/ui/primitives/switch'
import useEditor from './../../../../../store/use-editor'
+import { SegmentedControl } from '../../../controls/segmented-control'
import { AudioSettingsDialog } from './audio-settings-dialog'
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
@@ -182,6 +183,8 @@ export function SettingsPanel({
const clearScene = useScene((state) => state.clearScene)
const resetSelection = useViewer((state) => state.resetSelection)
const exportScene = useViewer((state) => state.exportScene)
+ const unitSystem = useViewer((state) => state.unitSystem)
+ const setUnitSystem = useViewer((state) => state.setUnitSystem)
const setPhase = useEditor((state) => state.setPhase)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
const sceneGraphValue = useMemo(
@@ -314,6 +317,31 @@ export function SettingsPanel({
)}
+
+
+
+
+
+
Units
+
+ Live dimensions, manual entry, and panel readouts
+
+
+
+ Snap: 1 in
+
+
+
+
+
+
{/* Export Section */}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx
index 0663ebe5..5b2bfb4a 100644
--- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx
@@ -38,6 +38,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{ keys: ['3'], action: 'Switch to Furnish phase' },
{ keys: ['S'], action: 'Switch to Structure layer' },
{ keys: ['F'], action: 'Switch to Furnish layer' },
+ { keys: ['M'], action: 'Jump to the Measure tool' },
{ keys: ['Z'], action: 'Switch to Zones layer' },
{
keys: ['Cmd/Ctrl', 'Arrow Up'],
@@ -79,9 +80,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
shortcuts: [
{
keys: ['Shift'],
- action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings',
+ action: 'Temporarily disable angle snapping while drawing or measuring',
note: 'Hold while drawing.',
},
+ {
+ keys: ['Tab'],
+ action: 'Type an exact wall, slab segment, or measurement distance',
+ note: 'Available while a draw segment is active.',
+ },
],
},
{
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx
index 82db3e49..2cc6a2f0 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx
@@ -2,7 +2,9 @@ import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useEffect, useState } from 'react'
+import { formatArea } from '../../../../../lib/measurements'
import useEditor from './../../../../../store/use-editor'
+import { calculatePolygonArea } from './polygon-math'
import { InlineRenameInput } from './inline-rename-input'
import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
@@ -17,6 +19,7 @@ export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds)
+ const unitSystem = useViewer((state) => state.unitSystem)
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
@@ -63,8 +66,7 @@ export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
}
// Calculate approximate area from polygon
- const area = calculatePolygonArea(node.polygon).toFixed(1)
- const defaultName = `Ceiling (${area}m²)`
+ const defaultName = `Ceiling (${formatArea(calculatePolygonArea(node.polygon), unitSystem)})`
return (
)
}
-
-/**
- * Calculate the area of a polygon using the shoelace formula
- */
-function calculatePolygonArea(polygon: Array<[number, number]>): number {
- if (polygon.length < 3) return 0
-
- let area = 0
- const n = polygon.length
-
- for (let i = 0; i < n; i++) {
- const j = (i + 1) % n
- area += polygon[i]![0] * polygon[j]![1]
- area -= polygon[j]![0] * polygon[i]![1]
- }
-
- return Math.abs(area) / 2
-}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
index 32d9cb4d..4bbf5a0f 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
@@ -28,6 +28,7 @@ import {
} from 'lucide-react'
import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
+import { formatArea, formatLength } from '../../../../../lib/measurements'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
import {
Popover,
@@ -38,6 +39,7 @@ import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload'
import { InlineRenameInput } from './inline-rename-input'
+import { MeasurementGuideTreeNode } from './measurement-guide-tree-node'
import { TreeNode } from './tree-node'
// ============================================================================
@@ -82,6 +84,7 @@ function useSiteNode(): SiteNode | null {
function PropertyLineSection() {
const siteNode = useSiteNode()
const updateNode = useScene((state) => state.updateNode)
+ const unitSystem = useViewer((state) => state.unitSystem)
const mode = useEditor((state) => state.mode)
const setMode = useEditor((state) => state.setMode)
@@ -157,10 +160,10 @@ function PropertyLineSection() {
{/* Measurements */}
- Area: {area.toFixed(1)} m²
+ Area: {formatArea(area, unitSystem)}
- Perimeter: {perimeter.toFixed(1)} m
+ Perimeter: {formatLength(perimeter, unitSystem)}
@@ -986,6 +989,7 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
const updateNode = useScene((state) => state.updateNode)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const hoveredId = useViewer((state) => state.hoveredId)
+ const unitSystem = useViewer((state) => state.unitSystem)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const setPhase = useEditor((state) => state.setPhase)
@@ -1002,8 +1006,7 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
}
}, [isSelected])
- const area = calculatePolygonArea(zone.polygon).toFixed(1)
- const defaultName = `Zone (${area}m²)`
+ const defaultName = `Zone (${formatArea(calculatePolygonArea(zone.polygon), unitSystem)})`
const handleClick = () => {
setSelection({ zoneId: zone.id })
@@ -1168,6 +1171,7 @@ function ContentSection() {
const nodes = useScene((state) => state.nodes)
const selectedLevelId = useViewer((state) => state.selection.levelId)
const structureLayer = useEditor((state) => state.structureLayer)
+ const measurementGuides = useEditor((state) => state.measurementGuides)
const phase = useEditor((state) => state.phase)
const setPhase = useEditor((state) => state.setPhase)
const setMode = useEditor((state) => state.setMode)
@@ -1224,19 +1228,34 @@ function ContentSection() {
return true
})
- if (elementChildren.length === 0) {
+ const levelMeasurementGuides = measurementGuides.filter((guide) => guide.levelId === selectedLevelId)
+ const rows = [
+ ...levelMeasurementGuides.map((guide) => ({ type: 'measurement' as const, guide })),
+ ...elementChildren.map((childId) => ({ type: 'node' as const, childId })),
+ ]
+
+ if (rows.length === 0) {
return No elements on this level
}
return (
- {elementChildren.map((childId, index) => (
-
+ {rows.map((row, index) => (
+ row.type === 'measurement' ? (
+
+ ) : (
+
+ )
))}
)
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-guide-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-guide-tree-node.tsx
new file mode 100644
index 00000000..78df9c71
--- /dev/null
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-guide-tree-node.tsx
@@ -0,0 +1,95 @@
+import { useViewer } from '@pascal-app/viewer'
+import { Eye, EyeOff, Ruler, Trash2 } from 'lucide-react'
+import { formatLength } from './../../../../../lib/measurements'
+import useEditor, { type MeasurementGuide } from './../../../../../store/use-editor'
+import { TreeNodeWrapper } from './tree-node'
+
+interface MeasurementGuideTreeNodeProps {
+ guide: MeasurementGuide
+ depth: number
+ isLast?: boolean
+}
+
+function MeasurementGuideTreeActions({ guide }: { guide: MeasurementGuide }) {
+ const selectedMeasurementGuideId = useEditor((state) => state.selectedMeasurementGuideId)
+ const updateMeasurementGuide = useEditor((state) => state.updateMeasurementGuide)
+ const deleteMeasurementGuide = useEditor((state) => state.deleteMeasurementGuide)
+
+ const isVisible = guide.visible !== false
+
+ const handleToggleVisibility = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ updateMeasurementGuide(guide.id, { visible: !isVisible })
+ }
+
+ const handleDelete = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ deleteMeasurementGuide(guide.id)
+ }
+
+ return (
+
+
+
+ {selectedMeasurementGuideId === guide.id && Selected}
+
+ )
+}
+
+export function MeasurementGuideTreeNode({
+ guide,
+ depth,
+ isLast,
+}: MeasurementGuideTreeNodeProps) {
+ const unitSystem = useViewer((state) => state.unitSystem)
+ const setSelection = useViewer((state) => state.setSelection)
+ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
+ const selectedMeasurementGuideId = useEditor((state) => state.selectedMeasurementGuideId)
+ const setSelectedMeasurementGuideId = useEditor((state) => state.setSelectedMeasurementGuideId)
+ const hoveredMeasurementGuideId = useEditor((state) => state.hoveredMeasurementGuideId)
+ const setHoveredMeasurementGuideId = useEditor((state) => state.setHoveredMeasurementGuideId)
+
+ const distance = Math.hypot(guide.end[0] - guide.start[0], guide.end[1] - guide.start[1])
+ const label = `${guide.name ?? 'Measurement'} · ${formatLength(distance, unitSystem)}`
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setSelection({ selectedIds: [], zoneId: null })
+ setSelectedReferenceId(null)
+ setSelectedMeasurementGuideId(guide.id)
+ }
+
+ return (
+ }
+ depth={depth}
+ expanded={false}
+ hasChildren={false}
+ icon={}
+ isHovered={hoveredMeasurementGuideId === guide.id}
+ isLast={isLast}
+ isSelected={selectedMeasurementGuideId === guide.id}
+ isVisible={guide.visible !== false}
+ label={label}
+ nodeId={guide.id}
+ onClick={handleClick}
+ onMouseEnter={() => setHoveredMeasurementGuideId(guide.id)}
+ onMouseLeave={() => setHoveredMeasurementGuideId(null)}
+ onToggle={() => {}}
+ />
+ )
+}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/polygon-math.ts b/packages/editor/src/components/ui/sidebar/panels/site-panel/polygon-math.ts
new file mode 100644
index 00000000..aaf4ad31
--- /dev/null
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/polygon-math.ts
@@ -0,0 +1,14 @@
+export function calculatePolygonArea(polygon: Array<[number, number]>): number {
+ if (polygon.length < 3) return 0
+
+ let area = 0
+ const n = polygon.length
+
+ for (let i = 0; i < n; i++) {
+ const j = (i + 1) % n
+ area += polygon[i]![0] * polygon[j]![1]
+ area -= polygon[j]![0] * polygon[i]![1]
+ }
+
+ return Math.abs(area) / 2
+}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx
index 369cae36..3bea83ca 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx
@@ -1,7 +1,8 @@
-import type { RoofNode } from '@pascal-app/core'
+import { type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useState } from 'react'
+import { getRoofDimensions } from './../../../../../lib/roof-dimensions'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -15,6 +16,7 @@ interface RoofTreeNodeProps {
export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
+ const nodes = useScene((state) => state.nodes)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
@@ -41,9 +43,8 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
setHoveredId(null)
}
- // Calculate dimensions: length × total width (leftWidth + rightWidth)
- const totalWidth = node.leftWidth + node.rightWidth
- const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`
+ const { length, totalWidth } = getRoofDimensions(node, nodes)
+ const sizeLabel = `${length.toFixed(1)}×${totalWidth.toFixed(1)}m`
const defaultName = `Roof (${sizeLabel})`
return (
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx
index fb03c42e..6bba06ba 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx
@@ -2,7 +2,9 @@ import type { SlabNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useState } from 'react'
+import { formatArea } from '../../../../../lib/measurements'
import useEditor from './../../../../../store/use-editor'
+import { calculatePolygonArea } from './polygon-math'
import { InlineRenameInput } from './inline-rename-input'
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
@@ -16,6 +18,7 @@ interface SlabTreeNodeProps {
export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds)
+ const unitSystem = useViewer((state) => state.unitSystem)
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
@@ -42,8 +45,7 @@ export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
}
// Calculate approximate area from polygon
- const area = calculatePolygonArea(node.polygon).toFixed(1)
- const defaultName = `Slab (${area}m²)`
+ const defaultName = `Slab (${formatArea(calculatePolygonArea(node.polygon), unitSystem)})`
return (
)
}
-
-/**
- * Calculate the area of a polygon using the shoelace formula
- */
-function calculatePolygonArea(polygon: Array<[number, number]>): number {
- if (polygon.length < 3) return 0
-
- let area = 0
- const n = polygon.length
-
- for (let i = 0; i < n; i++) {
- const j = (i + 1) % n
- area += polygon[i]![0] * polygon[j]![1]
- area -= polygon[j]![0] * polygon[i]![1]
- }
-
- return Math.abs(area) / 2
-}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx
index 69453d3d..032ff6fd 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx
@@ -1,7 +1,9 @@
import { useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useState } from 'react'
+import { formatArea } from '../../../../../lib/measurements'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
+import { calculatePolygonArea } from './polygon-math'
import { InlineRenameInput } from './inline-rename-input'
import { TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
@@ -15,6 +17,7 @@ interface ZoneTreeNodeProps {
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const updateNode = useScene((state) => state.updateNode)
+ const unitSystem = useViewer((state) => state.unitSystem)
const isSelected = useViewer((state) => state.selection.zoneId === node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
@@ -37,8 +40,7 @@ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
}
// Calculate approximate area from polygon
- const area = calculatePolygonArea(node.polygon).toFixed(1)
- const defaultName = `Zone (${area}m²)`
+ const defaultName = `Zone (${formatArea(calculatePolygonArea(node.polygon), unitSystem)})`
return (
)
}
-
-/**
- * Calculate the area of a polygon using the shoelace formula
- */
-function calculatePolygonArea(polygon: Array<[number, number]>): number {
- if (polygon.length < 3) return 0
-
- let area = 0
- const n = polygon.length
-
- for (let i = 0; i < n; i++) {
- const j = (i + 1) % n
- area += polygon[i]![0] * polygon[j]![1]
- area -= polygon[j]![0] * polygon[i]![1]
- }
-
- return Math.abs(area) / 2
-}
diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts
index 50beff88..3a8f2e01 100644
--- a/packages/editor/src/hooks/use-contextual-tools.ts
+++ b/packages/editor/src/hooks/use-contextual-tools.ts
@@ -16,7 +16,15 @@ export function useContextualTools() {
}
// Default tools when nothing is selected
- const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window']
+ const defaultTools: StructureTool[] = [
+ 'measure',
+ 'wall',
+ 'slab',
+ 'ceiling',
+ 'roof',
+ 'door',
+ 'window',
+ ]
if (selection.selectedIds.length === 0) {
return defaultTools
@@ -29,12 +37,12 @@ export function useContextualTools() {
// If a wall is selected, prioritize wall-hosted elements
if (selectedTypes.has('wall')) {
- return ['window', 'door', 'wall'] as StructureTool[]
+ return ['measure', 'window', 'door', 'wall'] as StructureTool[]
}
// If a slab is selected, prioritize slab editing
if (selectedTypes.has('slab')) {
- return ['slab', 'wall'] as StructureTool[]
+ return ['measure', 'slab', 'wall'] as StructureTool[]
}
// If a ceiling is selected, prioritize ceiling editing
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts
index b558331c..9ba00658 100644
--- a/packages/editor/src/hooks/use-keyboard.ts
+++ b/packages/editor/src/hooks/use-keyboard.ts
@@ -19,6 +19,8 @@ export const useKeyboard = () => {
// Clear selections to close UI panels, but KEEP the active building and level context
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
+ useEditor.getState().setSelectedMeasurementGuideId(null)
+ useEditor.getState().setHoveredMeasurementGuideId(null)
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('site')
@@ -38,6 +40,12 @@ export const useKeyboard = () => {
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('furnish')
+ } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
+ e.preventDefault()
+ useEditor.getState().setPhase('structure')
+ useEditor.getState().setStructureLayer('elements')
+ useEditor.getState().setMode('build')
+ useEditor.getState().setTool('measure')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('structure')
@@ -49,12 +57,12 @@ export const useKeyboard = () => {
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('build')
- } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
- e.preventDefault()
- useScene.temporal.getState().undo()
- } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
+ } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
useScene.temporal.getState().redo()
+ } else if (e.key.toLowerCase() === 'z' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ useScene.temporal.getState().undo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
@@ -91,6 +99,7 @@ export const useKeyboard = () => {
e.preventDefault()
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
+ const { deleteMeasurementGuide, selectedMeasurementGuideId } = useEditor.getState()
if (selectedNodeIds.length > 0) {
// Play appropriate SFX based on what's being deleted
@@ -106,6 +115,9 @@ export const useKeyboard = () => {
}
useScene.getState().deleteNodes(selectedNodeIds)
+ } else if (selectedMeasurementGuideId) {
+ sfxEmitter.emit('sfx:structure-delete')
+ deleteMeasurementGuide(selectedMeasurementGuideId)
}
}
}
diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts
new file mode 100644
index 00000000..b139b408
--- /dev/null
+++ b/packages/editor/src/lib/measurements.ts
@@ -0,0 +1,142 @@
+export type UnitSystem = 'metric' | 'imperial'
+
+export const METERS_PER_INCH = 0.0254
+export const INCHES_PER_FOOT = 12
+export const METERS_PER_FOOT = METERS_PER_INCH * INCHES_PER_FOOT
+export const SQUARE_FEET_PER_SQUARE_METER = 10.763910416709722
+
+const trimFixed = (value: number, precision: number) =>
+ value
+ .toFixed(precision)
+ .replace(/\.0+$/, '')
+ .replace(/(\.\d*?)0+$/, '$1')
+
+const formatMetricLength = (meters: number, precision?: number) => {
+ const resolvedPrecision =
+ precision ?? (Math.abs(meters) >= 10 ? 1 : Math.abs(meters) >= 1 ? 2 : 3)
+ return trimFixed(meters, resolvedPrecision)
+}
+
+const formatImperialLength = (
+ meters: number,
+ options?: {
+ includeZeroInches?: boolean
+ },
+) => {
+ const totalInches = Math.round(Math.abs(meters) / METERS_PER_INCH)
+ const feet = Math.floor(totalInches / INCHES_PER_FOOT)
+ const inches = totalInches % INCHES_PER_FOOT
+ const sign = meters < 0 ? '-' : ''
+
+ if (feet > 0) {
+ if (inches > 0 || options?.includeZeroInches) {
+ return `${sign}${feet}' ${inches}"`
+ }
+ return `${sign}${feet}'`
+ }
+
+ return `${sign}${inches}"`
+}
+
+const parseMetricLength = (value: string) => {
+ const match = value.match(
+ /^(-?\d+(?:\.\d+)?)\s*(mm|millimeters?|cm|centimeters?|m|meters?|metres?)$/,
+ )
+ if (!match) return null
+
+ const amount = Number.parseFloat(match[1]!)
+ const unit = match[2]!
+
+ if (!Number.isFinite(amount)) return null
+ if (unit.startsWith('mm')) return amount / 1000
+ if (unit.startsWith('cm')) return amount / 100
+ return amount
+}
+
+const parseImperialLength = (value: string) => {
+ const normalized = value
+ .replace(/[′’]/g, "'")
+ .replace(/[″”“]/g, '"')
+ .replace(/\b(feet|foot|ft)\b/g, "'")
+ .replace(/\b(inches|inch|in)\b/g, '"')
+ .replace(/\s+/g, ' ')
+ .trim()
+
+ if (!(normalized.includes("'") || normalized.includes('"'))) return null
+
+ const sign = normalized.startsWith('-') ? -1 : 1
+ const feetMatch = normalized.match(/(-?\d+(?:\.\d+)?)\s*'/)
+ const inchesMatch = normalized.match(/(-?\d+(?:\.\d+)?)\s*"/)
+
+ if (!(feetMatch || inchesMatch)) return null
+
+ const feet = Math.abs(Number.parseFloat(feetMatch?.[1] ?? '0'))
+ const inches = Math.abs(Number.parseFloat(inchesMatch?.[1] ?? '0'))
+
+ if (!(Number.isFinite(feet) && Number.isFinite(inches))) return null
+
+ return sign * (feet * METERS_PER_FOOT + inches * METERS_PER_INCH)
+}
+
+export const formatLength = (
+ meters: number,
+ unitSystem: UnitSystem,
+ options?: {
+ compact?: boolean
+ includeZeroInches?: boolean
+ precision?: number
+ },
+) => {
+ if (!Number.isFinite(meters)) return '--'
+
+ if (unitSystem === 'imperial') {
+ return formatImperialLength(meters, {
+ includeZeroInches: options?.includeZeroInches,
+ })
+ }
+
+ const value = formatMetricLength(meters, options?.precision)
+ return options?.compact ? `${value}m` : `${value} m`
+}
+
+export const formatLengthInputValue = (meters: number, unitSystem: UnitSystem) => {
+ if (!Number.isFinite(meters)) return ''
+
+ if (unitSystem === 'imperial') {
+ return formatImperialLength(meters, { includeZeroInches: true })
+ }
+
+ return formatMetricLength(meters)
+}
+
+export const getLengthInputUnitLabel = (unitSystem: UnitSystem) =>
+ unitSystem === 'imperial' ? 'ft/in' : 'm'
+
+export const parseLengthInput = (value: string, preferredUnitSystem: UnitSystem) => {
+ const normalized = value.trim().toLowerCase()
+ if (!normalized) return null
+
+ const imperial = parseImperialLength(normalized)
+ if (imperial !== null) return imperial
+
+ const metric = parseMetricLength(normalized)
+ if (metric !== null) return metric
+
+ const parsed = Number.parseFloat(normalized)
+ if (!Number.isFinite(parsed)) return null
+
+ return preferredUnitSystem === 'imperial' ? parsed * METERS_PER_FOOT : parsed
+}
+
+export const formatArea = (squareMeters: number, unitSystem: UnitSystem) => {
+ if (!Number.isFinite(squareMeters)) return '--'
+
+ if (unitSystem === 'imperial') {
+ const squareFeet = squareMeters * SQUARE_FEET_PER_SQUARE_METER
+ const precision = squareFeet >= 100 ? 0 : squareFeet >= 10 ? 1 : 2
+ return `${trimFixed(squareFeet, precision)} ft²`
+ }
+
+ const precision = squareMeters >= 100 ? 0 : squareMeters >= 10 ? 1 : 2
+ return `${trimFixed(squareMeters, precision)} m²`
+}
diff --git a/packages/editor/src/lib/roof-dimensions.ts b/packages/editor/src/lib/roof-dimensions.ts
new file mode 100644
index 00000000..d258efba
--- /dev/null
+++ b/packages/editor/src/lib/roof-dimensions.ts
@@ -0,0 +1,60 @@
+import type { AnyNode, RoofNode, RoofSegmentNode } from '@pascal-app/core'
+
+type LegacyRoofDimensions = {
+ length?: number
+ height?: number
+ leftWidth?: number
+ rightWidth?: number
+}
+
+const DEFAULT_ROOF_LENGTH = 8
+const DEFAULT_ROOF_HEIGHT = 2.5
+const DEFAULT_ROOF_SIDE_WIDTH = 3
+
+export function getRoofDimensions(
+ roof: RoofNode,
+ nodes: Record,
+): {
+ length: number
+ height: number
+ leftWidth: number
+ rightWidth: number
+ totalWidth: number
+ primarySegment: RoofSegmentNode | null
+} {
+ const legacyRoof = roof as RoofNode & LegacyRoofDimensions
+ const primarySegment =
+ (roof.children ?? [])
+ .map((childId) => nodes[childId])
+ .find((child): child is RoofSegmentNode => child?.type === 'roof-segment') ?? null
+
+ const length =
+ typeof legacyRoof.length === 'number'
+ ? legacyRoof.length
+ : (primarySegment?.width ?? DEFAULT_ROOF_LENGTH)
+ const height =
+ typeof legacyRoof.height === 'number'
+ ? legacyRoof.height
+ : (primarySegment?.roofHeight ?? DEFAULT_ROOF_HEIGHT)
+ const leftWidth =
+ typeof legacyRoof.leftWidth === 'number'
+ ? legacyRoof.leftWidth
+ : primarySegment
+ ? primarySegment.depth / 2
+ : DEFAULT_ROOF_SIDE_WIDTH
+ const rightWidth =
+ typeof legacyRoof.rightWidth === 'number'
+ ? legacyRoof.rightWidth
+ : primarySegment
+ ? primarySegment.depth / 2
+ : DEFAULT_ROOF_SIDE_WIDTH
+
+ return {
+ length,
+ height,
+ leftWidth,
+ rightWidth,
+ totalWidth: leftWidth + rightWidth,
+ primarySegment,
+ }
+}
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index 2542d632..f3819119 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -19,6 +19,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build'
// Structure mode tools (building elements)
export type StructureTool =
+ | 'measure'
| 'wall'
| 'room'
| 'custom-room'
@@ -50,6 +51,16 @@ export type CatalogCategory =
export type StructureLayer = 'zones' | 'elements'
+export type MeasurementGuide = {
+ id: string
+ name?: string
+ levelId: LevelNode['id']
+ levelY: number
+ start: [number, number]
+ end: [number, number]
+ visible?: boolean
+}
+
// Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool
@@ -70,6 +81,15 @@ type EditorState = {
setMovingNode: (node: ItemNode | WindowNode | DoorNode | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
+ selectedMeasurementGuideId: string | null
+ setSelectedMeasurementGuideId: (id: string | null) => void
+ hoveredMeasurementGuideId: string | null
+ setHoveredMeasurementGuideId: (id: string | null) => void
+ measurementGuides: MeasurementGuide[]
+ addMeasurementGuide: (guide: Omit & { id?: string }) => void
+ updateMeasurementGuide: (id: string, updates: Partial) => void
+ deleteMeasurementGuide: (id: string) => void
+ clearMeasurementGuides: (levelId?: LevelNode['id']) => void
// Space detection for cutaway mode
spaces: Record
setSpaces: (spaces: Record) => void
@@ -81,6 +101,14 @@ type EditorState = {
setPreviewMode: (preview: boolean) => void
}
+const createMeasurementGuideId = () => {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID()
+ }
+
+ return `measure-guide-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
+
const useEditor = create()((set, get) => ({
phase: 'site',
setPhase: (phase) => {
@@ -174,6 +202,7 @@ const useEditor = create()((set, get) => ({
selectedIds: [],
zoneId: null,
})
+ set({ selectedMeasurementGuideId: null })
// Ensure a tool is selected in build mode
if (!tool) {
@@ -209,6 +238,7 @@ const useEditor = create()((set, get) => ({
selectedIds: [],
zoneId: null,
})
+ set({ selectedMeasurementGuideId: null })
},
catalogCategory: null,
setCatalogCategory: (category) => set({ catalogCategory: category }),
@@ -218,6 +248,57 @@ const useEditor = create()((set, get) => ({
setMovingNode: (node) => set({ movingNode: node }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
+ selectedMeasurementGuideId: null,
+ setSelectedMeasurementGuideId: (id) => set({ selectedMeasurementGuideId: id }),
+ hoveredMeasurementGuideId: null,
+ setHoveredMeasurementGuideId: (id) => set({ hoveredMeasurementGuideId: id }),
+ measurementGuides: [],
+ addMeasurementGuide: (guide) =>
+ set((state) => ({
+ measurementGuides: [
+ ...state.measurementGuides,
+ {
+ ...guide,
+ id: guide.id ?? createMeasurementGuideId(),
+ name: guide.name ?? `Measurement ${state.measurementGuides.length + 1}`,
+ visible: guide.visible ?? true,
+ },
+ ],
+ })),
+ updateMeasurementGuide: (id, updates) =>
+ set((state) => ({
+ measurementGuides: state.measurementGuides.map((guide) =>
+ guide.id === id ? { ...guide, ...updates } : guide,
+ ),
+ })),
+ deleteMeasurementGuide: (id) =>
+ set((state) => ({
+ measurementGuides: state.measurementGuides.filter((guide) => guide.id !== id),
+ selectedMeasurementGuideId:
+ state.selectedMeasurementGuideId === id ? null : state.selectedMeasurementGuideId,
+ hoveredMeasurementGuideId:
+ state.hoveredMeasurementGuideId === id ? null : state.hoveredMeasurementGuideId,
+ })),
+ clearMeasurementGuides: (levelId) =>
+ set((state) => {
+ const remainingGuides = levelId
+ ? state.measurementGuides.filter((guide) => guide.levelId !== levelId)
+ : []
+
+ return {
+ measurementGuides: remainingGuides,
+ selectedMeasurementGuideId: remainingGuides.some(
+ (guide) => guide.id === state.selectedMeasurementGuideId,
+ )
+ ? state.selectedMeasurementGuideId
+ : null,
+ hoveredMeasurementGuideId: remainingGuides.some(
+ (guide) => guide.id === state.hoveredMeasurementGuideId,
+ )
+ ? state.hoveredMeasurementGuideId
+ : null,
+ }
+ }),
spaces: {},
setSpaces: (spaces) => set({ spaces }),
editingHole: null,
@@ -225,7 +306,13 @@ const useEditor = create()((set, get) => ({
isPreviewMode: false,
setPreviewMode: (preview) => {
if (preview) {
- set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
+ set({
+ isPreviewMode: true,
+ mode: 'select',
+ tool: null,
+ catalogCategory: null,
+ selectedMeasurementGuideId: null,
+ })
// Clear zone/item selection for clean viewer drill-down hierarchy
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else {
diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx
index 4d43ed19..53f56186 100644
--- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx
+++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx
@@ -1,6 +1,6 @@
import { type RoofSegmentNode, useRegistry } from '@pascal-app/core'
-import { useRef } from 'react'
-import type * as THREE from 'three'
+import { useEffect, useMemo, useRef } from 'react'
+import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
@@ -12,18 +12,27 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors)
+ const placeholderGeometry = useMemo(() => {
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
+ return geometry
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ placeholderGeometry.dispose()
+ }
+ }, [placeholderGeometry])
return (
- {/* RoofSystem will replace this geometry in the next frame */}
-
-
+ />
)
}
diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx
index 01cff317..56d510a4 100644
--- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx
+++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx
@@ -1,6 +1,6 @@
import { type RoofNode, useRegistry } from '@pascal-app/core'
-import { useRef } from 'react'
-import type * as THREE from 'three'
+import { useEffect, useMemo, useRef } from 'react'
+import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
@@ -13,6 +13,17 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors)
+ const placeholderGeometry = useMemo(() => {
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
+ return geometry
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ placeholderGeometry.dispose()
+ }
+ }, [placeholderGeometry])
return (
{
>
-
-
+ />
{(node.children ?? []).map((childId) => (
diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts
index a0f2b65d..ea374846 100644
--- a/packages/viewer/src/store/use-viewer.ts
+++ b/packages/viewer/src/store/use-viewer.ts
@@ -29,6 +29,9 @@ type ViewerState = {
theme: 'light' | 'dark'
setTheme: (theme: 'light' | 'dark') => void
+ unitSystem: 'metric' | 'imperial'
+ setUnitSystem: (unitSystem: 'metric' | 'imperial') => void
+
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
@@ -81,6 +84,9 @@ const useViewer = create()(
theme: 'light',
setTheme: (theme) => set({ theme }),
+ unitSystem: 'metric',
+ setUnitSystem: (unitSystem) => set({ unitSystem }),
+
levelMode: 'stacked',
setLevelMode: (mode) => set({ levelMode: mode }),
@@ -187,6 +193,7 @@ const useViewer = create()(
partialize: (state) => ({
cameraMode: state.cameraMode,
theme: state.theme,
+ unitSystem: state.unitSystem,
levelMode: state.levelMode,
wallMode: state.wallMode,
projectPreferences: state.projectPreferences,
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 00000000..34c3e38b
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "outputDirectory": "apps/editor/.next"
+}