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
2 changes: 2 additions & 0 deletions packages/core/src/schema/nodes/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})

Expand Down
154 changes: 154 additions & 0 deletions packages/editor/src/components/editor/floorplan-panel.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): 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)
Expand All @@ -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)
})
67 changes: 30 additions & 37 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -3305,16 +3292,19 @@ function FloorplanReferenceScaleLayer({

return (
<>
{visibleReferences.map((reference, index) => (
<FloorplanReferenceScaleLine
end={reference.end}
key={`${reference.label}-${index}-${reference.start.join(',')}-${reference.end.join(',')}`}
label={reference.label}
palette={palette}
start={reference.start}
unitsPerPixel={unitsPerPixel}
/>
))}
{visibleReferences.map((reference, index) => {
const label = formatReferenceScaleLabel(reference, locale)
return (
<FloorplanReferenceScaleLine
end={reference.end}
key={`${index}-${reference.start.join(',')}-${reference.end.join(',')}`}
label={label}
palette={palette}
start={reference.start}
unitsPerPixel={unitsPerPixel}
/>
)
})}
{draft?.start && (
<FloorplanReferenceScaleDraftLine
palette={palette}
Expand Down Expand Up @@ -7215,14 +7205,17 @@ export function FloorplanPanel({
{
position: nextGuidePosition,
scale: nextGuideScale,
scaleReference: {
start: pendingReferenceScale.start,
end: scaledEnd,
realLengthMeters,
measuredLengthUnits: scaledMeasuredLengthUnits,
metersPerUnit,
label: formatReferenceScaleLabel(displayLength, referenceScaleUnit, locale),
},
scaleReference: createGuideScaleReference(
{
start: pendingReferenceScale.start,
end: scaledEnd,
realLengthMeters,
measuredLengthUnits: scaledMeasuredLengthUnits,
metersPerUnit,
},
displayLength,
referenceScaleUnit,
),
} as Partial<GuideNode>,
)
setGuideLocked(pendingReferenceScale.guideId, true)
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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')
})
21 changes: 16 additions & 5 deletions packages/editor/src/components/ui/controls/metric-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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],
)

Expand Down
Loading
Loading