Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions packages/core/src/i18n/i18n.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
defaultI18n,
formatLength,
formatNumber,
getLocale,
LevelNode,
levelDisplayNumber,
PascalI18nProvider,
Expand Down Expand Up @@ -134,19 +135,29 @@ describe('message registry', () => {
expect(localeChanges).toBe(1)
})

test('keeps a scoped locale across catalog refreshes and accepts repeated global locale intent', async () => {
test('keeps a request-scoped locale across compiled catalog refreshes', () => {
setLocale('en')
const scope = createI18n({ locale: 'fr' })
const release = scope.retain()
const localeChanges: string[] = []
const unsubscribe = defaultI18n.subscribeLocale((locale) => scope.setLocale(locale))
const stopRecording = defaultI18n.subscribeLocale((locale) => localeChanges.push(locale))

registerCompiledMessages('fr', 'viewer', {
'test.catalog-refresh': ['Actualisé'],
})

await registerMessages('fr', 'viewer', { 'test.catalog-refresh': 'Actualisé' })
expect(defaultI18n.getLocale()).toBe('en')
expect(scope.getLocale()).toBe('fr')
expect(scope._({ id: 'viewer.test.catalog-refresh', message: 'Refreshed' })).toBe('Actualisé')
expect(localeChanges).toEqual([])

setLocale('en')
expect(scope.getLocale()).toBe('en')
setLocale('ja')
expect(scope.getLocale()).toBe('ja')
expect(localeChanges).toEqual(['ja'])

unsubscribe()
stopRecording()
release()
scope.destroy()
})
Expand Down Expand Up @@ -253,6 +264,7 @@ describe('locale formatting', () => {
})

test('server rendering uses the selected locale for text and numbers', async () => {
setLocale('en')
await registerMessages('fr', 'viewer', { greeting: 'Bonjour' })
const scope = createI18n({ locale: 'fr' })

Expand All @@ -273,6 +285,8 @@ test('server rendering uses the selected locale for text and numbers', async ()
</PascalI18nProvider>,
)

expect(getLocale()).toBe('en')
expect(formatNumber(2.75, { max: 2 })).toBe('2.75')
expect(serverHtml).toContain('Bonjour')
expect(serverHtml).toContain('2,75')
expect(serverHtml).not.toContain('Hello')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { describe, expect, test } from 'bun:test'
import {
formatMeasurementInputValue,
isProjectedFloorplanAxisPointVerified,
resolveFloorplanMeasurementAxisSnap,
resolveProjectedFloorplanSnap,
} from './floorplan-measurement-tool-layer'

test('extrusion inputs write values back with the scoped decimal separator', () => {
expect(formatMeasurementInputValue(2.75, 'fr')).toBe('2,75')
expect(formatMeasurementInputValue(2.75, 'en')).toBe('2.75')
})

describe('resolveFloorplanMeasurementAxisSnap', () => {
test('snaps to the closest X or Z axis inside the screen-space threshold', () => {
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 4, 9)).toEqual({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
closestMeasurementFeatureBinding,
collectAlignmentAnchors,
emitter,
formatNumber,
type GeometryContext,
type MeasurementFeatureAnchor,
type MeasurementSnapKind,
Expand Down Expand Up @@ -78,6 +79,10 @@ export type ProjectedFloorplanSnap = {
const MAX_REGISTERED_SNAP_ELEMENTS = 512
const MAX_REGISTERED_SNAP_SEGMENTS = 4096
const MAX_SAMPLED_PATH_SEGMENTS = 48

export function formatMeasurementInputValue(value: number, locale: string): string {
return formatNumber(value, { locale, max: 3 })
}
const VERTEX_SNAP_DISTANCE_PX = 16
const EDGE_SNAP_DISTANCE_PX = 10
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
Expand Down Expand Up @@ -642,13 +647,17 @@ function FloorplanExtrusionControl({
const points = useMeasurementDraft((state) => state.points)
const baseNormal = useMeasurementDraft((state) => state.baseNormal)
const [value, setValue] = useState(() =>
extrusionHeight === 0 ? '' : String(metersToLinearUnit(extrusionHeight, unit)),
extrusionHeight === 0
? ''
: formatMeasurementInputValue(metersToLinearUnit(extrusionHeight, unit), locale),
)

useEffect(() => {
const height = useMeasurementDraft.getState().extrusionHeight
setValue(height === 0 ? '' : String(Number(metersToLinearUnit(height, unit).toFixed(3))))
}, [unit])
setValue(
height === 0 ? '' : formatMeasurementInputValue(metersToLinearUnit(height, unit), locale),
)
}, [locale, unit])

const width = 260 * unitsPerPixel
const height = 72 * unitsPerPixel
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { expect, test } from 'bun:test'
import { parseReferenceScaleLength } from './floorplan-panel'
import { formatFloorplanNumber, parseReferenceScaleLength } from './floorplan-panel'

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)
})

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')
})
16 changes: 9 additions & 7 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
emitter,
type FenceNode,
type FloorplanGeometry,
formatNumber as formatLocalizedNumber,
type GeometryContext,
type GuideNode,
getRenderableSlabPolygon,
Expand Down Expand Up @@ -2439,8 +2440,8 @@ function formatMeasurement(
return formatLinearMeasurement(measuredValue, unit, metricNotation)
}

function formatNumber(value: number, fractionDigits = 2) {
return Number.parseFloat(value.toFixed(fractionDigits)).toString()
export function formatFloorplanNumber(value: number, locale: string, fractionDigits = 2) {
return formatLocalizedNumber(value, { locale, max: fractionDigits })
}

function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit) {
Expand Down Expand Up @@ -2515,8 +2516,8 @@ function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) {
}
}

function formatReferenceScaleLabel(value: number, unit: ReferenceScaleUnit) {
return `${formatNumber(value)} ${getReferenceScaleUnitLabel(unit)}`
function formatReferenceScaleLabel(value: number, unit: ReferenceScaleUnit, locale: string) {
return `${formatFloorplanNumber(value, locale)} ${getReferenceScaleUnitLabel(unit)}`
}

function getPolygonAreaAndCentroid(polygon: Point2D[]) {
Expand Down Expand Up @@ -7220,7 +7221,7 @@ export function FloorplanPanel({
realLengthMeters,
measuredLengthUnits: scaledMeasuredLengthUnits,
metersPerUnit,
label: formatReferenceScaleLabel(displayLength, referenceScaleUnit),
label: formatReferenceScaleLabel(displayLength, referenceScaleUnit, locale),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep localized labels out of persisted guide data

When a reference scale is created under French, this stores a value such as 2,75 m in scaleReference.label; the floor-plan layer and reference panel subsequently render that persisted string verbatim. If the editor locale later changes, or another user opens the scene under a different locale, existing labels retain the creator's decimal convention and scene serialization now varies by locale. Persist the numeric value and unit (or a locale-neutral representation) and format the label with the active locale at render time.

Useful? React with 👍 / 👎.

},
} as Partial<GuideNode>,
)
Expand Down Expand Up @@ -10037,10 +10038,11 @@ export function FloorplanPanel({
// confirming without editing is a no-op instead of a surprise
// rescale (plan units are meters; convert when defaulting to feet).
setReferenceScaleValue(
formatNumber(
formatFloorplanNumber(
unit === 'imperial'
? measuredLengthUnits / linearUnitToMeters(1, 'imperial')
: measuredLengthUnits,
locale,
2,
),
)
Expand Down Expand Up @@ -11399,7 +11401,7 @@ export function FloorplanPanel({

<div className="mt-3 rounded-lg bg-muted/45 px-3 py-2 text-muted-foreground text-xs">
{pendingReferenceImageScaleFactor
? `Image will scale ${formatNumber(pendingReferenceImageScaleFactor, 3)}x from the first point.`
? `Image will scale ${formatFloorplanNumber(pendingReferenceImageScaleFactor, locale, 3)}x from the first point.`
: 'Enter a length greater than 0.'}
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test'
import { parseMetricInput } from './metric-control'
import { formatMetricInputValue, parseMetricInput } from './metric-control'

test('metric controls parse stair dimensions with the scoped locale', () => {
expect(
Expand All @@ -17,3 +17,8 @@ test('metric controls parse stair dimensions with the scoped locale', () => {
}),
).toBe(1000)
})

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')
})
45 changes: 31 additions & 14 deletions packages/editor/src/components/ui/controls/metric-control.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core'
import { formatNumber, parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
import { useLinearDisplay } from '../../../lib/use-linear-display'
Expand Down Expand Up @@ -35,6 +35,19 @@ export function parseMetricInput(
: parseLocalizedNumber(text, options.locale)
}

export function formatMetricInputValue(
value: number,
precision: number,
locale: string,
): string {
return formatNumber(value, { locale, min: precision, max: precision })
}

function roundToPrecision(value: number, precision: number): number {
const factor = 10 ** precision
return Math.round((value + Number.EPSILON) * factor) / factor
Comment on lines +46 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve symmetric rounding for negative metric values

For metric controls that permit negative values, such as node-position controls, Math.round resolves exact negative half steps toward positive infinity. For example, at precision 1 this changes -1.25 to -1.2, whereas the previous toFixed path and the new Intl.NumberFormat display round it to -1.3; wheel or drag updates can therefore move a negative coordinate one quantum toward zero and disagree with the displayed value. Use a sign-symmetric rounding method consistent with the formatter.

Useful? React with 👍 / 👎.

}

export function MetricControl({
label,
value,
Expand Down Expand Up @@ -67,16 +80,20 @@ export function MetricControl({
)
const roundStoredValueForDisplayPrecision = useCallback(
(storedValue: number) =>
clamp(toStoredValue(Number.parseFloat(toDisplayValue(storedValue).toFixed(precision)))),
clamp(toStoredValue(roundToPrecision(toDisplayValue(storedValue), precision))),
[clamp, precision, toDisplayValue, toStoredValue],
)

const displayValue = toDisplayValue(value)
const formatDisplayValue = useCallback(
(nextValue: number) => formatMetricInputValue(nextValue, precision, locale),
[locale, precision],
)

const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(displayValue.toFixed(precision))
const [inputValue, setInputValue] = useState(() => formatDisplayValue(displayValue))
const startXRef = useRef(0)
const startValueRef = useRef(0)
const containerRef = useRef<HTMLDivElement>(null)
Expand All @@ -97,9 +114,9 @@ export function MetricControl({

useEffect(() => {
if (!isEditing) {
setInputValue(displayValue.toFixed(precision))
setInputValue(formatDisplayValue(displayValue))
}
}, [displayValue, precision, isEditing])
}, [displayValue, formatDisplayValue, isEditing])

useEffect(() => {
const container = containerRef.current
Expand Down Expand Up @@ -237,8 +254,8 @@ export function MetricControl({

const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(toDisplayValue(value).toFixed(precision))
}, [value, toDisplayValue, precision])
setInputValue(formatDisplayValue(toDisplayValue(value)))
}, [formatDisplayValue, value, toDisplayValue])

const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
Expand All @@ -249,7 +266,7 @@ export function MetricControl({
const parsed = parseMetricInput(inputValue, unit, { isImperial, locale, parseUnit })
const stored = parsed === null ? null : spec ? parsed : toStoredValue(parsed)
if (stored === null) {
setInputValue(toDisplayValue(value).toFixed(precision))
setInputValue(formatDisplayValue(toDisplayValue(value)))
} else {
applyCommittedValue(clamp(stored))
}
Expand All @@ -264,7 +281,7 @@ export function MetricControl({
clamp,
toStoredValue,
value,
precision,
formatDisplayValue,
toDisplayValue,
])

Expand All @@ -290,25 +307,25 @@ export function MetricControl({
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(toDisplayValue(value).toFixed(precision))
setInputValue(formatDisplayValue(toDisplayValue(value)))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + toStoredValue(step))
applyCommittedValue(newV)
setInputValue(toDisplayValue(newV).toFixed(precision))
setInputValue(formatDisplayValue(toDisplayValue(newV)))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - toStoredValue(step))
applyCommittedValue(newV)
setInputValue(toDisplayValue(newV).toFixed(precision))
setInputValue(formatDisplayValue(toDisplayValue(newV)))
}
},
[
submitValue,
value,
toDisplayValue,
precision,
formatDisplayValue,
step,
clamp,
applyCommittedValue,
Expand Down Expand Up @@ -373,7 +390,7 @@ export function MetricControl({
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
{Number(displayValue.toFixed(precision)).toFixed(precision)}
{formatDisplayValue(displayValue)}
</span>
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { expect, test } from 'bun:test'
import { createI18n, PascalI18nProvider } from '@pascal-app/core'
import { createI18n, getLocale, PascalI18nProvider, setLocale } from '@pascal-app/core'
import { renderToString } from 'react-dom/server'
import { parseSliderBareValue, SliderControl } from './slider-control'

test('slider numbers use the scoped locale for display and parsing', () => {
test('slider numbers use the scoped locale for display and parsing while the global locale is English', () => {
setLocale('en')
const i18n = createI18n({ locale: 'fr' })
try {
const html = renderToString(
Expand All @@ -12,6 +13,7 @@ test('slider numbers use the scoped locale for display and parsing', () => {
</PascalI18nProvider>,
)

expect(getLocale()).toBe('en')
expect(html).toContain('2,75')
expect(parseSliderBareValue('2,75', i18n.getLocale())).toBe(2.75)
expect(parseSliderBareValue('1e2', i18n.getLocale())).toBe(100)
Expand Down
Loading