fix(editor): area readouts and generated names follow the unit toggle
The metric/imperial preference was already honored by every length input, but two area surfaces were still hardcoded to m²: - the "Load build" import dialog's Floor area stat - the auto-generated default names for zones, slabs, and ceilings (e.g. "Zone (12.3m²)"), which are live display fallbacks Both now convert value + label to the active unit. Adds shared squareMetersToAreaUnit / getAreaUnitLabel / formatAreaLabel helpers to lib/measurements (with tests) and routes the site-panel Area readout through them, replacing an inline 10.7639 magic constant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2cfce3f6b5
commit
682c023be1
+12
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { BuildStats, SchemaIssue, ValidateBuildJsonResult } from '@pascal-app/core'
|
import type { BuildStats, SchemaIssue, ValidateBuildJsonResult } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
AppWindow,
|
AppWindow,
|
||||||
@@ -22,6 +23,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '../../../../../components/ui/primitives/dialog'
|
} from '../../../../../components/ui/primitives/dialog'
|
||||||
|
import {
|
||||||
|
getAreaUnitLabel,
|
||||||
|
type LinearUnit,
|
||||||
|
squareMetersToAreaUnit,
|
||||||
|
} from '../../../../../lib/measurements'
|
||||||
|
|
||||||
export type PendingImport = {
|
export type PendingImport = {
|
||||||
fileName: string
|
fileName: string
|
||||||
@@ -80,15 +86,17 @@ function formatFileSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFloorArea(m2: number): string {
|
function formatFloorArea(m2: number, unit: LinearUnit): string {
|
||||||
if (m2 === 0) return '—'
|
if (m2 === 0) return '—'
|
||||||
if (m2 < 10) return `${m2.toFixed(2)} m²`
|
const value = squareMetersToAreaUnit(m2, unit)
|
||||||
return `${m2.toFixed(1)} m²`
|
const label = getAreaUnitLabel(unit)
|
||||||
|
return `${value < 10 ? value.toFixed(2) : value.toFixed(1)} ${label}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
|
export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
|
||||||
const [showAllWarnings, setShowAllWarnings] = useState(false)
|
const [showAllWarnings, setShowAllWarnings] = useState(false)
|
||||||
const [showSchemaIssues, setShowSchemaIssues] = useState(false)
|
const [showSchemaIssues, setShowSchemaIssues] = useState(false)
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
|
||||||
if (!pending) return null
|
if (!pending) return null
|
||||||
|
|
||||||
@@ -165,7 +173,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
|
|||||||
<div className="flex items-center justify-between border-t px-3 py-2">
|
<div className="flex items-center justify-between border-t px-3 py-2">
|
||||||
<span className="text-muted-foreground text-sm">Floor area</span>
|
<span className="text-muted-foreground text-sm">Floor area</span>
|
||||||
<span className="font-medium text-sm">
|
<span className="font-medium text-sm">
|
||||||
{formatFloorArea(stats.floorAreaM2)}
|
{formatFloorArea(stats.floorAreaM2, unit)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { formatAreaLabel } from './../../../../../lib/measurements'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -32,6 +33,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({
|
|||||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
|
||||||
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,8 +77,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({
|
|||||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||||
|
|
||||||
const area = calculatePolygonArea(polygon).toFixed(1)
|
const defaultName = `Ceiling (${formatAreaLabel(calculatePolygonArea(polygon), unit)})`
|
||||||
const defaultName = `Ceiling (${area}m²)`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
|
|||||||
@@ -39,9 +39,12 @@ import {
|
|||||||
import { getDefaultLevelName } from '@pascal-app/core'
|
import { getDefaultLevelName } from '@pascal-app/core'
|
||||||
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
||||||
import {
|
import {
|
||||||
|
formatAreaLabel,
|
||||||
|
getAreaUnitLabel,
|
||||||
getLinearUnitLabel,
|
getLinearUnitLabel,
|
||||||
linearUnitToMeters,
|
linearUnitToMeters,
|
||||||
metersToLinearUnit,
|
metersToLinearUnit,
|
||||||
|
squareMetersToAreaUnit,
|
||||||
} from './../../../../../lib/measurements'
|
} from './../../../../../lib/measurements'
|
||||||
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
||||||
import { cn } from './../../../../../lib/utils'
|
import { cn } from './../../../../../lib/utils'
|
||||||
@@ -112,7 +115,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
|||||||
const linearLabel = getLinearUnitLabel(viewerUnit)
|
const linearLabel = getLinearUnitLabel(viewerUnit)
|
||||||
const toDisplayLinear = (meters: number) => metersToLinearUnit(meters, viewerUnit)
|
const toDisplayLinear = (meters: number) => metersToLinearUnit(meters, viewerUnit)
|
||||||
const toStoredLinear = (display: number) => linearUnitToMeters(display, viewerUnit)
|
const toStoredLinear = (display: number) => linearUnitToMeters(display, viewerUnit)
|
||||||
const displayArea = isImperial ? area * 10.763_910_417 : area
|
const displayArea = squareMetersToAreaUnit(area, viewerUnit)
|
||||||
const displayPerimeter = toDisplayLinear(perimeter)
|
const displayPerimeter = toDisplayLinear(perimeter)
|
||||||
|
|
||||||
const handleToggleEdit = () => {
|
const handleToggleEdit = () => {
|
||||||
@@ -182,7 +185,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
|||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
Area:{' '}
|
Area:{' '}
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
{displayArea.toFixed(1)} {isImperial ? 'ft²' : 'm²'}
|
{displayArea.toFixed(1)} {getAreaUnitLabel(viewerUnit)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
@@ -1117,6 +1120,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
|||||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const setMode = useEditor((state) => state.setMode)
|
const setMode = useEditor((state) => state.setMode)
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
|
||||||
const isSelected = selectedZoneId === zone.id
|
const isSelected = selectedZoneId === zone.id
|
||||||
const isHovered = hoveredId === zone.id
|
const isHovered = hoveredId === zone.id
|
||||||
@@ -1129,8 +1133,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
|||||||
}
|
}
|
||||||
}, [isSelected])
|
}, [isSelected])
|
||||||
|
|
||||||
const area = calculatePolygonArea(zone.polygon).toFixed(1)
|
const defaultName = `Zone (${formatAreaLabel(calculatePolygonArea(zone.polygon), unit)})`
|
||||||
const defaultName = `Zone (${area}m²)`
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setSelection({ zoneId: zone.id })
|
setSelection({ zoneId: zone.id })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import { formatAreaLabel } from './../../../../../lib/measurements'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
@@ -25,6 +26,7 @@ export const SlabTreeNode = memo(function SlabTreeNode({
|
|||||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
@@ -45,8 +47,7 @@ export const SlabTreeNode = memo(function SlabTreeNode({
|
|||||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||||
|
|
||||||
const area = calculatePolygonArea(polygon).toFixed(1)
|
const defaultName = `Slab (${formatAreaLabel(calculatePolygonArea(polygon), unit)})`
|
||||||
const defaultName = `Slab (${area}m²)`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useScene, type ZoneNode } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||||
|
import { formatAreaLabel } from './../../../../../lib/measurements'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
@@ -26,6 +27,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|||||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
|
||||||
const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
|
const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
|
||||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||||
@@ -34,9 +36,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|||||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||||
|
|
||||||
// Calculate approximate area from polygon
|
const defaultName = `Zone (${formatAreaLabel(calculatePolygonArea(polygon), unit)})`
|
||||||
const area = calculatePolygonArea(polygon).toFixed(1)
|
|
||||||
const defaultName = `Zone (${area}m²)`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
|
formatAreaLabel,
|
||||||
formatLinearMeasurement,
|
formatLinearMeasurement,
|
||||||
|
getAreaUnitLabel,
|
||||||
getLinearUnitLabel,
|
getLinearUnitLabel,
|
||||||
linearControlValueToMeters,
|
linearControlValueToMeters,
|
||||||
linearUnitToMeters,
|
linearUnitToMeters,
|
||||||
metersToLinearUnit,
|
metersToLinearUnit,
|
||||||
|
squareMetersToAreaUnit,
|
||||||
} from './measurements'
|
} from './measurements'
|
||||||
|
|
||||||
describe('linear measurements', () => {
|
describe('linear measurements', () => {
|
||||||
@@ -73,3 +76,22 @@ describe('linear measurements', () => {
|
|||||||
expect(getLinearUnitLabel('imperial')).toBe('ft')
|
expect(getLinearUnitLabel('imperial')).toBe('ft')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('area measurements', () => {
|
||||||
|
test('converts square meters to the active area unit', () => {
|
||||||
|
expect(squareMetersToAreaUnit(0, 'imperial')).toBe(0)
|
||||||
|
expect(squareMetersToAreaUnit(12.5, 'metric')).toBe(12.5)
|
||||||
|
expect(squareMetersToAreaUnit(1, 'imperial')).toBeCloseTo(10.7639)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the display label for area readouts', () => {
|
||||||
|
expect(getAreaUnitLabel('metric')).toBe('m²')
|
||||||
|
expect(getAreaUnitLabel('imperial')).toBe('ft²')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formats an area label with value and unit', () => {
|
||||||
|
expect(formatAreaLabel(12.34, 'metric')).toBe('12.3m²')
|
||||||
|
expect(formatAreaLabel(1, 'imperial')).toBe('10.8ft²')
|
||||||
|
expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ export function getLinearUnitLabel(unit: LinearUnit): string {
|
|||||||
return unit === 'imperial' ? 'ft' : 'm'
|
return unit === 'imperial' ? 'ft' : 'm'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
|
||||||
|
|
||||||
|
export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
|
||||||
|
return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAreaUnitLabel(unit: LinearUnit): string {
|
||||||
|
return unit === 'imperial' ? 'ft²' : 'm²'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAreaLabel(
|
||||||
|
squareMeters: number,
|
||||||
|
unit: LinearUnit,
|
||||||
|
fractionDigits = 1,
|
||||||
|
): string {
|
||||||
|
return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
|
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
|
||||||
if (!Number.isFinite(meters)) return '--'
|
if (!Number.isFinite(meters)) return '--'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user