feat(mcp): natural-language measurements for tool inputs via @pascal-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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a5963560b1
commit
f66dcd438b
@@ -59,6 +59,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@pascal-app/lingo": "^0.1.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { SceneOperations } from '../operations'
|
import type { SceneOperations } from '../operations'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
|
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
|
||||||
|
|
||||||
const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const
|
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 = {
|
export const createStoryShellInput = {
|
||||||
levelId: NodeIdSchema,
|
levelId: NodeIdSchema,
|
||||||
footprint: z.array(Vec2Schema).min(3),
|
footprint: z.array(Vec2Schema).min(3),
|
||||||
wallHeight: z.number().positive().default(2.8),
|
wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).default(2.8),
|
||||||
wallThickness: z.number().positive().default(0.16),
|
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).default(
|
||||||
|
0.16,
|
||||||
|
),
|
||||||
createSlab: z.boolean().default(true),
|
createSlab: z.boolean().default(true),
|
||||||
createCeiling: z.boolean().default(true),
|
createCeiling: z.boolean().default(true),
|
||||||
slabElevation: z.number().default(0.1),
|
slabElevation: measurement('length', 'm', { description: 'Slab elevation.' }).default(0.1),
|
||||||
ceilingHeight: z.number().positive().optional(),
|
ceilingHeight: measurement('length', 'm', { min: 0, description: 'Ceiling height.' }).optional(),
|
||||||
namePrefix: z.string().optional(),
|
namePrefix: z.string().optional(),
|
||||||
wallMaterialPreset: z.string().optional(),
|
wallMaterialPreset: z.string().optional(),
|
||||||
slabMaterialPreset: z.string().optional(),
|
slabMaterialPreset: z.string().optional(),
|
||||||
@@ -47,16 +50,25 @@ export const createRoofInput = {
|
|||||||
roofLevelId: NodeIdSchema.optional(),
|
roofLevelId: NodeIdSchema.optional(),
|
||||||
useDedicatedRoofLevel: z.boolean().default(true),
|
useDedicatedRoofLevel: z.boolean().default(true),
|
||||||
roofLevelLabel: z.string().default('Roof'),
|
roofLevelLabel: z.string().default('Roof'),
|
||||||
roofLevelElevation: z.number().optional(),
|
roofLevelElevation: measurement('length', 'm', {
|
||||||
roofLevelHeight: z.number().positive().optional(),
|
description: 'Roof level elevation.',
|
||||||
|
}).optional(),
|
||||||
|
roofLevelHeight: measurement('length', 'm', {
|
||||||
|
min: 0,
|
||||||
|
description: 'Roof level height.',
|
||||||
|
}).optional(),
|
||||||
center: Vec3Schema.optional(),
|
center: Vec3Schema.optional(),
|
||||||
width: z.number().positive(),
|
width: measurement('length', 'm', { min: 0, description: 'Roof width.' }),
|
||||||
depth: z.number().positive(),
|
depth: measurement('length', 'm', { min: 0, description: 'Roof depth.' }),
|
||||||
roofType: z.enum(ROOF_TYPES).default('hip'),
|
roofType: z.enum(ROOF_TYPES).default('hip'),
|
||||||
pitch: z.number().min(0).max(85).default(35),
|
pitch: measurement('angle', 'deg', { min: 0, max: 85, description: 'Roof pitch.' }).default(35),
|
||||||
wallHeight: z.number().min(0).default(0.35),
|
wallHeight: measurement('length', 'm', { min: 0, description: 'Knee-wall height.' }).default(
|
||||||
wallThickness: z.number().positive().default(0.16),
|
0.35,
|
||||||
overhang: z.number().min(0).default(0.45),
|
),
|
||||||
|
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(),
|
materialPreset: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
}
|
}
|
||||||
@@ -73,21 +85,31 @@ export const createStairBetweenLevelsInput = {
|
|||||||
fromLevelId: NodeIdSchema,
|
fromLevelId: NodeIdSchema,
|
||||||
toLevelId: NodeIdSchema,
|
toLevelId: NodeIdSchema,
|
||||||
position: Vec3Schema,
|
position: Vec3Schema,
|
||||||
rotation: z.number().default(0),
|
rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0),
|
||||||
width: z.number().positive().default(1),
|
width: measurement('length', 'm', { min: 0, description: 'Stair width.' }).default(1),
|
||||||
runLength: z.number().positive().default(3),
|
runLength: measurement('length', 'm', { min: 0, description: 'Horizontal run length.' }).default(
|
||||||
totalRise: z.number().positive().default(2.8),
|
3,
|
||||||
|
),
|
||||||
|
totalRise: measurement('length', 'm', { min: 0, description: 'Total vertical rise.' }).default(
|
||||||
|
2.8,
|
||||||
|
),
|
||||||
stepCount: z.number().int().positive().default(14),
|
stepCount: z.number().int().positive().default(14),
|
||||||
railingMode: z.enum(RAILING_MODES).default('both'),
|
railingMode: z.enum(RAILING_MODES).default('both'),
|
||||||
destinationSlabId: NodeIdSchema.optional(),
|
destinationSlabId: NodeIdSchema.optional(),
|
||||||
sourceCeilingId: NodeIdSchema.optional(),
|
sourceCeilingId: NodeIdSchema.optional(),
|
||||||
createDestinationSlabOpening: z.boolean().default(true),
|
createDestinationSlabOpening: z.boolean().default(true),
|
||||||
createSourceCeilingOpening: z.boolean().default(true),
|
createSourceCeilingOpening: z.boolean().default(true),
|
||||||
openingWidth: z.number().positive().optional(),
|
openingWidth: measurement('length', 'm', {
|
||||||
openingLength: z.number().positive().optional(),
|
min: 0,
|
||||||
openingOffset: z.number().min(0).default(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(),
|
openingCenter: Vec2Schema.optional(),
|
||||||
openingRotation: z.number().optional(),
|
openingRotation: measurement('angle', 'rad', { description: 'Opening rotation.' }).optional(),
|
||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import { z } from 'zod'
|
|||||||
import type { SceneOperations } from '../operations'
|
import type { SceneOperations } from '../operations'
|
||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema } from './schemas'
|
import { NodeIdSchema } from './schemas'
|
||||||
|
|
||||||
export const createLevelInput = {
|
export const createLevelInput = {
|
||||||
buildingId: NodeIdSchema,
|
buildingId: NodeIdSchema,
|
||||||
elevation: z.number().optional(),
|
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(),
|
label: z.string().optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,34 @@ describe('create_wall', () => {
|
|||||||
expect((created as { thickness?: number }).thickness).toBe(0.15)
|
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 () => {
|
test('publishes a live scene snapshot when bound to a saved scene', async () => {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const savedMeta: SceneMeta = {
|
const savedMeta: SceneMeta = {
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import { z } from 'zod'
|
|||||||
import type { SceneOperations } from '../operations'
|
import type { SceneOperations } from '../operations'
|
||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec2Schema } from './schemas'
|
import { NodeIdSchema, Vec2Schema } from './schemas'
|
||||||
|
|
||||||
export const createWallInput = {
|
export const createWallInput = {
|
||||||
levelId: NodeIdSchema,
|
levelId: NodeIdSchema,
|
||||||
start: Vec2Schema,
|
start: Vec2Schema,
|
||||||
end: Vec2Schema,
|
end: Vec2Schema,
|
||||||
thickness: z.number().positive().optional(),
|
thickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(),
|
||||||
height: z.number().positive().optional(),
|
height: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWallOutput = {
|
export const createWallOutput = {
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import type { SceneOperations } from '../operations'
|
|||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { wallLength, wallLocalXFromT } from './geometry'
|
import { wallLength, wallLocalXFromT } from './geometry'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema } from './schemas'
|
import { NodeIdSchema } from './schemas'
|
||||||
|
|
||||||
export const cutOpeningInput = {
|
export const cutOpeningInput = {
|
||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
type: z.enum(['door', 'window']),
|
type: z.enum(['door', 'window']),
|
||||||
position: z.number().min(0).max(1),
|
position: z.number().min(0).max(1),
|
||||||
width: z.number().positive(),
|
width: measurement('length', 'm', { min: 0, description: 'Opening width.' }),
|
||||||
height: z.number().positive(),
|
height: measurement('length', 'm', { min: 0, description: 'Opening height.' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cutOpeningOutput = {
|
export const cutOpeningOutput = {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { SceneOperations } from '../../operations'
|
import type { SceneOperations } from '../../operations'
|
||||||
import { appendLiveSceneEvent } from '../live-sync'
|
import { appendLiveSceneEvent } from '../live-sync'
|
||||||
|
import { measurement } from '../measurement'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input shape for the `photo_to_scene` orchestrator. `image` matches the
|
* 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²"'),
|
scaleHint: z.string().optional().describe('e.g. "1 cm = 1 m" or "approx 80 m²"'),
|
||||||
name: z.string().default('Scene from photo'),
|
name: z.string().default('Scene from photo'),
|
||||||
save: z.boolean().default(true),
|
save: z.boolean().default(true),
|
||||||
defaultWallThickness: z.number().default(0.2),
|
defaultWallThickness: measurement('length', 'm', {
|
||||||
defaultWallHeight: z.number().default(2.6),
|
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 = {
|
export const photoToSceneOutput = {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { findCatalogItem } from './asset-catalog'
|
|||||||
import { ErrorCode, throwMcpError } from './errors'
|
import { ErrorCode, throwMcpError } from './errors'
|
||||||
import { projectWorldPointToWallLocalX, wallLength } from './geometry'
|
import { projectWorldPointToWallLocalX, wallLength } from './geometry'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec3Schema } from './schemas'
|
import { NodeIdSchema, Vec3Schema } from './schemas'
|
||||||
|
|
||||||
export const placeItemInput = {
|
export const placeItemInput = {
|
||||||
catalogItemId: z.string().min(1),
|
catalogItemId: z.string().min(1),
|
||||||
targetNodeId: NodeIdSchema,
|
targetNodeId: NodeIdSchema,
|
||||||
position: Vec3Schema,
|
position: Vec3Schema,
|
||||||
rotation: z.number().optional(),
|
rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const placeItemOutput = {
|
export const placeItemOutput = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
wallLocalXFromT,
|
wallLocalXFromT,
|
||||||
} from './geometry'
|
} from './geometry'
|
||||||
import { publishLiveSceneSnapshot } from './live-sync'
|
import { publishLiveSceneSnapshot } from './live-sync'
|
||||||
|
import { measurement } from './measurement'
|
||||||
import { NodeIdSchema, Vec2Schema } from './schemas'
|
import { NodeIdSchema, Vec2Schema } from './schemas'
|
||||||
|
|
||||||
const ROOM_TYPES = [
|
const ROOM_TYPES = [
|
||||||
@@ -51,8 +52,8 @@ export const createRoomInput = {
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
polygon: z.array(Vec2Schema).min(3),
|
polygon: z.array(Vec2Schema).min(3),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
wallHeight: z.number().positive().optional(),
|
wallHeight: measurement('length', 'm', { min: 0, description: 'Wall height.' }).optional(),
|
||||||
wallThickness: z.number().positive().optional(),
|
wallThickness: measurement('length', 'm', { min: 0, description: 'Wall thickness.' }).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRoomOutput = {
|
export const createRoomOutput = {
|
||||||
@@ -67,8 +68,8 @@ export const addDoorInput = {
|
|||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
t: z.number().min(0).max(1).optional(),
|
t: z.number().min(0).max(1).optional(),
|
||||||
position: z.number().min(0).max(1).optional(),
|
position: z.number().min(0).max(1).optional(),
|
||||||
width: z.number().positive().optional(),
|
width: measurement('length', 'm', { min: 0, description: 'Door width.' }).optional(),
|
||||||
height: z.number().positive().optional(),
|
height: measurement('length', 'm', { min: 0, description: 'Door height.' }).optional(),
|
||||||
hingesSide: z.enum(['left', 'right']).optional(),
|
hingesSide: z.enum(['left', 'right']).optional(),
|
||||||
swingDirection: z.enum(['inward', 'outward']).optional(),
|
swingDirection: z.enum(['inward', 'outward']).optional(),
|
||||||
}
|
}
|
||||||
@@ -87,9 +88,12 @@ export const addWindowInput = {
|
|||||||
wallId: NodeIdSchema,
|
wallId: NodeIdSchema,
|
||||||
t: z.number().min(0).max(1).optional(),
|
t: z.number().min(0).max(1).optional(),
|
||||||
position: z.number().min(0).max(1).optional(),
|
position: z.number().min(0).max(1).optional(),
|
||||||
width: z.number().positive().optional(),
|
width: measurement('length', 'm', { min: 0, description: 'Window width.' }).optional(),
|
||||||
height: z.number().positive().optional(),
|
height: measurement('length', 'm', { min: 0, description: 'Window height.' }).optional(),
|
||||||
sillHeight: z.number().min(0).optional(),
|
sillHeight: measurement('length', 'm', {
|
||||||
|
min: 0,
|
||||||
|
description: 'Sill height above floor.',
|
||||||
|
}).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addWindowOutput = {
|
export const addWindowOutput = {
|
||||||
|
|||||||
Reference in New Issue
Block a user