From a5963560b168806ed74a9f8406c5b796e3863eb9 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 12:04:14 +0200 Subject: [PATCH 1/6] feat(editor): natural-language measurement inputs via @pascal-app/lingo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type any unit into SliderControl / MetricControl fields — "6ft", "180cm", "1m80", "5'11\"", "72in", "45°", "1.57rad" — and it canonicalizes to the unit the field already stores (meters / degrees / radians / inches), independent of the metric/imperial display toggle. A live "= 1.83 m" hint previews the parsed value while typing. Only the typed-text commit path changes: drag-scrub, wheel, and arrow-key editing are untouched, and any unit the adapter doesn't recognise (%, unitless, rad/s) falls back to Number.parseFloat, so nothing regresses. The shared lib/measurement-parser.ts maps each field's `unit` prop to a lingo kind + canonical unit, covering the auto-inspector and every custom panel at once. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/package.json | 1 + .../components/ui/controls/metric-control.tsx | 50 +++++++- .../components/ui/controls/slider-control.tsx | 29 ++++- packages/editor/src/lib/measurement-parser.ts | 116 ++++++++++++++++++ 4 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 packages/editor/src/lib/measurement-parser.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index 02ead606..481ab352 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -26,6 +26,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/lingo": "^0.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index 1ce1f5fc..f9336eb7 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -3,6 +3,11 @@ import { useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' +import { + lingoUnitSpec, + measurementHint, + parseMeasurement, +} from '../../../lib/measurement-parser' import { getLinearUnitLabel, linearUnitToMeters, @@ -229,14 +234,46 @@ export function MetricControl({ }, []) const submitValue = useCallback(() => { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { + const spec = lingoUnitSpec(unit) + let stored = spec + ? parseMeasurement(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + }) + : null + if (stored === null) { + const numValue = Number.parseFloat(inputValue) + stored = Number.isFinite(numValue) ? toStoredValue(numValue) : null + } + if (stored === null) { setInputValue(toDisplayValue(value).toFixed(precision)) } else { - applyCommittedValue(clamp(toStoredValue(numValue))) + applyCommittedValue(clamp(stored)) } setIsEditing(false) - }, [inputValue, applyCommittedValue, clamp, toStoredValue, value, precision, toDisplayValue]) + }, [ + inputValue, + unit, + isImperial, + applyCommittedValue, + clamp, + toStoredValue, + value, + precision, + toDisplayValue, + ]) + + const spec = lingoUnitSpec(unit) + const hint = + isEditing && spec + ? measurementHint(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + displayUnit: isImperial ? 'ft' : spec.unitId, + precision, + clamp, + }) + : null const handleInputBlur = useCallback(() => { submitValue() @@ -290,6 +327,11 @@ export function MetricControl({
{isEditing ? (
+ {hint && ( + + {hint} + + )} { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { + const spec = lingoUnitSpec(unit) + let parsed = spec ? parseMeasurement(inputValue, spec) : null + if (parsed === null) { + const numValue = Number.parseFloat(inputValue) + parsed = Number.isFinite(numValue) ? numValue : null + } + if (parsed === null) { setInputValue(value.toFixed(precision)) } else { - const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision))) + const nextValue = clamp(Number.parseFloat(parsed.toFixed(precision))) onChange(nextValue) onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, onChange, onCommit, clamp, precision, value]) + }, [inputValue, unit, onChange, onCommit, clamp, precision, value]) + + const spec = lingoUnitSpec(unit) + const hint = + isEditing && spec + ? measurementHint(inputValue, spec, { displayUnit: spec.unitId, precision, clamp }) + : null const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -279,6 +295,11 @@ export function SliderControl({
{isEditing ? ( <> + {hint && ( + + {hint} + + )} number +} + +const PLAIN_NUMBER = /^[+-]?\d*\.?\d*$/ + +/** + * A faint "= 1.83 m" preview of the value currently being typed, shown only + * when the user typed something beyond a plain decimal in the field's own unit + * (an explicit unit, a compound like `1m80`, or a number word) and it parses. + * Returns `null` when there's nothing useful to preview. + */ +export function measurementHint( + raw: string, + spec: LingoUnitSpec, + options: MeasurementHintOptions = {}, +): string | null { + const trimmed = raw.trim() + if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null + + const parsed = parseMeasurement(trimmed, spec, options) + if (parsed === null) return null + const stored = options.clamp ? options.clamp(parsed) : parsed + + const displayUnit = options.displayUnit ?? spec.unitId + const precision = options.precision ?? 2 + const q = quantity(stored, spec.unitId) + const shown = displayUnit === spec.unitId ? q : q.to(displayUnit) + return `= ${shown.format({ precision })}` +} From f66dcd438bef91c4d2c4630bedcd0f08d44ddc35 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 12:51:08 +0200 Subject: [PATCH 2/6] feat(mcp): natural-language measurements for tool inputs via @pascal-app/lingo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurement/angle parameters on the scene tools now accept a bare number OR a natural-language string ("6 in", "180cm", "45°", "1.57rad"), canonicalized to the unit the handler already expects (meters for length, radians/degrees for angles) with min/max bounds and model-readable errors. Makes LLM tool calls safer — "6 in" no longer has to be pre-converted to 0.1524, and a bad value is rejected with a message the model can self-correct from. A shared `measurement(kind, unit)` zod field wraps lingo's parseQuantity as a `number | string` union+transform; numbers pass through unchanged (backward compatible), so no handler changes were needed. Covers create_wall, cut_opening, place_item, create_level, create_story_shell, create_roof, create_stair_between_levels, create_room, add_door, add_window, photo_to_scene. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/package.json | 1 + packages/mcp/src/tools/construction-tools.ts | 62 ++++++++---- packages/mcp/src/tools/create-level.ts | 6 +- packages/mcp/src/tools/create-wall.test.ts | 28 ++++++ packages/mcp/src/tools/create-wall.ts | 5 +- packages/mcp/src/tools/cut-opening.ts | 5 +- packages/mcp/src/tools/measurement.test.ts | 62 ++++++++++++ packages/mcp/src/tools/measurement.ts | 98 +++++++++++++++++++ .../tools/photo-to-scene/photo-to-scene.ts | 11 ++- packages/mcp/src/tools/place-item.ts | 3 +- packages/mcp/src/tools/room-tools.ts | 18 ++-- 11 files changed, 264 insertions(+), 35 deletions(-) create mode 100644 packages/mcp/src/tools/measurement.test.ts create mode 100644 packages/mcp/src/tools/measurement.ts diff --git a/packages/mcp/package.json b/packages/mcp/package.json index aaadac8a..13cadbb3 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -59,6 +59,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@pascal-app/lingo": "^0.1.0", "zod": "^4.3.5" }, "devDependencies": { diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 1488e57f..ff9aba36 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -14,6 +14,7 @@ import { import { z } from 'zod' import type { SceneOperations } from '../operations' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const @@ -22,12 +23,14 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), - wallHeight: z.number().positive().default(2.8), - wallThickness: z.number().positive().default(0.16), + wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).default(2.8), + wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( + 0.16, + ), createSlab: z.boolean().default(true), createCeiling: z.boolean().default(true), - slabElevation: z.number().default(0.1), - ceilingHeight: z.number().positive().optional(), + slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1), + ceilingHeight: measurement('length', 'm', { min: 0, description: 'Ceiling height.' }).optional(), namePrefix: z.string().optional(), wallMaterialPreset: z.string().optional(), slabMaterialPreset: z.string().optional(), @@ -47,16 +50,25 @@ export const createRoofInput = { roofLevelId: NodeIdSchema.optional(), useDedicatedRoofLevel: z.boolean().default(true), roofLevelLabel: z.string().default('Roof'), - roofLevelElevation: z.number().optional(), - roofLevelHeight: z.number().positive().optional(), + roofLevelElevation: measurement('length', 'm', { + description: 'Roof level elevation.', + }).optional(), + roofLevelHeight: measurement('length', 'm', { + min: 0, + description: 'Roof level height.', + }).optional(), center: Vec3Schema.optional(), - width: z.number().positive(), - depth: z.number().positive(), + width: measurement('length', 'm', { min: 0, description: 'Roof width.' }), + depth: measurement('length', 'm', { min: 0, description: 'Roof depth.' }), roofType: z.enum(ROOF_TYPES).default('hip'), - pitch: z.number().min(0).max(85).default(35), - wallHeight: z.number().min(0).default(0.35), - wallThickness: z.number().positive().default(0.16), - overhang: z.number().min(0).default(0.45), + pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35), + wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default( + 0.35, + ), + wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( + 0.16, + ), + overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45), materialPreset: z.string().optional(), name: z.string().optional(), } @@ -73,21 +85,31 @@ export const createStairBetweenLevelsInput = { fromLevelId: NodeIdSchema, toLevelId: NodeIdSchema, position: Vec3Schema, - rotation: z.number().default(0), - width: z.number().positive().default(1), - runLength: z.number().positive().default(3), - totalRise: z.number().positive().default(2.8), + rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0), + width: measurement('length', 'm', { min: 0, description: 'Stair width.' }).default(1), + runLength: measurement('length', 'm', { min: 0, description: 'Horizontal run length.' }).default( + 3, + ), + totalRise: measurement('length', 'm', { min: 0, description: 'Total vertical rise.' }).default( + 2.8, + ), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), sourceCeilingId: NodeIdSchema.optional(), createDestinationSlabOpening: z.boolean().default(true), createSourceCeilingOpening: z.boolean().default(true), - openingWidth: z.number().positive().optional(), - openingLength: z.number().positive().optional(), - openingOffset: z.number().min(0).default(0), + openingWidth: measurement('length', 'm', { + min: 0, + description: 'Floor opening width.', + }).optional(), + openingLength: measurement('length', 'm', { + min: 0, + description: 'Floor opening length.', + }).optional(), + openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0), openingCenter: Vec2Schema.optional(), - openingRotation: z.number().optional(), + openingRotation: measurement('angle', 'rad', { description: 'Opening rotation.' }).optional(), materialPreset: z.string().optional(), name: z.string().optional(), } diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts index ade635b3..784297c4 100644 --- a/packages/mcp/src/tools/create-level.ts +++ b/packages/mcp/src/tools/create-level.ts @@ -5,12 +5,16 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema } from './schemas' export const createLevelInput = { buildingId: NodeIdSchema, elevation: z.number().optional(), - height: z.number().optional(), + height: measurement('length', 'm', { + min: 0, + description: 'Level height (stored in metadata).', + }).optional(), label: z.string().optional(), } diff --git a/packages/mcp/src/tools/create-wall.test.ts b/packages/mcp/src/tools/create-wall.test.ts index 4d8c49ef..1322b5ff 100644 --- a/packages/mcp/src/tools/create-wall.test.ts +++ b/packages/mcp/src/tools/create-wall.test.ts @@ -42,6 +42,34 @@ describe('create_wall', () => { expect((created as { thickness?: number }).thickness).toBe(0.15) }) + test('accepts a natural-language thickness and canonicalizes to meters', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'create_wall', + arguments: { + levelId: level.id, + start: [0, 0], + end: [4, 0], + thickness: '6 in', + height: '2.5m', + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + const created = bridge.getNode(parsed.wallId) as { thickness?: number; height?: number } + expect(created.thickness).toBeCloseTo(0.1524, 6) + expect(created.height).toBeCloseTo(2.5, 6) + }) + + test('rejects an out-of-unit-family value', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'create_wall', + arguments: { levelId: level.id, start: [0, 0], end: [4, 0], thickness: 'banana' }, + }) + expect(result.isError).toBe(true) + }) + test('publishes a live scene snapshot when bound to a saved scene', async () => { const now = new Date().toISOString() const savedMeta: SceneMeta = { diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index 66d6ee5e..ae9b6794 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -5,14 +5,15 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema } from './schemas' export const createWallInput = { levelId: NodeIdSchema, start: Vec2Schema, end: Vec2Schema, - thickness: z.number().positive().optional(), - height: z.number().positive().optional(), + thickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), + height: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), } export const createWallOutput = { diff --git a/packages/mcp/src/tools/cut-opening.ts b/packages/mcp/src/tools/cut-opening.ts index 86c0cc6b..b03f12f7 100644 --- a/packages/mcp/src/tools/cut-opening.ts +++ b/packages/mcp/src/tools/cut-opening.ts @@ -6,14 +6,15 @@ import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { wallLength, wallLocalXFromT } from './geometry' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema } from './schemas' export const cutOpeningInput = { wallId: NodeIdSchema, type: z.enum(['door', 'window']), position: z.number().min(0).max(1), - width: z.number().positive(), - height: z.number().positive(), + width: measurement('length', 'm', { min: 0, description: 'Opening width.' }), + height: measurement('length', 'm', { min: 0, description: 'Opening height.' }), } export const cutOpeningOutput = { diff --git a/packages/mcp/src/tools/measurement.test.ts b/packages/mcp/src/tools/measurement.test.ts new file mode 100644 index 00000000..ff0f9b0b --- /dev/null +++ b/packages/mcp/src/tools/measurement.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { measurement } from './measurement' + +describe('measurement()', () => { + test('parses natural-language length to meters', () => { + const m = measurement('length', 'm', { min: 0 }) + expect(m.parse('6 in')).toBeCloseTo(0.1524, 6) + expect(m.parse('180cm')).toBeCloseTo(1.8, 6) + expect(m.parse('1m80')).toBeCloseTo(1.8, 6) + expect(m.parse(`5'11"`)).toBeCloseTo(1.8034, 4) + expect(m.parse('2 ft 3 in')).toBeCloseTo(0.6858, 6) + }) + + test('passes numbers through unchanged (backward compatible)', () => { + const m = measurement('length', 'm', { min: 0 }) + expect(m.parse(0.15)).toBe(0.15) + expect(m.parse(3)).toBe(3) + }) + + test('reads a bare numeric string as the field unit', () => { + expect(measurement('length', 'm').parse('6')).toBe(6) + expect(measurement('angle', 'deg').parse('45')).toBe(45) + }) + + test('canonicalizes angles to the field unit', () => { + expect(measurement('angle', 'deg').parse('45°')).toBe(45) + expect(measurement('angle', 'deg').parse('1.57rad')).toBeCloseTo(89.954, 3) + expect(measurement('angle', 'rad').parse('90deg')).toBeCloseTo(Math.PI / 2, 6) + expect(measurement('angle', 'rad').parse('0.25 turn')).toBeCloseTo(Math.PI / 2, 6) + }) + + test('enforces min/max bounds in the field unit', () => { + const m = measurement('length', 'm', { min: 0, max: 3 }) + expect(m.safeParse('-1').success).toBe(false) + expect(m.safeParse('900ft').success).toBe(false) + expect(m.safeParse('2m').success).toBe(true) + const tooBig = m.safeParse('900ft') + expect(tooBig.success).toBe(false) + if (!tooBig.success) expect(tooBig.error.issues[0]?.message).toContain('at most 3 m') + }) + + test('rejects unparseable input with a model-readable message', () => { + const r = measurement('length', 'm').safeParse('banana') + expect(r.success).toBe(false) + if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number') + }) + + test('emits a number|string JSON schema advertising natural language', () => { + const schema = z.toJSONSchema( + measurement('length', 'm', { min: 0, description: 'Wall thickness.' }), + { + io: 'input', + }, + ) + const json = JSON.stringify(schema) + expect(json).toContain('anyOf') + expect(json).toContain('number') + expect(json).toContain('string') + expect((schema as { description?: string }).description).toContain('natural-language') + }) +}) diff --git a/packages/mcp/src/tools/measurement.ts b/packages/mcp/src/tools/measurement.ts new file mode 100644 index 00000000..1767ad06 --- /dev/null +++ b/packages/mcp/src/tools/measurement.ts @@ -0,0 +1,98 @@ +import { type Kind, parseQuantity } from '@pascal-app/lingo' +import { z } from 'zod' + +/** + * A zod field for a measurement tool argument that a model may emit as a bare + * number OR as natural language. `measurement('length', 'm')` accepts `0.15`, + * `"6 in"`, `"180cm"`, `"2 ft 3 in"`; `measurement('angle', 'deg')` accepts + * `45`, `"45°"`, `"1.57rad"`, `"0.25 turn"`. The value is canonicalized (via + * `@pascal-app/lingo`) to a number in `unit` — the exact unit the tool handler + * already expects — so no handler change is needed. `min`/`max` (in `unit`) + * reject out-of-range values with a model-readable message. + * + * The emitted JSON Schema is `number | string`, so the model is free to answer + * in whatever unit it is thinking in; AI SDK v6 applies the transform and + * forwards the canonical number to the tool executor. + */ + +export interface MeasurementOptions { + /** Lower bound, in `unit`. */ + min?: number + /** Upper bound, in `unit`. */ + max?: number + /** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */ + description?: string +} + +function unitNoun(unit: string): string { + switch (unit) { + case 'm': + return 'meters' + case 'deg': + return 'degrees' + case 'rad': + return 'radians' + default: + return unit + } +} + +function naturalLanguageNote(kind: Kind, unit: string): string { + const examples = + kind === 'angle' + ? unit === 'rad' + ? '45, "45°", "1.57rad", "0.25 turn"' + : '45, "45°", "1.57rad", "0.25 turn"' + : '0.9, "6 ft", "180cm", "2 ft 3 in"' + return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.` +} + +function boundsNote(unit: string, min?: number, max?: number): string { + if (min !== undefined && max !== undefined) return ` Range ${min}–${max} ${unit}.` + if (min !== undefined) return ` Minimum ${min} ${unit}.` + if (max !== undefined) return ` Maximum ${max} ${unit}.` + return '' +} + +export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) { + const { min, max } = opts + const description = [ + opts.description, + naturalLanguageNote(kind, unit) + boundsNote(unit, min, max), + ] + .filter(Boolean) + .join(' ') + + return z + .union([z.number(), z.string()]) + .transform((val, ctx) => { + let value: number + if (typeof val === 'number') { + value = val + } else { + const result = parseQuantity(val, { kind, unit, strictness: 'forgiving' }) + if (!result.ok) { + ctx.addIssue({ + code: 'custom', + message: result.issues[0]?.message ?? `Could not read "${val}" as a ${kind}.`, + }) + return z.NEVER + } + value = result.quantity.to(unit).value + } + if (!Number.isFinite(value)) { + ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` }) + return z.NEVER + } + if (min !== undefined && value < min) { + ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` }) + return z.NEVER + } + if (max !== undefined && value > max) { + ctx.addIssue({ code: 'custom', message: `Must be at most ${max} ${unit} (got ${value}).` }) + return z.NEVER + } + return value + }) + .describe(description) +} diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index ad183be5..c2032ab8 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -13,6 +13,7 @@ import { import { z } from 'zod' import type { SceneOperations } from '../../operations' import { appendLiveSceneEvent } from '../live-sync' +import { measurement } from '../measurement' /** * Input shape for the `photo_to_scene` orchestrator. `image` matches the @@ -23,8 +24,14 @@ export const photoToSceneInput = { scaleHint: z.string().optional().describe('e.g. "1 cm = 1 m" or "approx 80 m²"'), name: z.string().default('Scene from photo'), save: z.boolean().default(true), - defaultWallThickness: z.number().default(0.2), - defaultWallHeight: z.number().default(2.6), + defaultWallThickness: measurement('length', 'm', { + min: 0, + description: 'Default wall thickness.', + }).default(0.2), + defaultWallHeight: measurement('length', 'm', { + min: 0, + description: 'Default wall height.', + }).default(2.6), } export const photoToSceneOutput = { diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 88691241..1b7da901 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -7,13 +7,14 @@ import { findCatalogItem } from './asset-catalog' import { ErrorCode, throwMcpError } from './errors' import { projectWorldPointToWallLocalX, wallLength } from './geometry' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec3Schema } from './schemas' export const placeItemInput = { catalogItemId: z.string().min(1), targetNodeId: NodeIdSchema, position: Vec3Schema, - rotation: z.number().optional(), + rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).optional(), } export const placeItemOutput = { diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 2fca54bd..51aa1dee 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -22,6 +22,7 @@ import { wallLocalXFromT, } from './geometry' import { publishLiveSceneSnapshot } from './live-sync' +import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema } from './schemas' const ROOM_TYPES = [ @@ -51,8 +52,8 @@ export const createRoomInput = { name: z.string().min(1), polygon: z.array(Vec2Schema).min(3), color: z.string().optional(), - wallHeight: z.number().positive().optional(), - wallThickness: z.number().positive().optional(), + wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), + wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), } export const createRoomOutput = { @@ -67,8 +68,8 @@ export const addDoorInput = { wallId: NodeIdSchema, t: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(), - width: z.number().positive().optional(), - height: z.number().positive().optional(), + width: measurement('length', 'm', { min: 0, description: 'Door width.' }).optional(), + height: measurement('length', 'm', { min: 0, description: 'Door height.' }).optional(), hingesSide: z.enum(['left', 'right']).optional(), swingDirection: z.enum(['inward', 'outward']).optional(), } @@ -87,9 +88,12 @@ export const addWindowInput = { wallId: NodeIdSchema, t: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(), - width: z.number().positive().optional(), - height: z.number().positive().optional(), - sillHeight: z.number().min(0).optional(), + width: measurement('length', 'm', { min: 0, description: 'Window width.' }).optional(), + height: measurement('length', 'm', { min: 0, description: 'Window height.' }).optional(), + sillHeight: measurement('length', 'm', { + min: 0, + description: 'Sill height above floor.', + }).optional(), } export const addWindowOutput = { From 3273aac551b8d3e0a2243c19dbe67720bd199ae2 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 13:09:01 +0200 Subject: [PATCH 3/6] fix(mcp): restore >0 validation and reject ambiguous numbers in measurement() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review follow-ups to the lingo measurement() field: - Add a `positive` option (strict > 0) and use it for the non-zero dimension params. Swapping z.number().positive() → measurement(..,{min:0}) had started admitting 0 (the core node schemas have no positivity backstop), so a zero-size wall/opening/roof could be created. Inclusive-0 fields (overhang, sill height, knee-wall height, opening offset, roof pitch) keep min:0. - Escalate AMBIGUOUS_NUMBER to error so "1,234" fails instead of silently reading as 1234 — a 1000x hazard for European decimals. Matches lingo's own /ai fields. - roofLevelElevation reverted to z.number() (it's a level ordinal, not meters); radians fields now advertise radian-appropriate examples. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/tools/construction-tools.ts | 52 ++++++++++------- packages/mcp/src/tools/create-wall.ts | 7 ++- packages/mcp/src/tools/cut-opening.ts | 4 +- packages/mcp/src/tools/measurement.test.ts | 13 +++++ packages/mcp/src/tools/measurement.ts | 58 ++++++++++++++----- .../tools/photo-to-scene/photo-to-scene.ts | 4 +- packages/mcp/src/tools/room-tools.ts | 18 ++++-- 7 files changed, 108 insertions(+), 48 deletions(-) diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index ff9aba36..a8e43e92 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -23,14 +23,20 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), - wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).default(2.8), - wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( - 0.16, + wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default( + 2.8, ), + wallThickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).default(0.16), createSlab: z.boolean().default(true), createCeiling: z.boolean().default(true), slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1), - ceilingHeight: measurement('length', 'm', { min: 0, description: 'Ceiling height.' }).optional(), + ceilingHeight: measurement('length', 'm', { + positive: true, + description: 'Ceiling height.', + }).optional(), namePrefix: z.string().optional(), wallMaterialPreset: z.string().optional(), slabMaterialPreset: z.string().optional(), @@ -50,24 +56,24 @@ export const createRoofInput = { roofLevelId: NodeIdSchema.optional(), useDedicatedRoofLevel: z.boolean().default(true), roofLevelLabel: z.string().default('Roof'), - roofLevelElevation: measurement('length', 'm', { - description: 'Roof level elevation.', - }).optional(), + // A level ordinal (story index), not a length — kept numeric. + roofLevelElevation: z.number().optional(), roofLevelHeight: measurement('length', 'm', { - min: 0, + positive: true, description: 'Roof level height.', }).optional(), center: Vec3Schema.optional(), - width: measurement('length', 'm', { min: 0, description: 'Roof width.' }), - depth: measurement('length', 'm', { min: 0, description: 'Roof depth.' }), + width: measurement('length', 'm', { positive: true, description: 'Roof width.' }), + depth: measurement('length', 'm', { positive: true, description: 'Roof depth.' }), roofType: z.enum(ROOF_TYPES).default('hip'), pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35), wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default( 0.35, ), - wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default( - 0.16, - ), + wallThickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).default(0.16), overhang: measurement('length', 'm', { min: 0, description: 'Eave overhang.' }).default(0.45), materialPreset: z.string().optional(), name: z.string().optional(), @@ -86,13 +92,15 @@ export const createStairBetweenLevelsInput = { toLevelId: NodeIdSchema, position: Vec3Schema, rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0), - width: measurement('length', 'm', { min: 0, description: 'Stair width.' }).default(1), - runLength: measurement('length', 'm', { min: 0, description: 'Horizontal run length.' }).default( - 3, - ), - totalRise: measurement('length', 'm', { min: 0, description: 'Total vertical rise.' }).default( - 2.8, - ), + width: measurement('length', 'm', { positive: true, description: 'Stair width.' }).default(1), + runLength: measurement('length', 'm', { + positive: true, + description: 'Horizontal run length.', + }).default(3), + totalRise: measurement('length', 'm', { + positive: true, + description: 'Total vertical rise.', + }).default(2.8), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), @@ -100,11 +108,11 @@ export const createStairBetweenLevelsInput = { createDestinationSlabOpening: z.boolean().default(true), createSourceCeilingOpening: z.boolean().default(true), openingWidth: measurement('length', 'm', { - min: 0, + positive: true, description: 'Floor opening width.', }).optional(), openingLength: measurement('length', 'm', { - min: 0, + positive: true, description: 'Floor opening length.', }).optional(), openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0), diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index ae9b6794..70679d15 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -12,8 +12,11 @@ export const createWallInput = { levelId: NodeIdSchema, start: Vec2Schema, end: Vec2Schema, - thickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), - height: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), + thickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).optional(), + height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(), } export const createWallOutput = { diff --git a/packages/mcp/src/tools/cut-opening.ts b/packages/mcp/src/tools/cut-opening.ts index b03f12f7..e5d8e2c4 100644 --- a/packages/mcp/src/tools/cut-opening.ts +++ b/packages/mcp/src/tools/cut-opening.ts @@ -13,8 +13,8 @@ export const cutOpeningInput = { wallId: NodeIdSchema, type: z.enum(['door', 'window']), position: z.number().min(0).max(1), - width: measurement('length', 'm', { min: 0, description: 'Opening width.' }), - height: measurement('length', 'm', { min: 0, description: 'Opening height.' }), + width: measurement('length', 'm', { positive: true, description: 'Opening width.' }), + height: measurement('length', 'm', { positive: true, description: 'Opening height.' }), } export const cutOpeningOutput = { diff --git a/packages/mcp/src/tools/measurement.test.ts b/packages/mcp/src/tools/measurement.test.ts index ff0f9b0b..deb90c0c 100644 --- a/packages/mcp/src/tools/measurement.test.ts +++ b/packages/mcp/src/tools/measurement.test.ts @@ -46,6 +46,19 @@ describe('measurement()', () => { if (!r.success) expect(r.error.issues[0]?.message.toLowerCase()).toContain('number') }) + test('positive rejects zero and negatives (restores .positive() behavior)', () => { + const m = measurement('length', 'm', { positive: true }) + expect(m.safeParse(0).success).toBe(false) + expect(m.safeParse('0m').success).toBe(false) + expect(m.safeParse(-1).success).toBe(false) + expect(m.parse(0.1)).toBe(0.1) + }) + + test('rejects ambiguous separators instead of a silent 1000x reading', () => { + const r = measurement('length', 'm').safeParse('1,234') + expect(r.success).toBe(false) + }) + test('emits a number|string JSON schema advertising natural language', () => { const schema = z.toJSONSchema( measurement('length', 'm', { min: 0, description: 'Wall thickness.' }), diff --git a/packages/mcp/src/tools/measurement.ts b/packages/mcp/src/tools/measurement.ts index 1767ad06..6df60b1e 100644 --- a/packages/mcp/src/tools/measurement.ts +++ b/packages/mcp/src/tools/measurement.ts @@ -13,15 +13,21 @@ import { z } from 'zod' * The emitted JSON Schema is `number | string`, so the model is free to answer * in whatever unit it is thinking in; AI SDK v6 applies the transform and * forwards the canonical number to the tool executor. + * + * Tool-boundary safety: genuinely ambiguous separators (`"1,234"`, which could + * mean 1234 or 1.234) are rejected rather than silently absorbed, so a European + * decimal never becomes a 1000× value. */ export interface MeasurementOptions { - /** Lower bound, in `unit`. */ - min?: number - /** Upper bound, in `unit`. */ - max?: number /** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */ description?: string + /** Upper bound, in `unit`. */ + max?: number + /** Inclusive lower bound, in `unit`. */ + min?: number + /** Require the value to be strictly greater than 0 (for non-zero dimensions). */ + positive?: boolean } function unitNoun(unit: string): string { @@ -38,16 +44,29 @@ function unitNoun(unit: string): string { } function naturalLanguageNote(kind: Kind, unit: string): string { - const examples = - kind === 'angle' - ? unit === 'rad' - ? '45, "45°", "1.57rad", "0.25 turn"' - : '45, "45°", "1.57rad", "0.25 turn"' - : '0.9, "6 ft", "180cm", "2 ft 3 in"' + let examples: string + if (kind === 'angle') { + // Lead with an example in the field's own unit so a bare number is read + // the way the model intends (a bare "45" in a radians field is 45 rad). + examples = + unit === 'rad' ? '1.5708, "90°", "1.57rad", "0.25 turn"' : '45, "45°", "1.57rad", "0.25 turn"' + } else { + examples = '0.9, "6 ft", "180cm", "2 ft 3 in"' + } return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.` } -function boundsNote(unit: string, min?: number, max?: number): string { +function boundsNote( + unit: string, + min: number | undefined, + max: number | undefined, + positive: boolean, +): string { + if (positive) { + return max === undefined + ? ` Must be greater than 0 ${unit}.` + : ` Greater than 0, up to ${max} ${unit}.` + } if (min !== undefined && max !== undefined) return ` Range ${min}–${max} ${unit}.` if (min !== undefined) return ` Minimum ${min} ${unit}.` if (max !== undefined) return ` Maximum ${max} ${unit}.` @@ -55,10 +74,10 @@ function boundsNote(unit: string, min?: number, max?: number): string { } export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = {}) { - const { min, max } = opts + const { min, max, positive = false } = opts const description = [ opts.description, - naturalLanguageNote(kind, unit) + boundsNote(unit, min, max), + naturalLanguageNote(kind, unit) + boundsNote(unit, min, max, positive), ] .filter(Boolean) .join(' ') @@ -70,7 +89,14 @@ export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = if (typeof val === 'number') { value = val } else { - const result = parseQuantity(val, { kind, unit, strictness: 'forgiving' }) + const result = parseQuantity(val, { + kind, + unit, + strictness: 'forgiving', + // A genuinely ambiguous separator ("1,234") must not silently become + // a 1000× value at a tool boundary — fail so the model self-corrects. + escalate: { AMBIGUOUS_NUMBER: 'error' }, + }) if (!result.ok) { ctx.addIssue({ code: 'custom', @@ -84,6 +110,10 @@ export function measurement(kind: Kind, unit: string, opts: MeasurementOptions = ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` }) return z.NEVER } + if (positive && value <= 0) { + ctx.addIssue({ code: 'custom', message: `Must be greater than 0 ${unit} (got ${value}).` }) + return z.NEVER + } if (min !== undefined && value < min) { ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` }) return z.NEVER diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index c2032ab8..96d2d2b1 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -25,11 +25,11 @@ export const photoToSceneInput = { name: z.string().default('Scene from photo'), save: z.boolean().default(true), defaultWallThickness: measurement('length', 'm', { - min: 0, + positive: true, description: 'Default wall thickness.', }).default(0.2), defaultWallHeight: measurement('length', 'm', { - min: 0, + positive: true, description: 'Default wall height.', }).default(2.6), } diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 51aa1dee..2a36fdc1 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -52,8 +52,14 @@ export const createRoomInput = { name: z.string().min(1), polygon: z.array(Vec2Schema).min(3), color: z.string().optional(), - wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(), - wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(), + wallHeight: measurement('length', 'm', { + positive: true, + description: 'Wall height.', + }).optional(), + wallThickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness.', + }).optional(), } export const createRoomOutput = { @@ -68,8 +74,8 @@ export const addDoorInput = { wallId: NodeIdSchema, t: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(), - width: measurement('length', 'm', { min: 0, description: 'Door width.' }).optional(), - height: measurement('length', 'm', { min: 0, description: 'Door height.' }).optional(), + width: measurement('length', 'm', { positive: true, description: 'Door width.' }).optional(), + height: measurement('length', 'm', { positive: true, description: 'Door height.' }).optional(), hingesSide: z.enum(['left', 'right']).optional(), swingDirection: z.enum(['inward', 'outward']).optional(), } @@ -88,8 +94,8 @@ export const addWindowInput = { wallId: NodeIdSchema, t: z.number().min(0).max(1).optional(), position: z.number().min(0).max(1).optional(), - width: measurement('length', 'm', { min: 0, description: 'Window width.' }).optional(), - height: measurement('length', 'm', { min: 0, description: 'Window height.' }).optional(), + width: measurement('length', 'm', { positive: true, description: 'Window width.' }).optional(), + height: measurement('length', 'm', { positive: true, description: 'Window height.' }).optional(), sillHeight: measurement('length', 'm', { min: 0, description: 'Sill height above floor.', From af1fe6a34c7a954d4bb455db63d2a58d33141064 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 13:32:36 +0200 Subject: [PATCH 4/6] chore(deps): add @pascal-app/lingo to the lockfile; note duplicated helper Records @pascal-app/lingo@^0.1.0 in bun.lock (added to @pascal-app/editor and @pascal-app/mcp), so `bun install --frozen-lockfile` in CI resolves it. Also documents that measurement.ts is intentionally duplicated with the AI-chat copy across the submodule boundary and must be kept in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 4 ++++ packages/mcp/src/tools/measurement.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/bun.lock b/bun.lock index f402fa78..304552a1 100644 --- a/bun.lock +++ b/bun.lock @@ -131,6 +131,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/lingo": "^0.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -220,6 +221,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@pascal-app/lingo": "^0.1.0", "zod": "^4.3.5", }, "devDependencies": { @@ -714,6 +716,8 @@ "@pascal-app/ifc-converter": ["@pascal-app/ifc-converter@workspace:packages/ifc-converter"], + "@pascal-app/lingo": ["@pascal-app/lingo@0.1.0", "", { "peerDependencies": { "react": "^19" }, "optionalPeers": ["react"] }, "sha512-j4cN9DSc3QD4LGG003TCIO5+jMYySFpJO6467V0Q3IPumVkwq7/BpEAw8JGY6zTZFmFETIeupuPEi4drnA9m+g=="], + "@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"], "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], diff --git a/packages/mcp/src/tools/measurement.ts b/packages/mcp/src/tools/measurement.ts index 6df60b1e..d17a5cc0 100644 --- a/packages/mcp/src/tools/measurement.ts +++ b/packages/mcp/src/tools/measurement.ts @@ -17,6 +17,12 @@ import { z } from 'zod' * Tool-boundary safety: genuinely ambiguous separators (`"1,234"`, which could * mean 1234 or 1.234) are rejected rather than silently absorbed, so a European * decimal never becomes a 1000× value. + * + * NOTE: intentionally duplicated as `editor/packages/mcp/src/tools/measurement.ts` + * (MCP tools) and `packages/ai/src/tools/scene/measurement.ts` (AI-chat tools). + * The two tool stacks live in different packages — one inside the `editor` + * submodule — so they can't share a module. Keep the two copies identical; the + * clean long-term dedup is a zod-field adapter exported from `@pascal-app/lingo`. */ export interface MeasurementOptions { From 69813a6e62c4f5d83e449510d3e194a6ef71b719 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 17:00:47 +0200 Subject: [PATCH 5/6] feat(editor): honor the metric/imperial toggle in every length input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SliderControl (which drives the auto-inspector's ~144 fields and most custom panels) previously ignored the viewer unit preference and always rendered raw meters with a static "m" label, while MetricControl converted to feet — so toggling imperial changed some length inputs and not others. Extract the conversion into a shared `useLinearDisplay(unit, precision)` hook and route BOTH controls through it, so every `unit="m"` length field displays and edits in feet when imperial and meters when metric — consistently. Values are always stored in meters; only display, the text field, and drag/ wheel/arrow deltas move to the display unit. For metric and non-length units (°, %, in, …) the conversions are the identity, so those paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ui/controls/metric-control.tsx | 25 ++--- .../components/ui/controls/slider-control.tsx | 102 +++++++++++------- packages/editor/src/lib/use-linear-display.ts | 40 +++++++ 3 files changed, 108 insertions(+), 59 deletions(-) create mode 100644 packages/editor/src/lib/use-linear-display.ts diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index f9336eb7..9742c813 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -1,18 +1,13 @@ 'use client' import { useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { lingoUnitSpec, measurementHint, parseMeasurement, } from '../../../lib/measurement-parser' -import { - getLinearUnitLabel, - linearUnitToMeters, - metersToLinearUnit, -} from '../../../lib/measurements' +import { useLinearDisplay } from '../../../lib/use-linear-display' import { cn } from '../../../lib/utils' interface MetricControlProps { @@ -42,19 +37,13 @@ export function MetricControl({ unit = '', restoreOnCommit = true, }: MetricControlProps) { - const viewerUnit = useViewer((state) => state.unit) - const isImperial = viewerUnit === 'imperial' && unit === 'm' - const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit + const { + isImperial, + displayUnit, + toDisplay: toDisplayValue, + toStored: toStoredValue, + } = useLinearDisplay(unit, precision) - const toDisplayValue = useCallback( - (storedValue: number) => (isImperial ? metersToLinearUnit(storedValue, 'imperial') : storedValue), - [isImperial], - ) - const toStoredValue = useCallback( - (displayValue: number) => - isImperial ? linearUnitToMeters(displayValue, 'imperial') : displayValue, - [isImperial], - ) const clamp = useCallback( (val: number) => { return Math.min(Math.max(val, min), max) diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 36386eef..c902cecd 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -7,6 +7,7 @@ import { measurementHint, parseMeasurement, } from '../../../lib/measurement-parser' +import { useLinearDisplay } from '../../../lib/use-linear-display' import { cn } from '../../../lib/utils' interface SliderControlProps { @@ -64,10 +65,17 @@ export function SliderControl({ unit = '', restoreOnCommit = true, }: SliderControlProps) { + // Display/storage conversion so the value honors the metric/imperial toggle. + // `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the + // stored unit (meters for `unit === 'm'`); the step, drag deltas, text field + // and rendered number are in the DISPLAY unit (feet when imperial). For + // metric and non-length units these conversions are the identity. + const { isImperial, displayUnit, toDisplay, toStored } = useLinearDisplay(unit, precision) + const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isHovered, setIsHovered] = useState(false) - const [inputValue, setInputValue] = useState(value.toFixed(precision)) + const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision)) const dragRef = useRef<{ // Original value at drag start — preserved across modifier re-anchors so @@ -85,12 +93,23 @@ export function SliderControl({ valueRef.current = value const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max]) + // Apply a signed display-unit delta to a stored value, rounding in the + // display unit and clamping in the stored unit. + const applyDisplayDelta = useCallback( + (storedValue: number, displayDelta: number, displayStep: number) => + clamp( + toStored( + Number.parseFloat((toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep))), + ), + ), + [clamp, toDisplay, toStored], + ) useEffect(() => { if (!isEditing) { - setInputValue(value.toFixed(precision)) + setInputValue(toDisplay(value).toFixed(precision)) } - }, [value, precision, isEditing]) + }, [value, precision, isEditing, toDisplay]) // Wheel support on the label useEffect(() => { @@ -101,14 +120,13 @@ export function SliderControl({ e.preventDefault() const direction = e.deltaY < 0 ? 1 : -1 const s = getAdjustedStep(step, e) - const newValue = clamp(valueRef.current + direction * s) - const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) + const final = applyDisplayDelta(valueRef.current, direction * s, s) if (final !== valueRef.current) onChange(final) onCommit?.(final) } el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) - }, [isEditing, step, clamp, onChange, onCommit]) + }, [isEditing, step, applyDisplayDelta, onChange, onCommit]) // Arrow key support while hovered useEffect(() => { @@ -120,15 +138,14 @@ export function SliderControl({ if (direction !== 0) { e.preventDefault() const s = getAdjustedStep(step, e) - const newValue = clamp(valueRef.current + direction * s) - const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) + const final = applyDisplayDelta(valueRef.current, direction * s, s) if (final !== valueRef.current) onChange(final) onCommit?.(final) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isHovered, isEditing, step, clamp, onChange, onCommit]) + }, [isHovered, isEditing, step, applyDisplayDelta, onChange, onCommit]) const handleLabelPointerDown = useCallback( (e: React.PointerEvent) => { @@ -165,15 +182,13 @@ export function SliderControl({ const dx = e.clientX - anchorX const s = step * multiplier // 4 px per step at default sensitivity - const newValue = clamp( - Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))), - ) + const newValue = applyDisplayDelta(anchorValue, (dx / 4) * s, s) if (newValue !== valueRef.current) { valueRef.current = newValue onChange(newValue) } }, - [step, clamp, onChange], + [step, applyDisplayDelta, onChange], ) const handleLabelPointerUp = useCallback( @@ -200,30 +215,42 @@ export function SliderControl({ const handleValueClick = useCallback(() => { setIsEditing(true) - setInputValue(value.toFixed(precision)) - }, [value, precision]) + setInputValue(toDisplay(value).toFixed(precision)) + }, [value, precision, toDisplay]) const submitValue = useCallback(() => { const spec = lingoUnitSpec(unit) - let parsed = spec ? parseMeasurement(inputValue, spec) : null - if (parsed === null) { + let stored = spec + ? parseMeasurement(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + }) + : null + if (stored === null) { + // Fallback: a bare number typed in the DISPLAY unit → convert to stored. const numValue = Number.parseFloat(inputValue) - parsed = Number.isFinite(numValue) ? numValue : null + stored = Number.isFinite(numValue) ? toStored(numValue) : null } - if (parsed === null) { - setInputValue(value.toFixed(precision)) + if (stored === null) { + setInputValue(toDisplay(value).toFixed(precision)) } else { - const nextValue = clamp(Number.parseFloat(parsed.toFixed(precision))) + const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision)))) onChange(nextValue) onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, unit, onChange, onCommit, clamp, precision, value]) + }, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored]) const spec = lingoUnitSpec(unit) const hint = isEditing && spec - ? measurementHint(inputValue, spec, { displayUnit: spec.unitId, precision, clamp }) + ? measurementHint(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + displayUnit: isImperial ? 'ft' : spec.unitId, + precision, + clamp, + }) : null const handleInputKeyDown = useCallback( @@ -231,29 +258,22 @@ export function SliderControl({ if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { - setInputValue(value.toFixed(precision)) + setInputValue(toDisplay(value).toFixed(precision)) setIsEditing(false) - } else if (e.key === 'ArrowUp') { + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() + const direction = e.key === 'ArrowUp' ? 1 : -1 const adjustedStep = getAdjustedStep(step, e) - const newV = clamp( - Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))), - ) + const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep) onChange(newV) - setInputValue(newV.toFixed(precision)) - } else if (e.key === 'ArrowDown') { - e.preventDefault() - const adjustedStep = getAdjustedStep(step, e) - const newV = clamp( - Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))), - ) - onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue(toDisplay(newV).toFixed(precision)) } }, - [submitValue, value, precision, step, clamp, onChange], + [submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay], ) + const displayValue = toDisplay(value) + return (
- {unit && {unit}} + {displayUnit && {displayUnit}} ) : (
- {Number(value.toFixed(precision)).toFixed(precision)} + {Number(displayValue.toFixed(precision)).toFixed(precision)} - {unit && {unit}} + {displayUnit && {displayUnit}}
)}
diff --git a/packages/editor/src/lib/use-linear-display.ts b/packages/editor/src/lib/use-linear-display.ts new file mode 100644 index 00000000..6578ea30 --- /dev/null +++ b/packages/editor/src/lib/use-linear-display.ts @@ -0,0 +1,40 @@ +'use client' + +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' +import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements' + +/** + * Shared display/storage conversion for numeric property controls so that + * every length input honors the metric/imperial toggle identically. + * + * Values are always STORED in the field's own unit (meters for `unit === 'm'`). + * When the viewer preference is imperial AND the field is a meter length, the + * value is DISPLAYED (and edited) in feet; otherwise the conversions are the + * identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`, + * …) behave exactly as before. + * + * Used by both `SliderControl` and `MetricControl` — keep the two in sync via + * this single source of truth. + */ +export function useLinearDisplay(unit: string, precision: number) { + const viewerUnit = useViewer((state) => state.unit) + const isImperial = viewerUnit === 'imperial' && unit === 'm' + const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit + + const toDisplay = useCallback( + (stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored), + [isImperial], + ) + const toStored = useCallback( + (display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display), + [isImperial], + ) + // Round a stored value so it lands on a clean number of DISPLAY-unit digits. + const roundStored = useCallback( + (stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))), + [toDisplay, toStored, precision], + ) + + return { isImperial, displayUnit, toDisplay, toStored, roundStored } +} From 5ffde5f66ae5ab18a99d08d2a8d141653b2141b4 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 17:00:47 +0200 Subject: [PATCH 6/6] fix(editor): site-panel property-line coords + area/perimeter follow the unit toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property-line vertex X/Z inputs and the Area/Perimeter readout were hardcoded to meters. Convert them to feet / ft² when the viewer preference is imperial, matching every other length input. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/sidebar/panels/site-panel/index.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index bb5209eb..4ce869b6 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -38,6 +38,11 @@ import { } from './../../../../../lib/level-duplication' import { getDefaultLevelName } from '@pascal-app/core' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' +import { + getLinearUnitLabel, + linearUnitToMeters, + metersToLinearUnit, +} from './../../../../../lib/measurements' import { createLocalGuideImage } from './../../../../../lib/local-guide-image' import { cn } from './../../../../../lib/utils' import useEditor from './../../../../../store/use-editor' @@ -93,6 +98,7 @@ const PropertyLineSection = memo(function PropertyLineSection() { const updateNode = useScene((state) => state.updateNode) const mode = useEditor((state) => state.mode) const setMode = useEditor((state) => state.setMode) + const viewerUnit = useViewer((state) => state.unit) if (!siteNode) return null @@ -101,6 +107,14 @@ const PropertyLineSection = memo(function PropertyLineSection() { const perimeter = calculatePerimeter(points) const isEditing = mode === 'edit' + // Property-line coordinates and readouts follow the metric/imperial toggle. + const isImperial = viewerUnit === 'imperial' + const linearLabel = getLinearUnitLabel(viewerUnit) + const toDisplayLinear = (meters: number) => metersToLinearUnit(meters, viewerUnit) + const toStoredLinear = (display: number) => linearUnitToMeters(display, viewerUnit) + const displayArea = isImperial ? area * 10.763_910_417 : area + const displayPerimeter = toDisplayLinear(perimeter) + const handleToggleEdit = () => { setMode(isEditing ? 'select' : 'edit') } @@ -166,10 +180,16 @@ const PropertyLineSection = memo(function PropertyLineSection() { {/* Measurements */}
- Area: {area.toFixed(1)} m² + Area:{' '} + + {displayArea.toFixed(1)} {isImperial ? 'ft²' : 'm²'} +
- Perimeter: {perimeter.toFixed(1)} m + Perimeter:{' '} + + {displayPerimeter.toFixed(1)} {linearLabel} +
@@ -184,21 +204,21 @@ const PropertyLineSection = memo(function PropertyLineSection() { - handlePointChange(index, 0, Number.parseFloat(e.target.value) || 0) + handlePointChange(index, 0, toStoredLinear(Number.parseFloat(e.target.value) || 0)) } step={0.5} type="number" - value={point[0]} + value={Number(toDisplayLinear(point[0]).toFixed(2))} /> - handlePointChange(index, 1, Number.parseFloat(e.target.value) || 0) + handlePointChange(index, 1, toStoredLinear(Number.parseFloat(e.target.value) || 0)) } step={0.5} type="number" - value={point[1]} + value={Number(toDisplayLinear(point[1]).toFixed(2))} />