From f7c6c32957d695953ee9b5d024f2e521cbb0b5a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 01:20:06 +0000 Subject: [PATCH 1/6] fix(i18n): keep scale labels locale neutral --- packages/core/src/schema/nodes/guide.ts | 4 +- .../components/editor/floorplan-panel.test.ts | 37 ++++++++++ .../src/components/editor/floorplan-panel.tsx | 67 +++++++++---------- .../ui/controls/metric-control.test.ts | 14 +++- .../components/ui/controls/metric-control.tsx | 8 ++- .../components/ui/panels/reference-panel.tsx | 12 +++- packages/editor/src/lib/reference-scale.ts | 32 +++++++++ 7 files changed, 129 insertions(+), 45 deletions(-) create mode 100644 packages/editor/src/lib/reference-scale.ts diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index 8dafffb204..f3fdb57634 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -8,7 +8,9 @@ export const GuideScaleReference = z.object({ realLengthMeters: z.number().positive(), measuredLengthUnits: z.number().positive(), metersPerUnit: z.number().positive(), - label: z.string(), + displayLength: z.number().positive().optional(), + displayUnit: z.enum(['meters', 'centimeters', 'feet', 'inches']).optional(), + label: z.string().optional(), }) export const GuideNode = BaseNode.extend({ diff --git a/packages/editor/src/components/editor/floorplan-panel.test.ts b/packages/editor/src/components/editor/floorplan-panel.test.ts index dc3be36eb6..d3fe7be2b8 100644 --- a/packages/editor/src/components/editor/floorplan-panel.test.ts +++ b/packages/editor/src/components/editor/floorplan-panel.test.ts @@ -1,4 +1,6 @@ import { expect, test } from 'bun:test' +import { GuideScaleReference } from '@pascal-app/core' +import { createGuideScaleReference, formatReferenceScaleLabel } from '../../lib/reference-scale' import { formatFloorplanNumber, parseReferenceScaleLength } from './floorplan-panel' test('floor-plan reference scales parse with the scoped locale', () => { @@ -10,3 +12,38 @@ 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 store locale-neutral values and still display 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', + }) + expect(formatReferenceScaleLabel(reference, 'fr')).toBe('2,75 m') + expect(formatReferenceScaleLabel(reference, 'en')).toBe('2.75 m') + const legacyReference = GuideScaleReference.parse({ + start: [0, 0], + end: [1, 0], + realLengthMeters: 2.75, + measuredLengthUnits: 1, + metersPerUnit: 2.75, + label: '2,75 m', + }) + expect(formatReferenceScaleLabel(legacyReference, 'en')).toBe('2,75 m') +}) 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..8f60cef881 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,11 @@ 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', () => { + const rounded = roundMetricValueForDisplay(-1.25, 1) + + expect(rounded).toBe(-1.3) + expect(formatMetricInputValue(rounded, 1, 'en')).toBe('-1.3') + expect(roundMetricValueForDisplay(1.25, 1)).toBe(1.3) +}) diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index cee440b1a9..55c8edb4d4 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -43,9 +43,11 @@ export function formatMetricInputValue( return formatNumber(value, { locale, min: precision, max: precision }) } -function roundToPrecision(value: number, precision: number): number { +export function roundMetricValueForDisplay(value: number, precision: number): number { const factor = 10 ** precision - return Math.round((value + Number.EPSILON) * factor) / factor + return ( + (Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * factor)) / factor + ) } export function MetricControl({ @@ -80,7 +82,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', +} + +export function createGuideScaleReference( + reference: Omit, + displayLength: number, + displayUnit: ReferenceScaleUnit, +): GuideScaleReference { + return { + ...reference, + displayLength, + displayUnit, + } +} + +export function formatReferenceScaleLabel(reference: GuideScaleReference, locale: string): string { + if (reference.displayLength !== undefined && reference.displayUnit !== undefined) { + return `${formatNumber(reference.displayLength, { locale, max: 2 })} ${ + REFERENCE_SCALE_UNIT_LABELS[reference.displayUnit] + }` + } + + return reference.label ?? `${formatNumber(reference.realLengthMeters, { locale, max: 2 })} m` +} From a6f8c1c69e7949ad62e226573c112e1d5dd23217 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 01:30:56 +0000 Subject: [PATCH 2/6] fix(i18n): preserve scale label compatibility --- packages/core/src/schema/nodes/guide.ts | 2 +- .../components/editor/floorplan-panel.test.ts | 36 ++++++++++++++-- .../ui/controls/metric-control.test.ts | 17 ++++++-- .../components/ui/controls/metric-control.tsx | 10 +++-- packages/editor/src/lib/reference-scale.ts | 41 +++++++++++++++++-- 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index f3fdb57634..a658eea0fb 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -10,7 +10,7 @@ export const GuideScaleReference = z.object({ metersPerUnit: z.number().positive(), displayLength: z.number().positive().optional(), displayUnit: z.enum(['meters', 'centimeters', 'feet', 'inches']).optional(), - label: z.string().optional(), + label: z.string(), }) export const GuideNode = BaseNode.extend({ diff --git a/packages/editor/src/components/editor/floorplan-panel.test.ts b/packages/editor/src/components/editor/floorplan-panel.test.ts index d3fe7be2b8..17a1de1248 100644 --- a/packages/editor/src/components/editor/floorplan-panel.test.ts +++ b/packages/editor/src/components/editor/floorplan-panel.test.ts @@ -1,8 +1,18 @@ 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(), +}) + 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) @@ -13,7 +23,7 @@ test('floor-plan reference scales display with the scoped locale', () => { expect(formatFloorplanNumber(2.75, 'en')).toBe('2.75') }) -test('reference-scale scenes store locale-neutral values and still display legacy labels', () => { +test('reference-scale scenes remain compatible and format numeric and legacy labels', () => { const reference = createGuideScaleReference( { start: [0, 0], @@ -34,10 +44,12 @@ test('reference-scale scenes store locale-neutral values and still display legac 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 legacyReference = GuideScaleReference.parse({ + const legacyFrenchReference = GuideScaleReference.parse({ start: [0, 0], end: [1, 0], realLengthMeters: 2.75, @@ -45,5 +57,23 @@ test('reference-scale scenes store locale-neutral values and still display legac metersPerUnit: 2.75, label: '2,75 m', }) - expect(formatReferenceScaleLabel(legacyReference, 'en')).toBe('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') }) 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 8f60cef881..100f17bf9c 100644 --- a/packages/editor/src/components/ui/controls/metric-control.test.ts +++ b/packages/editor/src/components/ui/controls/metric-control.test.ts @@ -28,9 +28,18 @@ test('metric controls write values back with the scoped decimal separator', () = }) test('metric write-back rounds negative halves away from zero like the display', () => { - const rounded = roundMetricValueForDisplay(-1.25, 1) - - expect(rounded).toBe(-1.3) - expect(formatMetricInputValue(rounded, 1, 'en')).toBe('-1.3') + 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') }) diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index 55c8edb4d4..eb9def91a6 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -40,14 +40,16 @@ 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, + }) } export function roundMetricValueForDisplay(value: number, precision: number): number { const factor = 10 ** precision - return ( - (Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * factor)) / factor - ) + return (Math.sign(value) * Math.round(Math.abs(value) * factor)) / factor } export function MetricControl({ diff --git a/packages/editor/src/lib/reference-scale.ts b/packages/editor/src/lib/reference-scale.ts index 92f74d3e00..1e54ea59a8 100644 --- a/packages/editor/src/lib/reference-scale.ts +++ b/packages/editor/src/lib/reference-scale.ts @@ -9,24 +9,57 @@ const REFERENCE_SCALE_UNIT_LABELS: Record = { inches: 'in', } +function formatScaleValue(value: number, unit: string, locale: string): string { + return `${formatNumber(value, { locale, max: 2 })} ${unit}` +} + +function parseLegacyScaleLabel(label: string): { unit: string; value: number } | null { + const match = label.trim().match(/^([+−-]?[\d\s\u00a0\u202f.,]+)\s*(m|cm|ft|in)$/i) + if (!match) return null + + const compact = match[1]!.replace(/[ \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 { if (reference.displayLength !== undefined && reference.displayUnit !== undefined) { - return `${formatNumber(reference.displayLength, { locale, max: 2 })} ${ - REFERENCE_SCALE_UNIT_LABELS[reference.displayUnit] - }` + return formatScaleValue( + reference.displayLength, + REFERENCE_SCALE_UNIT_LABELS[reference.displayUnit], + locale, + ) } - return reference.label ?? `${formatNumber(reference.realLengthMeters, { locale, max: 2 })} m` + const parsed = parseLegacyScaleLabel(reference.label) + return parsed ? formatScaleValue(parsed.value, parsed.unit, locale) : reference.label } From 1a2d8438a748e313fcc2c95b1133abe4a98f913f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 01:42:09 +0000 Subject: [PATCH 3/6] fix(editor): align metric rounding with Intl --- .../src/components/ui/controls/metric-control.test.ts | 7 +++++++ .../src/components/ui/controls/metric-control.tsx | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) 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 100f17bf9c..cc04ff9143 100644 --- a/packages/editor/src/components/ui/controls/metric-control.test.ts +++ b/packages/editor/src/components/ui/controls/metric-control.test.ts @@ -43,3 +43,10 @@ test('metric write-back and display agree for values immediately below a half', 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 eb9def91a6..4f60e3b606 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -48,8 +48,15 @@ export function formatMetricInputValue( } export function roundMetricValueForDisplay(value: number, precision: number): number { - const factor = 10 ** precision - return (Math.sign(value) * Math.round(Math.abs(value) * factor)) / factor + const rounded = Number( + formatNumber(value, { + locale: 'en', + min: precision, + max: precision, + useGrouping: false, + }), + ) + return Number.isFinite(rounded) ? rounded : value } export function MetricControl({ From c380312aa4ff255db7502aab4b4f2a915177ffaa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 02:06:36 +0000 Subject: [PATCH 4/6] fix(i18n): harden the label parser --- packages/core/src/schema/nodes/guide.ts | 2 +- .../components/editor/floorplan-panel.test.ts | 87 +++++++++++++++++++ packages/editor/src/lib/reference-scale.ts | 30 ++++--- 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index a658eea0fb..b75d0a3f18 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -8,7 +8,7 @@ export const GuideScaleReference = z.object({ realLengthMeters: z.number().positive(), measuredLengthUnits: z.number().positive(), metersPerUnit: z.number().positive(), - displayLength: z.number().positive().optional(), + 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 17a1de1248..68b6a5b00f 100644 --- a/packages/editor/src/components/editor/floorplan-panel.test.ts +++ b/packages/editor/src/components/editor/floorplan-panel.test.ts @@ -13,6 +13,18 @@ const Core106GuideScaleReference = z.object({ 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) @@ -77,3 +89,78 @@ test('reference-scale scenes remain compatible and format numeric and legacy lab ), ).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/lib/reference-scale.ts b/packages/editor/src/lib/reference-scale.ts index 1e54ea59a8..83775047d7 100644 --- a/packages/editor/src/lib/reference-scale.ts +++ b/packages/editor/src/lib/reference-scale.ts @@ -13,11 +13,16 @@ 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.,]+)\s*(m|cm|ft|in)$/i) + 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(/[ \u00a0\u202f]/g, '').replace('−', '-') + const compact = match[1]!.replace(/[\s\u00a0\u202f]/g, '').replace('−', '-') const lastDot = compact.lastIndexOf('.') const lastComma = compact.lastIndexOf(',') const separatorIndex = Math.max(lastDot, lastComma) @@ -52,14 +57,19 @@ export function createGuideScaleReference( } export function formatReferenceScaleLabel(reference: GuideScaleReference, locale: string): string { - if (reference.displayLength !== undefined && reference.displayUnit !== undefined) { - return formatScaleValue( - reference.displayLength, - REFERENCE_SCALE_UNIT_LABELS[reference.displayUnit], - locale, - ) + const unitLabel = getReferenceScaleUnitLabel(reference.displayUnit) + if ( + typeof reference.displayLength === 'number' && + Number.isFinite(reference.displayLength) && + unitLabel + ) { + return formatScaleValue(reference.displayLength, unitLabel, locale) } - const parsed = parseLegacyScaleLabel(reference.label) - return parsed ? formatScaleValue(parsed.value, parsed.unit, locale) : reference.label + const label: unknown = reference.label + if (typeof label !== 'string') return label == null ? '' : String(label) + if (label.length > 64) return label + + const parsed = parseLegacyScaleLabel(label) + return parsed ? formatScaleValue(parsed.value, parsed.unit, locale) : label } From ab1a2960b0d2189a7a1b661eb291af846ca5a983 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 02:11:53 +0000 Subject: [PATCH 5/6] chore(i18n): harden the label parser From 9121db3c5ef4b910c87bb47eddd4ff1442640539 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 02:13:19 +0000 Subject: [PATCH 6/6] fix(i18n): harden the label parser --- packages/editor/src/lib/reference-scale.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/lib/reference-scale.ts b/packages/editor/src/lib/reference-scale.ts index 83775047d7..c27bec1ba6 100644 --- a/packages/editor/src/lib/reference-scale.ts +++ b/packages/editor/src/lib/reference-scale.ts @@ -2,6 +2,8 @@ import { formatNumber, type GuideScaleReference } from '@pascal-app/core' export type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' +const MAX_REFERENCE_SCALE_LABEL_LENGTH = 64 + const REFERENCE_SCALE_UNIT_LABELS: Record = { meters: 'm', centimeters: 'cm', @@ -68,7 +70,7 @@ export function formatReferenceScaleLabel(reference: GuideScaleReference, locale const label: unknown = reference.label if (typeof label !== 'string') return label == null ? '' : String(label) - if (label.length > 64) return label + if (label.length > MAX_REFERENCE_SCALE_LABEL_LENGTH) return label const parsed = parseLegacyScaleLabel(label) return parsed ? formatScaleValue(parsed.value, parsed.unit, locale) : label