Merge pull request #477 from pascalorg/fix/unit-labels-lingo-0.2

fix(editor): area readouts + generated names follow the unit toggle; bump lingo to 0.2.0
This commit is contained in:
Aymeric Rabot
2026-07-08 22:51:49 +02:00
committed by GitHub
10 changed files with 73 additions and 20 deletions
+3 -3
View File
@@ -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"],
+1 -1
View File
@@ -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",
@@ -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)}`
return `${m2.toFixed(1)}`
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) {
<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="font-medium text-sm">
{formatFloorArea(stats.floorAreaM2)}
{formatFloorArea(stats.floorAreaM2, unit)}
</span>
</div>
)}
@@ -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 (
<TreeNodeWrapper
@@ -39,9 +39,12 @@ import {
import { getDefaultLevelName } from '@pascal-app/core'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import {
formatAreaLabel,
getAreaUnitLabel,
getLinearUnitLabel,
linearUnitToMeters,
metersToLinearUnit,
squareMetersToAreaUnit,
} from './../../../../../lib/measurements'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import { cn } from './../../../../../lib/utils'
@@ -112,7 +115,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
const linearLabel = getLinearUnitLabel(viewerUnit)
const toDisplayLinear = (meters: number) => 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() {
<div className="text-muted-foreground text-xs">
Area:{' '}
<span className="text-foreground">
{displayArea.toFixed(1)} {isImperial ? 'ft²' : 'm²'}
{displayArea.toFixed(1)} {getAreaUnitLabel(viewerUnit)}
</span>
</div>
<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 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 })
@@ -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 (
<TreeNodeWrapper
@@ -2,6 +2,7 @@ import { useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo, useCallback, useState } from 'react'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
import { formatAreaLabel } from './../../../../../lib/measurements'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
@@ -26,6 +27,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
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(() => 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 (
<TreeNodeWrapper
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'bun:test'
import {
formatAreaLabel,
formatLinearMeasurement,
getAreaUnitLabel,
getLinearUnitLabel,
linearControlValueToMeters,
linearUnitToMeters,
metersToLinearUnit,
squareMetersToAreaUnit,
} from './measurements'
describe('linear measurements', () => {
@@ -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²')
})
})
+18
View File
@@ -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 '--'
+1 -1
View File
@@ -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": {