diff --git a/bun.lock b/bun.lock index 304552a10..c1420e61f 100644 --- a/bun.lock +++ b/bun.lock @@ -131,7 +131,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@pascal-app/lingo": "^0.1.0", + "@pascal-app/lingo": "^0.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -221,7 +221,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "@pascal-app/lingo": "^0.1.0", + "@pascal-app/lingo": "^0.2.0", "zod": "^4.3.5", }, "devDependencies": { @@ -716,7 +716,7 @@ "@pascal-app/ifc-converter": ["@pascal-app/ifc-converter@workspace:packages/ifc-converter"], - "@pascal-app/lingo": ["@pascal-app/lingo@0.1.0", "", { "peerDependencies": { "react": "^19" }, "optionalPeers": ["react"] }, "sha512-j4cN9DSc3QD4LGG003TCIO5+jMYySFpJO6467V0Q3IPumVkwq7/BpEAw8JGY6zTZFmFETIeupuPEi4drnA9m+g=="], + "@pascal-app/lingo": ["@pascal-app/lingo@0.2.0", "", { "peerDependencies": { "react": "^19" }, "optionalPeers": ["react"] }, "sha512-Ws+Utm23SmDbMB0Jc7joIp4KPkXhs4Z1boUcW9dl8nC3ATJz+a50F7/MyVTbE3QaL5Fr+YwNfsiOMxrKdhwPhA=="], "@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"], diff --git a/packages/editor/package.json b/packages/editor/package.json index 481ab3529..2b864afa5 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -26,7 +26,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@pascal-app/lingo": "^0.1.0", + "@pascal-app/lingo": "^0.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx index 28424179e..1b5f7172b 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx @@ -1,4 +1,5 @@ import type { BuildStats, SchemaIssue, ValidateBuildJsonResult } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { AlertTriangle, AppWindow, @@ -22,6 +23,11 @@ import { DialogHeader, DialogTitle, } from '../../../../../components/ui/primitives/dialog' +import { + getAreaUnitLabel, + type LinearUnit, + squareMetersToAreaUnit, +} from '../../../../../lib/measurements' export type PendingImport = { fileName: string @@ -80,15 +86,17 @@ function formatFileSize(bytes: number): string { 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 < 10) return `${m2.toFixed(2)} m²` - return `${m2.toFixed(1)} m²` + const value = squareMetersToAreaUnit(m2, unit) + const label = getAreaUnitLabel(unit) + return `${value < 10 ? value.toFixed(2) : value.toFixed(1)} ${label}` } export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { const [showAllWarnings, setShowAllWarnings] = useState(false) const [showSchemaIssues, setShowSchemaIssues] = useState(false) + const unit = useViewer((state) => state.unit) if (!pending) return null @@ -165,7 +173,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
Floor area - {formatFloorArea(stats.floorAreaM2)} + {formatFloorArea(stats.floorAreaM2, unit)}
)} 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 c71fa6edd..354cd9da6 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 @@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { memo, useCallback, useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' +import { formatAreaLabel } from './../../../../../lib/measurements' import useEditor from './../../../../../store/use-editor' import { InlineRenameInput } from './inline-rename-input' 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 setSelection = useViewer((state) => state.setSelection) 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 useEffect(() => { @@ -75,8 +77,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({ const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStopEditing = useCallback(() => setIsEditing(false), []) - const area = calculatePolygonArea(polygon).toFixed(1) - const defaultName = `Ceiling (${area}m²)` + const defaultName = `Ceiling (${formatAreaLabel(calculatePolygonArea(polygon), unit)})` return ( metersToLinearUnit(meters, 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 handleToggleEdit = () => { @@ -182,7 +185,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
Area:{' '} - {displayArea.toFixed(1)} {isImperial ? 'ft²' : 'm²'} + {displayArea.toFixed(1)} {getAreaUnitLabel(viewerUnit)}
@@ -1117,6 +1120,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa const setHoveredId = useViewer((state) => state.setHoveredId) const setPhase = useEditor((state) => state.setPhase) const setMode = useEditor((state) => state.setMode) + const unit = useViewer((state) => state.unit) const isSelected = selectedZoneId === zone.id const isHovered = hoveredId === zone.id @@ -1129,8 +1133,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa } }, [isSelected]) - const area = calculatePolygonArea(zone.polygon).toFixed(1) - const defaultName = `Zone (${area}m²)` + const defaultName = `Zone (${formatAreaLabel(calculatePolygonArea(zone.polygon), unit)})` const handleClick = () => { setSelection({ zoneId: zone.id }) 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 627dc8dd9..bd5339c78 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,6 +2,7 @@ import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { memo, useCallback, useState } from 'react' +import { formatAreaLabel } from './../../../../../lib/measurements' import useEditor from './../../../../../store/use-editor' import { InlineRenameInput } from './inline-rename-input' 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 setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) + const unit = useViewer((state) => state.unit) const handleClick = useCallback( (e: React.MouseEvent) => { @@ -45,8 +47,7 @@ export const SlabTreeNode = memo(function SlabTreeNode({ const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStopEditing = useCallback(() => setIsEditing(false), []) - const area = calculatePolygonArea(polygon).toFixed(1) - const defaultName = `Slab (${area}m²)` + const defaultName = `Slab (${formatAreaLabel(calculatePolygonArea(polygon), unit)})` return ( state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) + const unit = useViewer((state) => state.unit) const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection]) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) @@ -34,9 +36,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({ const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStopEditing = useCallback(() => setIsEditing(false), []) - // Calculate approximate area from polygon - const area = calculatePolygonArea(polygon).toFixed(1) - const defaultName = `Zone (${area}m²)` + const defaultName = `Zone (${formatAreaLabel(calculatePolygonArea(polygon), unit)})` return ( { @@ -73,3 +76,22 @@ describe('linear measurements', () => { 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²') + }) +}) diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts index d53e61f4c..76e31338c 100644 --- a/packages/editor/src/lib/measurements.ts +++ b/packages/editor/src/lib/measurements.ts @@ -32,6 +32,24 @@ export function getLinearUnitLabel(unit: LinearUnit): string { 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 { if (!Number.isFinite(meters)) return '--' diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 13cadbb3b..704b08692 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -59,7 +59,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "@pascal-app/lingo": "^0.1.0", + "@pascal-app/lingo": "^0.2.0", "zod": "^4.3.5" }, "devDependencies": {