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
56 changes: 55 additions & 1 deletion packages/editor/src/components/editor/floorplan-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ 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'
import {
formatFloorplanNumber,
getReferenceScaleInputError,
parseReferenceScaleLength,
} from './floorplan-panel'

const Core106GuideScaleReference = z.object({
start: z.tuple([z.number(), z.number()]),
Expand Down Expand Up @@ -104,6 +108,14 @@ test('oversized legacy reference labels bypass parsing within the input budget',
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({ label: { toString: null } }), 'en'),
).toBe('')
expect(
formatReferenceScaleLabel(runtimeScaleReference({ label: Object.create(null) }), 'en'),
).toBe('')
expect(formatReferenceScaleLabel(runtimeScaleReference({ label: Symbol('x') }), 'en')).toBe('')
expect(formatReferenceScaleLabel(runtimeScaleReference({ label: ['1 m'] }), 'en')).toBe('')
expect(
formatReferenceScaleLabel(
runtimeScaleReference({
Expand Down Expand Up @@ -164,3 +176,45 @@ test('reference-scale schema caps persisted display lengths', () => {
).success,
).toBe(false)
})

test('reference-scale calibration rejects lengths the schema would not accept', () => {
expect(getReferenceScaleInputError('1000000', 'meters', 'en')).toBeNull()
expect(getReferenceScaleInputError('2.75', 'feet', 'en')).toBeNull()
expect(getReferenceScaleInputError('1000001', 'meters', 'en')).toBe(
'Length must be at most 1000000 m.',
)
expect(getReferenceScaleInputError('1e9', 'centimeters', 'en')).toBe(
'Length must be at most 1000000 cm.',
)
expect(getReferenceScaleInputError('0', 'meters', 'en')).toBe('Length must be greater than 0.')
expect(getReferenceScaleInputError('', 'meters', 'en')).toBe('Enter the real length of the line.')
})

test('reference-scale creation drops display fields outside the schema range', () => {
const base = {
start: [0, 0] as [number, number],
end: [1, 0] as [number, number],
realLengthMeters: 2_000_000,
measuredLengthUnits: 1,
metersPerUnit: 2_000_000,
}
const oversized = createGuideScaleReference(base, 2_000_000, 'meters')
expect(oversized.displayLength).toBeUndefined()
expect(oversized.displayUnit).toBeUndefined()
expect(oversized.label).toBe('2000000 m')
expect(GuideScaleReference.safeParse(oversized).success).toBe(true)

const atCap = createGuideScaleReference(base, 1_000_000, 'meters')
expect(atCap.displayLength).toBe(1_000_000)
expect(GuideScaleReference.safeParse(atCap).success).toBe(true)
})

test('legacy reference-scale labels accept whitespace runs between digit groups', () => {
const label = (value: string) =>
formatReferenceScaleLabel(runtimeScaleReference({ label: value }), 'en')
expect(label('1 234,5 m')).toBe('1234.5 m')
expect(label('1 \u00a0234 cm')).toBe('1234 cm')
expect(label('12\u202f345\t\t678 ft')).toBe('12345678 ft')
expect(label('1 234 m')).toBe('1234 m')
expect(label('1 234 ')).toBe('1 234 ')
})
38 changes: 27 additions & 11 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measureme
import {
createGuideScaleReference,
formatReferenceScaleLabel,
isValidReferenceScaleDisplayLength,
MAX_REFERENCE_SCALE_DISPLAY_LENGTH,
type ReferenceScaleUnit,
} from '../../lib/reference-scale'
import { snapRegisteredDraftPoint } from '../../lib/registered-draft-snap'
Expand Down Expand Up @@ -2487,6 +2489,23 @@ export function parseReferenceScaleLength(
)
}

export function getReferenceScaleInputError(
raw: string,
unit: ReferenceScaleUnit,
locale: string,
): string | null {
if (raw.trim() === '') return 'Enter the real length of the line.'
const displayLength = parseReferenceScaleLength(raw, unit, locale)
if (displayLength === null || Number.isNaN(displayLength)) {
return `Enter a length like 3.5, 180cm or 5'11".`
}
if (!(displayLength > 0)) return 'Length must be greater than 0.'
if (!isValidReferenceScaleDisplayLength(displayLength)) {
return `Length must be at most ${formatFloorplanNumber(MAX_REFERENCE_SCALE_DISPLAY_LENGTH, locale)} ${REFERENCE_SCALE_LINGO_UNIT[unit]}.`
}
return null
}

function referenceScaleLengthHint(
raw: string,
unit: ReferenceScaleUnit,
Expand Down Expand Up @@ -7168,7 +7187,7 @@ export function FloorplanPanel({
}

const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit, locale)
if (!(displayLength && displayLength > 0)) {
if (displayLength === null || !isValidReferenceScaleDisplayLength(displayLength)) {
return
}

Expand Down Expand Up @@ -11218,7 +11237,7 @@ export function FloorplanPanel({
const pendingReferenceDisplayLength =
parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit, locale) ?? Number.NaN
const pendingReferenceRealLengthMeters =
pendingReferenceScale && pendingReferenceDisplayLength > 0
pendingReferenceScale && isValidReferenceScaleDisplayLength(pendingReferenceDisplayLength)
? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit)
: null
const pendingReferenceMetersPerUnit =
Expand All @@ -11229,14 +11248,11 @@ export function FloorplanPanel({
pendingReferenceScale && pendingReferenceRealLengthMeters
? pendingReferenceRealLengthMeters / pendingReferenceScale.measuredLengthUnits
: null
const referenceScaleInputError =
referenceScaleValue.trim() === ''
? 'Enter the real length of the line.'
: Number.isNaN(pendingReferenceDisplayLength)
? `Enter a length like 3.5, 180cm or 5'11".`
: pendingReferenceDisplayLength > 0
? null
: 'Length must be greater than 0.'
const referenceScaleInputError = getReferenceScaleInputError(
referenceScaleValue,
referenceScaleUnit,
locale,
)
const referenceScaleHint = referenceScaleInputError
? null
: referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit, locale)
Expand Down Expand Up @@ -11395,7 +11411,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 ${formatFloorplanNumber(pendingReferenceImageScaleFactor, locale, 3)}x from the first point.`
: 'Enter a length greater than 0.'}
: 'Enter a valid length to preview the scale.'}
</div>

<div className="mt-4 flex justify-end gap-2">
Expand Down
24 changes: 15 additions & 9 deletions packages/editor/src/lib/reference-scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ export type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches'

const MAX_REFERENCE_SCALE_LABEL_LENGTH = 64

/** Mirrors `GuideScaleReference.displayLength`'s `.max()` in core. */
export const MAX_REFERENCE_SCALE_DISPLAY_LENGTH = 1_000_000

export function isValidReferenceScaleDisplayLength(value: number): boolean {
return Number.isFinite(value) && value > 0 && value <= MAX_REFERENCE_SCALE_DISPLAY_LENGTH
}

const REFERENCE_SCALE_UNIT_LABELS: Record<ReferenceScaleUnit, string> = {
meters: 'm',
centimeters: 'cm',
Expand All @@ -21,10 +28,12 @@ function getReferenceScaleUnitLabel(unit: unknown): string | null {
}

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)
// `\s` covers NBSP and narrow NBSP; the whitespace run and digit group are
// disjoint, so each repetition has exactly one way to match.
const match = label.trim().match(/^([+−-]?[\d.,]+(?:\s+[\d.,]+)*)\s*(m|cm|ft|in)$/i)
if (!match) return null

const compact = match[1]!.replace(/[\s\u00a0\u202f]/g, '').replace('−', '-')
const compact = match[1]!.replace(/\s/g, '').replace('−', '-')
const lastDot = compact.lastIndexOf('.')
const lastComma = compact.lastIndexOf(',')
const separatorIndex = Math.max(lastDot, lastComma)
Expand All @@ -50,12 +59,9 @@ export function createGuideScaleReference(
displayUnit: ReferenceScaleUnit,
): GuideScaleReference {
const unitLabel = REFERENCE_SCALE_UNIT_LABELS[displayUnit]
return {
...reference,
displayLength,
displayUnit,
label: formatScaleValue(displayLength, unitLabel, 'en'),
}
const label = formatScaleValue(displayLength, unitLabel, 'en')
if (!isValidReferenceScaleDisplayLength(displayLength)) return { ...reference, label }
return { ...reference, displayLength, displayUnit, label }
}

export function formatReferenceScaleLabel(reference: GuideScaleReference, locale: string): string {
Expand All @@ -69,7 +75,7 @@ export function formatReferenceScaleLabel(reference: GuideScaleReference, locale
}

const label: unknown = reference.label
if (typeof label !== 'string') return label == null ? '' : String(label)
if (typeof label !== 'string') return typeof label === 'number' ? String(label) : ''
if (label.length > MAX_REFERENCE_SCALE_LABEL_LENGTH) return label

const parsed = parseLegacyScaleLabel(label)
Expand Down
Loading