Skip to content

feat: natural-language measurement inputs (UI + MCP tools) via @pascal-app/lingo#472

Merged
Aymericr merged 6 commits into
mainfrom
feat/lingo-natural-inputs
Jul 8, 2026
Merged

feat: natural-language measurement inputs (UI + MCP tools) via @pascal-app/lingo#472
Aymericr merged 6 commits into
mainfrom
feat/lingo-natural-inputs

Conversation

@Aymericr

@Aymericr Aymericr commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What

Integrates @pascal-app/lingo so users and LLMs can express measurements in natural language, canonicalized to the units the editor already stores. Two surfaces:

1. UI property inputs (packages/editor)

Every editable measurement field now accepts 6ft, 180cm, 1m80, 5'11", 72in, 45°, 1.57rad (typo-tolerant too), regardless of the metric/imperial toggle.

  • New adapter packages/editor/src/lib/measurement-parser.ts maps a field's unit prop → a lingo kind + canonical unit.
  • Wired into the two shared primitives — SliderControl (the auto-inspector's ~144 fields + most custom panels) and MetricControlonly on the typed-text commit path. Drag-scrub, wheel, and arrow-key editing are byte-for-byte unchanged; any unrecognized unit falls back to Number.parseFloat, so nothing regresses.
  • A live = 1.83 m conversion hint previews the parsed value while typing (clamped to the field's min/max so it matches what a commit stores).
  • Angle fields need no call-site changes — they already pass unit="°"/"rad".

2. MCP tool inputs (packages/mcp)

Measurement/angle parameters on the scene tools now accept a bare number or a natural-language string, canonicalized to the unit each handler 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, and a bad value ("banana", out-of-range, or an ambiguous "1,234") is rejected with a message the model can self-correct from.

  • 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. Position coordinates, parametric t (0–1), and integer counts are intentionally left numeric.

Why

Users think in mixed units; today a field showing meters forces an imperial user to mentally convert. And LLMs emit "6 ft" far more reliably than a canonical float (Number("6ft") is NaN). lingo's length base is meters and angle base is radians — exactly how the editor already stores values — so it drops in with no schema change.

Testing

  • UI: verified live in the editor — 6ft→1.83 m, 1m80→1.80 m, 2 ft 3 in→0.69 m, 1.57rad→90.0°, with the live hint and safe revert on invalid input. Imperial parity is byte-identical to the old × 0.3048 path.
  • MCP: measurement.test.ts (helper) + create-wall.test.ts (natural-language round-trip through the MCP SDK). Full suite: 293 tests pass. New coverage for positive/ambiguous-number rejection.
  • bun run check (biome), bun run check-types, core+mcp build all green.

Notes

  • measurement.ts is duplicated between packages/mcp and the private-editor AI-chat package (they're in different repos and can't share a module). Kept in sync; the clean long-term dedup is a zod-field adapter exported from @pascal-app/lingo itself.
  • Reviewed by two independent adversarial passes; both fixes applied (restored strict >0 validation that .positive() provided; escalate AMBIGUOUS_NUMBER to error so "1,234" can't silently become 1234).

🤖 Generated with Claude Code


Note

Medium Risk
Touches many MCP tool inputs and editor numeric commit paths; wrong unit conversion or parse edge cases could change stored geometry, though numbers still pass through and ambiguous strings are rejected at the MCP boundary.

Overview
Adds @pascal-app/lingo to editor and MCP so measurement values can be typed or passed as natural language and normalized to the units the app already stores.

In the editor, new measurement-parser and useLinearDisplay back MetricControl and SliderControl: typed commits accept strings like 6ft, 1m80, 5'11", and angles; a live = … hint shows the parsed value; imperial m fields keep display/edit in feet while storage stays in meters. SliderControl also routes wheel, drag, and arrow deltas through display-unit conversion. The site panel property line shows area/perimeter and vertex coords in the viewer’s metric/imperial preference.

In MCP, a shared measurement() Zod field (number | string) wraps lingo parsing with bounds, positive, and hard failure on ambiguous numbers like 1,234; construction/scene tools swap raw z.number() length/angle args for this helper. Tests cover the helper and create_wall NL round-trips.

Reviewed by Cursor Bugbot for commit 5ffde5f. Bugbot is set up for automated code reviews on this repo. Configure here.

Aymericr and others added 4 commits July 8, 2026 12:04
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) <noreply@anthropic.com>
…app/lingo

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) <noreply@anthropic.com>
…rement()

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) <noreply@anthropic.com>
…elper

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) <noreply@anthropic.com>
unit: options.bareUnit ?? spec.unitId,
system: options.system,
strictness: 'forgiving',
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ambiguous commas accepted in UI

Medium Severity

Editor parseMeasurement calls parseQuantity with forgiving strictness but without escalate: { AMBIGUOUS_NUMBER: 'error' }, unlike MCP measurement(). Comma-separated values such as 1,234 can be parsed as thousands of meters instead of being rejected or reverted, so committed scene dimensions may be wrong by orders of magnitude.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit af1fe6a. Configure here.

@mintlify

mintlify Bot commented Jul 8, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
pascal 🔴 Failed Jul 8, 2026, 11:54 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Aymericr and others added 2 commits July 8, 2026 17:00
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) <noreply@anthropic.com>
…the unit toggle

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) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5ffde5f. Configure here.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Imperial mode mis-scales scrub steps

Medium Severity

With the viewer set to imperial on unit="m" fields, wheel, drag, and arrow adjustments use the raw step prop as feet (or apply toStored to it) even though callers like the parametric inspector still pass step in stored meters. Each tick moves roughly three times less stored length than before, so scrubbing no longer matches field descriptors or prior behavior.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5ffde5f. Configure here.

@Aymericr Aymericr merged commit 2cfce3f into main Jul 8, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant