diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index 8dafffb204..b75d0a3f18 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -8,6 +8,8 @@ export const GuideScaleReference = z.object({ realLengthMeters: z.number().positive(), measuredLengthUnits: z.number().positive(), metersPerUnit: z.number().positive(), + displayLength: z.number().positive().max(1_000_000).optional(), + displayUnit: z.enum(['meters', 'centimeters', 'feet', 'inches']).optional(), label: z.string(), }) diff --git a/packages/editor/src/components/editor/floorplan-panel.test.ts b/packages/editor/src/components/editor/floorplan-panel.test.ts index dc3be36eb6..68b6a5b00f 100644 --- a/packages/editor/src/components/editor/floorplan-panel.test.ts +++ b/packages/editor/src/components/editor/floorplan-panel.test.ts @@ -1,6 +1,30 @@ import { expect, test } from 'bun:test' +import { GuideScaleReference } from '@pascal-app/core' +import { z } from 'zod' +import { createGuideScaleReference, formatReferenceScaleLabel } from '../../lib/reference-scale' import { formatFloorplanNumber, parseReferenceScaleLength } from './floorplan-panel' +const Core106GuideScaleReference = z.object({ + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + realLengthMeters: z.number().positive(), + measuredLengthUnits: z.number().positive(), + metersPerUnit: z.number().positive(), + label: z.string(), +}) + +function runtimeScaleReference(overrides: Record = {}): GuideScaleReference { + return { + start: [0, 0], + end: [1, 0], + realLengthMeters: 2.75, + measuredLengthUnits: 1, + metersPerUnit: 2.75, + label: '2.75 m', + ...overrides, + } as unknown as GuideScaleReference +} + test('floor-plan reference scales parse with the scoped locale', () => { expect(parseReferenceScaleLength('1,000', 'meters', 'fr')).toBe(1) expect(parseReferenceScaleLength('1,000', 'meters', 'en')).toBe(1000) @@ -10,3 +34,133 @@ test('floor-plan reference scales display with the scoped locale', () => { expect(formatFloorplanNumber(2.75, 'fr')).toBe('2,75') expect(formatFloorplanNumber(2.75, 'en')).toBe('2.75') }) + +test('reference-scale scenes remain compatible and format numeric and legacy labels', () => { + const reference = createGuideScaleReference( + { + start: [0, 0], + end: [1, 0], + realLengthMeters: 2.75, + measuredLengthUnits: 1, + metersPerUnit: 2.75, + }, + 2.75, + 'meters', + ) + + expect(reference).toEqual({ + start: [0, 0], + end: [1, 0], + realLengthMeters: 2.75, + measuredLengthUnits: 1, + metersPerUnit: 2.75, + displayLength: 2.75, + displayUnit: 'meters', + label: '2.75 m', + }) + expect(Core106GuideScaleReference.safeParse(reference).success).toBe(true) + expect(formatReferenceScaleLabel(reference, 'fr')).toBe('2,75 m') + expect(formatReferenceScaleLabel(reference, 'en')).toBe('2.75 m') + const legacyFrenchReference = GuideScaleReference.parse({ + start: [0, 0], + end: [1, 0], + realLengthMeters: 2.75, + measuredLengthUnits: 1, + metersPerUnit: 2.75, + label: '2,75 m', + }) + expect(formatReferenceScaleLabel(legacyFrenchReference, 'en')).toBe('2.75 m') + expect( + formatReferenceScaleLabel( + GuideScaleReference.parse({ + ...legacyFrenchReference, + label: '2.75 m', + }), + 'fr', + ), + ).toBe('2,75 m') + expect( + formatReferenceScaleLabel( + GuideScaleReference.parse({ + ...legacyFrenchReference, + label: 'Known span', + }), + 'fr', + ), + ).toBe('Known span') +}) + +test('oversized legacy reference labels bypass parsing within the input budget', () => { + const label = `${'1, '.repeat(34_000)}?` + const reference = runtimeScaleReference({ label }) + const startedAt = performance.now() + const formatted = formatReferenceScaleLabel(reference, 'en') + const elapsed = performance.now() - startedAt + + expect(formatted).toBe(label) + expect(elapsed).toBeLessThan(50) +}) + +test('reference-scale labels tolerate malformed persisted values', () => { + expect(formatReferenceScaleLabel(runtimeScaleReference({ label: 42 }), 'en')).toBe('42') + expect(formatReferenceScaleLabel(runtimeScaleReference({ label: null }), 'en')).toBe('') + expect( + formatReferenceScaleLabel( + runtimeScaleReference({ + displayLength: 2.75, + displayUnit: '__proto__', + label: 'fallback', + }), + 'en', + ), + ).toBe('fallback') + expect( + formatReferenceScaleLabel( + runtimeScaleReference({ + displayLength: 2.75, + displayUnit: 'constructor', + label: 'fallback', + }), + 'en', + ), + ).toBe('fallback') + expect( + formatReferenceScaleLabel( + runtimeScaleReference({ + displayLength: Number.POSITIVE_INFINITY, + displayUnit: 'meters', + label: 'fallback', + }), + 'en', + ), + ).toBe('fallback') + expect( + formatReferenceScaleLabel( + runtimeScaleReference({ + displayLength: Number.NaN, + displayUnit: 'meters', + label: 'fallback', + }), + 'en', + ), + ).toBe('fallback') +}) + +test('reference-scale schema caps persisted display lengths', () => { + expect( + GuideScaleReference.safeParse( + runtimeScaleReference({ + displayLength: 1_000_000, + displayUnit: 'meters', + }), + ).success, + ).toBe(true) + expect( + GuideScaleReference.safeParse( + runtimeScaleReference({ + displayLength: 1_000_001, + displayUnit: 'meters', + }), + ).success, + ).toBe(false) +}) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a28adcb285..2734b764c7 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -123,6 +123,11 @@ import { } from '../../lib/keyboard-pan' import { measurementHint, parseMeasurement } from '../../lib/measurement-parser' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' +import { + createGuideScaleReference, + formatReferenceScaleLabel, + type ReferenceScaleUnit, +} from '../../lib/reference-scale' import { snapRegisteredDraftPoint } from '../../lib/registered-draft-snap' import { selectRoomFromHit } from '../../lib/room-selection-commands' import { enterBuildingFromPlanHit } from '../../lib/selection-routing' @@ -528,8 +533,6 @@ type GuideTransformDraft = { rotation: number } -export type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' - // The in-flight reference-scale measurement. Only the per-CLICK fields live // here (guide + start anchor); the rubber-band's moving END is the shared // `useFloorplanDraftPreview.cursorPoint` (set on every move anyway), so it @@ -2503,23 +2506,6 @@ function referenceScaleLengthHint( ) } -function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) { - switch (unit) { - case 'centimeters': - return 'cm' - case 'feet': - return 'ft' - case 'inches': - return 'in' - default: - return 'm' - } -} - -function formatReferenceScaleLabel(value: number, unit: ReferenceScaleUnit, locale: string) { - return `${formatFloorplanNumber(value, locale)} ${getReferenceScaleUnitLabel(unit)}` -} - function getPolygonAreaAndCentroid(polygon: Point2D[]) { let cx = 0 let cy = 0 @@ -3296,6 +3282,7 @@ function FloorplanReferenceScaleLayer({ unit: 'metric' | 'imperial' unitsPerPixel: number }) { + const locale = useLocale() const visibleReferences = guides .filter((guide) => guideUi[guide.id]?.scaleReferenceVisible !== false) .map((guide) => guide.scaleReference) @@ -3305,16 +3292,19 @@ function FloorplanReferenceScaleLayer({ return ( <> - {visibleReferences.map((reference, index) => ( - - ))} + {visibleReferences.map((reference, index) => { + const label = formatReferenceScaleLabel(reference, locale) + return ( + + ) + })} {draft?.start && ( , ) setGuideLocked(pendingReferenceScale.guideId, true) diff --git a/packages/editor/src/components/ui/controls/metric-control.test.ts b/packages/editor/src/components/ui/controls/metric-control.test.ts index b565f096e7..cc04ff9143 100644 --- a/packages/editor/src/components/ui/controls/metric-control.test.ts +++ b/packages/editor/src/components/ui/controls/metric-control.test.ts @@ -1,5 +1,9 @@ import { expect, test } from 'bun:test' -import { formatMetricInputValue, parseMetricInput } from './metric-control' +import { + formatMetricInputValue, + parseMetricInput, + roundMetricValueForDisplay, +} from './metric-control' test('metric controls parse stair dimensions with the scoped locale', () => { expect( @@ -22,3 +26,27 @@ test('metric controls write values back with the scoped decimal separator', () = expect(formatMetricInputValue(2.75, 2, 'fr')).toBe('2,75') expect(formatMetricInputValue(2.75, 2, 'en')).toBe('2.75') }) + +test('metric write-back rounds negative halves away from zero like the display', () => { + expect(roundMetricValueForDisplay(-1.25, 1)).toBe(-1.3) + expect(formatMetricInputValue(-1.25, 1, 'en')).toBe('-1.3') + expect(roundMetricValueForDisplay(1.25, 1)).toBe(1.3) + expect(formatMetricInputValue(1.25, 1, 'en')).toBe('1.3') +}) + +test('metric write-back and display agree for values immediately below a half', () => { + const positive = 1.2499999999999998 + const negative = -positive + + expect(roundMetricValueForDisplay(negative, 1)).toBe(-1.2) + expect(formatMetricInputValue(negative, 1, 'en')).toBe('-1.2') + expect(roundMetricValueForDisplay(positive, 1)).toBe(1.2) + expect(formatMetricInputValue(positive, 1, 'en')).toBe('1.2') +}) + +test('metric write-back preserves Intl rounding for binary-inexact decimal halves', () => { + expect(roundMetricValueForDisplay(-1.005, 2)).toBe(-1.01) + expect(formatMetricInputValue(-1.005, 2, 'en')).toBe('-1.01') + expect(roundMetricValueForDisplay(1.005, 2)).toBe(1.01) + expect(formatMetricInputValue(1.005, 2, 'en')).toBe('1.01') +}) diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index cee440b1a9..4f60e3b606 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -40,12 +40,23 @@ export function formatMetricInputValue( precision: number, locale: string, ): string { - return formatNumber(value, { locale, min: precision, max: precision }) + return formatNumber(roundMetricValueForDisplay(value, precision), { + locale, + min: precision, + max: precision, + }) } -function roundToPrecision(value: number, precision: number): number { - const factor = 10 ** precision - return Math.round((value + Number.EPSILON) * factor) / factor +export function roundMetricValueForDisplay(value: number, precision: number): number { + const rounded = Number( + formatNumber(value, { + locale: 'en', + min: precision, + max: precision, + useGrouping: false, + }), + ) + return Number.isFinite(rounded) ? rounded : value } export function MetricControl({ @@ -80,7 +91,7 @@ export function MetricControl({ ) const roundStoredValueForDisplayPrecision = useCallback( (storedValue: number) => - clamp(toStoredValue(roundToPrecision(toDisplayValue(storedValue), precision))), + clamp(toStoredValue(roundMetricValueForDisplay(toDisplayValue(storedValue), precision))), [clamp, precision, toDisplayValue, toStoredValue], ) diff --git a/packages/editor/src/components/ui/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index 7acb704591..b9c4ac9232 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -6,6 +6,7 @@ import { loadAssetUrl, type ScanNode, saveAsset, + useLocale, useScene, } from '@pascal-app/core' import { @@ -23,6 +24,7 @@ import { import { useCallback, useEffect, useRef, useState } from 'react' import { guideEmitter } from '../../../lib/guide-events' import { getGuideImageName } from '../../../lib/local-guide-image' +import { formatReferenceScaleLabel } from '../../../lib/reference-scale' import { cn } from '../../../lib/utils' import useEditor from '../../../store/use-editor' import { isViewVisible, VIEW_2D } from '../../../store/view-layout' @@ -33,16 +35,20 @@ import { PanelWrapper } from './panel-wrapper' type ReferenceNode = ScanNode | GuideNode -function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) { +function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean, locale: string) { const reference = guide.scaleReference if (!reference) { return 'Uncalibrated' } - return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}` + return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${formatReferenceScaleLabel( + reference, + locale, + )}` } export function ReferencePanel() { + const locale = useLocale() const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) const guideUi = useEditor((s) => @@ -173,7 +179,7 @@ export function ReferencePanel() { const isScan = node.type === 'scan' const guideLocked = !isScan && guideUi?.locked === true const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false - const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible) + const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible, locale) return ( = { + meters: 'm', + centimeters: 'cm', + feet: 'ft', + inches: 'in', +} + +function formatScaleValue(value: number, unit: string, locale: string): string { + return `${formatNumber(value, { locale, max: 2 })} ${unit}` +} + +function getReferenceScaleUnitLabel(unit: unknown): string | null { + if (typeof unit !== 'string' || !Object.hasOwn(REFERENCE_SCALE_UNIT_LABELS, unit)) return null + return REFERENCE_SCALE_UNIT_LABELS[unit as ReferenceScaleUnit] +} + +function parseLegacyScaleLabel(label: string): { unit: string; value: number } | null { + const match = label.trim().match(/^([+−-]?[\d.,]+(?:[\s\u00a0\u202f][\d.,]+)*)\s*(m|cm|ft|in)$/i) + if (!match) return null + + const compact = match[1]!.replace(/[\s\u00a0\u202f]/g, '').replace('−', '-') + const lastDot = compact.lastIndexOf('.') + const lastComma = compact.lastIndexOf(',') + const separatorIndex = Math.max(lastDot, lastComma) + const trailingDigits = separatorIndex === -1 ? 0 : compact.length - separatorIndex - 1 + const decimalSeparator = + separatorIndex !== -1 && trailingDigits > 0 && trailingDigits <= 2 + ? compact[separatorIndex] + : null + const normalized = decimalSeparator + ? `${compact.slice(0, separatorIndex).replace(/[.,]/g, '')}.${compact + .slice(separatorIndex + 1) + .replace(/[.,]/g, '')}` + : compact.replace(/[.,]/g, '') + + if (!/^[+-]?\d+(?:\.\d+)?$/.test(normalized)) return null + const value = Number(normalized) + return Number.isFinite(value) ? { unit: match[2]!.toLowerCase(), value } : null +} + +export function createGuideScaleReference( + reference: Omit, + displayLength: number, + displayUnit: ReferenceScaleUnit, +): GuideScaleReference { + const unitLabel = REFERENCE_SCALE_UNIT_LABELS[displayUnit] + return { + ...reference, + displayLength, + displayUnit, + label: formatScaleValue(displayLength, unitLabel, 'en'), + } +} + +export function formatReferenceScaleLabel(reference: GuideScaleReference, locale: string): string { + const unitLabel = getReferenceScaleUnitLabel(reference.displayUnit) + if ( + typeof reference.displayLength === 'number' && + Number.isFinite(reference.displayLength) && + unitLabel + ) { + return formatScaleValue(reference.displayLength, unitLabel, locale) + } + + const label: unknown = reference.label + if (typeof label !== 'string') return label == null ? '' : String(label) + if (label.length > MAX_REFERENCE_SCALE_LABEL_LENGTH) return label + + const parsed = parseLegacyScaleLabel(label) + return parsed ? formatScaleValue(parsed.value, parsed.unit, locale) : label +}