@pascal-app/mcp 0.3.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +2 -1
  2. package/dist/bridge/scene-bridge.d.ts.map +1 -1
  3. package/dist/bridge/scene-bridge.js +6 -0
  4. package/dist/operations/scene-operations.js +7 -6
  5. package/dist/resources/constraints.js +1 -1
  6. package/dist/templates/empty-studio.d.ts.map +1 -1
  7. package/dist/templates/empty-studio.js +2 -4
  8. package/dist/templates/garden-house.js +1 -1
  9. package/dist/templates/two-bedroom.js +1 -1
  10. package/dist/tools/construction-tools.d.ts +21 -21
  11. package/dist/tools/construction-tools.d.ts.map +1 -1
  12. package/dist/tools/construction-tools.js +81 -33
  13. package/dist/tools/create-level.d.ts +1 -1
  14. package/dist/tools/create-level.d.ts.map +1 -1
  15. package/dist/tools/create-level.js +19 -7
  16. package/dist/tools/create-wall.d.ts +2 -2
  17. package/dist/tools/create-wall.d.ts.map +1 -1
  18. package/dist/tools/create-wall.js +6 -2
  19. package/dist/tools/cut-opening.d.ts +3 -3
  20. package/dist/tools/cut-opening.d.ts.map +1 -1
  21. package/dist/tools/cut-opening.js +3 -2
  22. package/dist/tools/describe-node.d.ts.map +1 -1
  23. package/dist/tools/describe-node.js +14 -5
  24. package/dist/tools/find-nodes.d.ts +2 -2
  25. package/dist/tools/find-nodes.js +1 -1
  26. package/dist/tools/measurement.d.ts +37 -0
  27. package/dist/tools/measurement.d.ts.map +1 -0
  28. package/dist/tools/measurement.js +94 -0
  29. package/dist/tools/photo-to-scene/photo-to-scene.d.ts +2 -2
  30. package/dist/tools/photo-to-scene/photo-to-scene.d.ts.map +1 -1
  31. package/dist/tools/photo-to-scene/photo-to-scene.js +14 -5
  32. package/dist/tools/place-item.d.ts +1 -1
  33. package/dist/tools/place-item.d.ts.map +1 -1
  34. package/dist/tools/place-item.js +2 -1
  35. package/dist/tools/room-tools.d.ts +8 -8
  36. package/dist/tools/room-tools.d.ts.map +1 -1
  37. package/dist/tools/room-tools.js +17 -7
  38. package/dist/tools/scene-query.d.ts +4 -0
  39. package/dist/tools/scene-query.d.ts.map +1 -1
  40. package/dist/tools/scene-query.js +24 -11
  41. package/package.json +5 -4
@@ -1,5 +1,7 @@
1
+ import { resolveCeilingHeight } from '@pascal-app/core';
1
2
  import { z } from 'zod';
2
3
  import { ErrorCode, throwMcpError } from './errors';
4
+ import { resolveReportedWallHeight } from './scene-query';
3
5
  import { NodeIdSchema } from './schemas';
4
6
  export const describeNodeInput = {
5
7
  id: NodeIdSchema,
@@ -17,13 +19,13 @@ export const describeNodeOutput = {
17
19
  * Build a short, human-readable one-liner describing the node.
18
20
  * Covers the common shapes; falls back to a generic sentence otherwise.
19
21
  */
20
- function describe(node) {
22
+ function describe(node, bridge) {
21
23
  switch (node.type) {
22
24
  case 'wall': {
23
25
  const [x1, z1] = node.start;
24
26
  const [x2, z2] = node.end;
25
27
  const t = node.thickness ?? 0.1;
26
- const h = node.height ?? 2.5;
28
+ const h = resolveReportedWallHeight(bridge, node);
27
29
  return `Wall from (${x1},${z1}) to (${x2},${z2}), thickness ${t.toFixed(2)}m, height ${h.toFixed(2)}m`;
28
30
  }
29
31
  case 'level':
@@ -39,7 +41,7 @@ function describe(node) {
39
41
  case 'slab':
40
42
  return `Slab with ${node.polygon.length} vertices`;
41
43
  case 'ceiling':
42
- return `Ceiling with ${node.polygon.length} vertices, height ${node.height.toFixed(2)}m`;
44
+ return `Ceiling with ${node.polygon.length} vertices, height ${resolveCeilingHeight(node, bridge.getNodes()).toFixed(2)}m`;
43
45
  case 'door':
44
46
  return `Door (${node.width.toFixed(2)}m x ${node.height.toFixed(2)}m)`;
45
47
  case 'window':
@@ -69,14 +71,21 @@ export function registerDescribeNode(server, bridge) {
69
71
  const children = bridge.getChildren(id);
70
72
  const childrenIds = children.map((n) => n.id);
71
73
  const n = node;
74
+ const properties = n.type === 'wall'
75
+ ? {
76
+ ...n,
77
+ resolvedHeight: resolveReportedWallHeight(bridge, n),
78
+ heightIsExplicit: n.height !== undefined,
79
+ }
80
+ : n;
72
81
  const payload = {
73
82
  id: n.id,
74
83
  type: n.type,
75
84
  parentId: (n.parentId ?? null),
76
85
  ancestryIds,
77
86
  childrenIds,
78
- properties: n,
79
- description: describe(n),
87
+ properties: properties,
88
+ description: describe(n, bridge),
80
89
  };
81
90
  return {
82
91
  content: [{ type: 'text', text: JSON.stringify(payload) }],
@@ -8,9 +8,10 @@ export declare const findNodesInput: {
8
8
  level: "level";
9
9
  wall: "wall";
10
10
  fence: "fence";
11
+ slab: "slab";
12
+ door: "door";
11
13
  item: "item";
12
14
  zone: "zone";
13
- slab: "slab";
14
15
  stair: "stair";
15
16
  ceiling: "ceiling";
16
17
  roof: "roof";
@@ -19,7 +20,6 @@ export declare const findNodesInput: {
19
20
  scan: "scan";
20
21
  guide: "guide";
21
22
  window: "window";
22
- door: "door";
23
23
  }>>;
24
24
  parentId: z.ZodOptional<z.ZodString>;
25
25
  levelId: z.ZodOptional<z.ZodString>;
@@ -79,7 +79,7 @@ export function registerFindNodes(server, bridge) {
79
79
  // Zone-polygon filter: point-in-polygon on a representative 2D point.
80
80
  if (zoneId) {
81
81
  const zone = bridge.getNode(zoneId);
82
- if (!zone || zone.type !== 'zone') {
82
+ if (zone?.type !== 'zone') {
83
83
  // Unknown zoneId → return empty list rather than throw; matches
84
84
  // typical "filter" semantics.
85
85
  results = [];
@@ -0,0 +1,37 @@
1
+ import { type Kind } from '@pascal-app/lingo';
2
+ import { z } from 'zod';
3
+ /**
4
+ * A zod field for a measurement tool argument that a model may emit as a bare
5
+ * number OR as natural language. `measurement('length', 'm')` accepts `0.15`,
6
+ * `"6 in"`, `"180cm"`, `"2 ft 3 in"`; `measurement('angle', 'deg')` accepts
7
+ * `45`, `"45°"`, `"1.57rad"`, `"0.25 turn"`. The value is canonicalized (via
8
+ * `@pascal-app/lingo`) to a number in `unit` — the exact unit the tool handler
9
+ * already expects — so no handler change is needed. `min`/`max` (in `unit`)
10
+ * reject out-of-range values with a model-readable message.
11
+ *
12
+ * The emitted JSON Schema is `number | string`, so the model is free to answer
13
+ * in whatever unit it is thinking in; AI SDK v6 applies the transform and
14
+ * forwards the canonical number to the tool executor.
15
+ *
16
+ * Tool-boundary safety: genuinely ambiguous separators (`"1,234"`, which could
17
+ * mean 1234 or 1.234) are rejected rather than silently absorbed, so a European
18
+ * decimal never becomes a 1000× value.
19
+ *
20
+ * NOTE: intentionally duplicated as `editor/packages/mcp/src/tools/measurement.ts`
21
+ * (MCP tools) and `packages/ai/src/tools/scene/measurement.ts` (AI-chat tools).
22
+ * The two tool stacks live in different packages — one inside the `editor`
23
+ * submodule — so they can't share a module. Keep the two copies identical; the
24
+ * clean long-term dedup is a zod-field adapter exported from `@pascal-app/lingo`.
25
+ */
26
+ export interface MeasurementOptions {
27
+ /** Semantic description (e.g. "Wall thickness"). The natural-language note is appended. */
28
+ description?: string;
29
+ /** Upper bound, in `unit`. */
30
+ max?: number;
31
+ /** Inclusive lower bound, in `unit`. */
32
+ min?: number;
33
+ /** Require the value to be strictly greater than 0 (for non-zero dimensions). */
34
+ positive?: boolean;
35
+ }
36
+ export declare function measurement(kind: Kind, unit: string, opts?: MeasurementOptions): z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>;
37
+ //# sourceMappingURL=measurement.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measurement.d.ts","sourceRoot":"","sources":["../../src/tools/measurement.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,EAAiB,MAAM,mBAAmB,CAAA;AAC5D,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,MAAM,WAAW,kBAAkB;IACjC,2FAA2F;IAC3F,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,8BAA8B;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,wCAAwC;IACxC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AA6CD,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,kBAAuB,uGAoDlF"}
@@ -0,0 +1,94 @@
1
+ import { parseQuantity } from '@pascal-app/lingo';
2
+ import { z } from 'zod';
3
+ function unitNoun(unit) {
4
+ switch (unit) {
5
+ case 'm':
6
+ return 'meters';
7
+ case 'deg':
8
+ return 'degrees';
9
+ case 'rad':
10
+ return 'radians';
11
+ default:
12
+ return unit;
13
+ }
14
+ }
15
+ function naturalLanguageNote(kind, unit) {
16
+ let examples;
17
+ if (kind === 'angle') {
18
+ // Lead with an example in the field's own unit so a bare number is read
19
+ // the way the model intends (a bare "45" in a radians field is 45 rad).
20
+ examples =
21
+ unit === 'rad' ? '1.5708, "90°", "1.57rad", "0.25 turn"' : '45, "45°", "1.57rad", "0.25 turn"';
22
+ }
23
+ else {
24
+ examples = '0.9, "6 ft", "180cm", "2 ft 3 in"';
25
+ }
26
+ return `Accepts a number (${unitNoun(unit)}) or a natural-language string; other units are converted. e.g. ${examples}.`;
27
+ }
28
+ function boundsNote(unit, min, max, positive) {
29
+ if (positive) {
30
+ return max === undefined
31
+ ? ` Must be greater than 0 ${unit}.`
32
+ : ` Greater than 0, up to ${max} ${unit}.`;
33
+ }
34
+ if (min !== undefined && max !== undefined)
35
+ return ` Range ${min}–${max} ${unit}.`;
36
+ if (min !== undefined)
37
+ return ` Minimum ${min} ${unit}.`;
38
+ if (max !== undefined)
39
+ return ` Maximum ${max} ${unit}.`;
40
+ return '';
41
+ }
42
+ export function measurement(kind, unit, opts = {}) {
43
+ const { min, max, positive = false } = opts;
44
+ const description = [
45
+ opts.description,
46
+ naturalLanguageNote(kind, unit) + boundsNote(unit, min, max, positive),
47
+ ]
48
+ .filter(Boolean)
49
+ .join(' ');
50
+ return z
51
+ .union([z.number(), z.string()])
52
+ .transform((val, ctx) => {
53
+ let value;
54
+ if (typeof val === 'number') {
55
+ value = val;
56
+ }
57
+ else {
58
+ const result = parseQuantity(val, {
59
+ kind,
60
+ unit,
61
+ strictness: 'forgiving',
62
+ // A genuinely ambiguous separator ("1,234") must not silently become
63
+ // a 1000× value at a tool boundary — fail so the model self-corrects.
64
+ escalate: { AMBIGUOUS_NUMBER: 'error' },
65
+ });
66
+ if (!result.ok) {
67
+ ctx.addIssue({
68
+ code: 'custom',
69
+ message: result.issues[0]?.message ?? `Could not read "${val}" as a ${kind}.`,
70
+ });
71
+ return z.NEVER;
72
+ }
73
+ value = result.quantity.to(unit).value;
74
+ }
75
+ if (!Number.isFinite(value)) {
76
+ ctx.addIssue({ code: 'custom', message: `Value must be a finite ${kind} in ${unit}.` });
77
+ return z.NEVER;
78
+ }
79
+ if (positive && value <= 0) {
80
+ ctx.addIssue({ code: 'custom', message: `Must be greater than 0 ${unit} (got ${value}).` });
81
+ return z.NEVER;
82
+ }
83
+ if (min !== undefined && value < min) {
84
+ ctx.addIssue({ code: 'custom', message: `Must be at least ${min} ${unit} (got ${value}).` });
85
+ return z.NEVER;
86
+ }
87
+ if (max !== undefined && value > max) {
88
+ ctx.addIssue({ code: 'custom', message: `Must be at most ${max} ${unit} (got ${value}).` });
89
+ return z.NEVER;
90
+ }
91
+ return value;
92
+ })
93
+ .describe(description);
94
+ }
@@ -10,8 +10,8 @@ export declare const photoToSceneInput: {
10
10
  scaleHint: z.ZodOptional<z.ZodString>;
11
11
  name: z.ZodDefault<z.ZodString>;
12
12
  save: z.ZodDefault<z.ZodBoolean>;
13
- defaultWallThickness: z.ZodDefault<z.ZodNumber>;
14
- defaultWallHeight: z.ZodDefault<z.ZodNumber>;
13
+ defaultWallThickness: z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
14
+ defaultWallHeight: z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
15
15
  };
16
16
  export declare const photoToSceneOutput: {
17
17
  sceneId: z.ZodOptional<z.ZodString>;
@@ -1 +1 @@
1
- {"version":3,"file":"photo-to-scene.d.ts","sourceRoot":"","sources":["../../../src/tools/photo-to-scene/photo-to-scene.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAYxE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAGvD;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;;;;;CAO7B,CAAA;AAED,eAAO,MAAM,kBAAkB;;;;;;;;CAQ9B,CAAA;AAiTD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CA6ErF"}
1
+ {"version":3,"file":"photo-to-scene.d.ts","sourceRoot":"","sources":["../../../src/tools/photo-to-scene/photo-to-scene.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAYxE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAIvD;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;;;;;CAa7B,CAAA;AAED,eAAO,MAAM,kBAAkB;;;;;;;;CAQ9B,CAAA;AAmTD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CA6ErF"}
@@ -2,6 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { AnyNode, BuildingNode, LevelNode, SiteNode, WallNode, ZoneNode, } from '@pascal-app/core/schema';
3
3
  import { z } from 'zod';
4
4
  import { appendLiveSceneEvent } from '../live-sync';
5
+ import { measurement } from '../measurement';
5
6
  /**
6
7
  * Input shape for the `photo_to_scene` orchestrator. `image` matches the
7
8
  * contract documented on `analyze_floorplan_image` — base64 or http(s) URL.
@@ -11,8 +12,14 @@ export const photoToSceneInput = {
11
12
  scaleHint: z.string().optional().describe('e.g. "1 cm = 1 m" or "approx 80 m²"'),
12
13
  name: z.string().default('Scene from photo'),
13
14
  save: z.boolean().default(true),
14
- defaultWallThickness: z.number().default(0.2),
15
- defaultWallHeight: z.number().default(2.6),
15
+ defaultWallThickness: measurement('length', 'm', {
16
+ positive: true,
17
+ description: 'Default wall thickness.',
18
+ }).default(0.2),
19
+ defaultWallHeight: measurement('length', 'm', {
20
+ positive: true,
21
+ description: 'Default wall height.',
22
+ }).default(2.6),
16
23
  };
17
24
  export const photoToSceneOutput = {
18
25
  sceneId: z.string().optional(),
@@ -33,6 +40,7 @@ const VisionResponseSchema = z.object({
33
40
  start: z.tuple([z.number(), z.number()]),
34
41
  end: z.tuple([z.number(), z.number()]),
35
42
  thickness: z.number().optional(),
43
+ height: z.number().positive().optional(),
36
44
  })),
37
45
  rooms: z.array(z.object({
38
46
  name: z.string(),
@@ -54,13 +62,14 @@ const SYSTEM_PROMPT = `You are a vision assistant that extracts structured floor
54
62
  Your ONLY job: return a JSON object that exactly matches this schema — no prose, no markdown fences.
55
63
 
56
64
  {
57
- "walls": [{ "start": [x, z], "end": [x, z], "thickness": number? }, ...],
65
+ "walls": [{ "start": [x, z], "end": [x, z], "thickness": number?, "height": number? }, ...],
58
66
  "rooms": [{ "name": string, "polygon": [[x,z], ...], "approximateAreaSqM": number? }, ...],
59
67
  "approximateDimensions": { "widthM": number, "depthM": number },
60
68
  "confidence": number 0..1
61
69
  }
62
70
 
63
71
  Coordinates are in metres. Origin can be the floor plan's centre or bottom-left — be consistent.
72
+ Only include a wall height when it is visibly measured or annotated in the image.
64
73
  If the image is unclear, lower the confidence score but still produce your best attempt.
65
74
  DO NOT wrap the JSON in markdown. DO NOT explain. Just output the raw JSON.`;
66
75
  const DATA_URI_RE = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i;
@@ -163,7 +172,7 @@ function buildSceneGraphFromVision(vision, defaultWallThickness, defaultWallHeig
163
172
  const warnings = [];
164
173
  // Build the skeleton: site → building → level.
165
174
  const building = BuildingNode.parse({});
166
- const level = LevelNode.parse({ level: 0 });
175
+ const level = LevelNode.parse({ level: 0, height: defaultWallHeight });
167
176
  const site = SiteNode.parse({ children: [building.id] });
168
177
  // Link parent ids so downstream traversal works.
169
178
  const siteId = site.id;
@@ -201,7 +210,7 @@ function buildSceneGraphFromVision(vision, defaultWallThickness, defaultWallHeig
201
210
  start: w.start,
202
211
  end: w.end,
203
212
  thickness: w.thickness ?? defaultWallThickness,
204
- height: defaultWallHeight,
213
+ ...(w.height !== undefined ? { height: w.height } : {}),
205
214
  });
206
215
  const linkedWall = {
207
216
  ...wall,
@@ -5,7 +5,7 @@ export declare const placeItemInput: {
5
5
  catalogItemId: z.ZodString;
6
6
  targetNodeId: z.ZodString;
7
7
  position: z.ZodArray<z.ZodNumber>;
8
- rotation: z.ZodOptional<z.ZodNumber>;
8
+ rotation: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
9
9
  };
10
10
  export declare const placeItemOutput: {
11
11
  itemId: z.ZodString;
@@ -1 +1 @@
1
- {"version":3,"file":"place-item.d.ts","sourceRoot":"","sources":["../../src/tools/place-item.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAGxE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAOpD,eAAO,MAAM,cAAc;;;;;CAK1B,CAAA;AAED,eAAO,MAAM,eAAe;;;CAG3B,CAAA;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAsFlF"}
1
+ {"version":3,"file":"place-item.d.ts","sourceRoot":"","sources":["../../src/tools/place-item.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAGxE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAQpD,eAAO,MAAM,cAAc;;;;;CAK1B,CAAA;AAED,eAAO,MAAM,eAAe;;;CAG3B,CAAA;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAsFlF"}
@@ -4,12 +4,13 @@ import { findCatalogItem } from './asset-catalog';
4
4
  import { ErrorCode, throwMcpError } from './errors';
5
5
  import { projectWorldPointToWallLocalX, wallLength } from './geometry';
6
6
  import { publishLiveSceneSnapshot } from './live-sync';
7
+ import { measurement } from './measurement';
7
8
  import { NodeIdSchema, Vec3Schema } from './schemas';
8
9
  export const placeItemInput = {
9
10
  catalogItemId: z.string().min(1),
10
11
  targetNodeId: NodeIdSchema,
11
12
  position: Vec3Schema,
12
- rotation: z.number().optional(),
13
+ rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).optional(),
13
14
  };
14
15
  export const placeItemOutput = {
15
16
  itemId: z.string(),
@@ -14,8 +14,8 @@ export declare const createRoomInput: {
14
14
  name: z.ZodString;
15
15
  polygon: z.ZodArray<z.ZodArray<z.ZodNumber>>;
16
16
  color: z.ZodOptional<z.ZodString>;
17
- wallHeight: z.ZodOptional<z.ZodNumber>;
18
- wallThickness: z.ZodOptional<z.ZodNumber>;
17
+ wallHeight: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
18
+ wallThickness: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
19
19
  };
20
20
  export declare const createRoomOutput: {
21
21
  zoneId: z.ZodString;
@@ -28,11 +28,11 @@ export declare const addDoorInput: {
28
28
  wallId: z.ZodString;
29
29
  t: z.ZodOptional<z.ZodNumber>;
30
30
  position: z.ZodOptional<z.ZodNumber>;
31
- width: z.ZodOptional<z.ZodNumber>;
32
- height: z.ZodOptional<z.ZodNumber>;
31
+ width: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
32
+ height: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
33
33
  hingesSide: z.ZodOptional<z.ZodEnum<{
34
- left: "left";
35
34
  right: "right";
35
+ left: "left";
36
36
  }>>;
37
37
  swingDirection: z.ZodOptional<z.ZodEnum<{
38
38
  inward: "inward";
@@ -52,9 +52,9 @@ export declare const addWindowInput: {
52
52
  wallId: z.ZodString;
53
53
  t: z.ZodOptional<z.ZodNumber>;
54
54
  position: z.ZodOptional<z.ZodNumber>;
55
- width: z.ZodOptional<z.ZodNumber>;
56
- height: z.ZodOptional<z.ZodNumber>;
57
- sillHeight: z.ZodOptional<z.ZodNumber>;
55
+ width: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
56
+ height: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
57
+ sillHeight: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>, z.ZodTransform<number, string | number>>>;
58
58
  };
59
59
  export declare const addWindowOutput: {
60
60
  windowId: z.ZodString;
@@ -1 +1 @@
1
- {"version":3,"file":"room-tools.d.ts","sourceRoot":"","sources":["../../src/tools/room-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAWxE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AA0BpD,eAAO,MAAM,iBAAiB;;;CAG7B,CAAA;AAED,eAAO,MAAM,kBAAkB;;;CAG9B,CAAA;AAED,eAAO,MAAM,eAAe;;;;;;;CAO3B,CAAA;AAED,eAAO,MAAM,gBAAgB;;;;;;CAM5B,CAAA;AAED,eAAO,MAAM,YAAY;;;;;;;;;;;;;;CAQxB,CAAA;AAED,eAAO,MAAM,aAAa;;;;;;;;CAQzB,CAAA;AAED,eAAO,MAAM,cAAc;;;;;;;CAO1B,CAAA;AAED,eAAO,MAAM,eAAe;;;;;;;;;CAS3B,CAAA;AAED,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;CAM5B,CAAA;AAED,eAAO,MAAM,iBAAiB;;;;CAI7B,CAAA;AA+PD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAsB5D;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAqDnF;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CA2ChF;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CA0ClF;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAsEpF;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAMlF"}
1
+ {"version":3,"file":"room-tools.d.ts","sourceRoot":"","sources":["../../src/tools/room-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAWxE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AA2BpD,eAAO,MAAM,iBAAiB;;;CAG7B,CAAA;AAED,eAAO,MAAM,kBAAkB;;;CAG9B,CAAA;AAED,eAAO,MAAM,eAAe;;;;;;;CAa3B,CAAA;AAED,eAAO,MAAM,gBAAgB;;;;;;CAM5B,CAAA;AAED,eAAO,MAAM,YAAY;;;;;;;;;;;;;;CAQxB,CAAA;AAED,eAAO,MAAM,aAAa;;;;;;;;CAQzB,CAAA;AAED,eAAO,MAAM,cAAc;;;;;;;CAU1B,CAAA;AAED,eAAO,MAAM,eAAe;;;;;;;;;CAS3B,CAAA;AAED,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;CAM5B,CAAA;AAED,eAAO,MAAM,iBAAiB;;;;CAI7B,CAAA;AA+PD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAsB5D;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAqDnF;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CA2ChF;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CA0ClF;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAsEpF;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAMlF"}
@@ -4,6 +4,7 @@ import { findCatalogItem, searchCatalogItems } from './asset-catalog';
4
4
  import { ErrorCode, throwMcpError } from './errors';
5
5
  import { pointInBoundsWithPadding, polygonArea, polygonBounds, wallLength, wallLocalXFromT, } from './geometry';
6
6
  import { publishLiveSceneSnapshot } from './live-sync';
7
+ import { measurement } from './measurement';
7
8
  import { NodeIdSchema, Vec2Schema } from './schemas';
8
9
  const ROOM_TYPES = [
9
10
  'bedroom',
@@ -29,8 +30,14 @@ export const createRoomInput = {
29
30
  name: z.string().min(1),
30
31
  polygon: z.array(Vec2Schema).min(3),
31
32
  color: z.string().optional(),
32
- wallHeight: z.number().positive().optional(),
33
- wallThickness: z.number().positive().optional(),
33
+ wallHeight: measurement('length', 'm', {
34
+ positive: true,
35
+ description: 'Wall height.',
36
+ }).optional(),
37
+ wallThickness: measurement('length', 'm', {
38
+ positive: true,
39
+ description: 'Wall thickness.',
40
+ }).optional(),
34
41
  };
35
42
  export const createRoomOutput = {
36
43
  zoneId: z.string(),
@@ -43,8 +50,8 @@ export const addDoorInput = {
43
50
  wallId: NodeIdSchema,
44
51
  t: z.number().min(0).max(1).optional(),
45
52
  position: z.number().min(0).max(1).optional(),
46
- width: z.number().positive().optional(),
47
- height: z.number().positive().optional(),
53
+ width: measurement('length', 'm', { positive: true, description: 'Door width.' }).optional(),
54
+ height: measurement('length', 'm', { positive: true, description: 'Door height.' }).optional(),
48
55
  hingesSide: z.enum(['left', 'right']).optional(),
49
56
  swingDirection: z.enum(['inward', 'outward']).optional(),
50
57
  };
@@ -61,9 +68,12 @@ export const addWindowInput = {
61
68
  wallId: NodeIdSchema,
62
69
  t: z.number().min(0).max(1).optional(),
63
70
  position: z.number().min(0).max(1).optional(),
64
- width: z.number().positive().optional(),
65
- height: z.number().positive().optional(),
66
- sillHeight: z.number().min(0).optional(),
71
+ width: measurement('length', 'm', { positive: true, description: 'Window width.' }).optional(),
72
+ height: measurement('length', 'm', { positive: true, description: 'Window height.' }).optional(),
73
+ sillHeight: measurement('length', 'm', {
74
+ min: 0,
75
+ description: 'Sill height above floor.',
76
+ }).optional(),
67
77
  };
68
78
  export const addWindowOutput = {
69
79
  windowId: z.string(),
@@ -1,4 +1,5 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { AnyNode } from '@pascal-app/core/schema';
2
3
  import { z } from 'zod';
3
4
  import type { SceneOperations } from '../operations';
4
5
  export declare const levelScopedInput: {
@@ -48,6 +49,9 @@ export declare const verifySceneOutput: {
48
49
  issues: z.ZodArray<z.ZodString>;
49
50
  hasIssues: z.ZodBoolean;
50
51
  };
52
+ export declare function resolveReportedWallHeight(bridge: SceneOperations, wall: Extract<AnyNode, {
53
+ type: 'wall';
54
+ }>): number;
51
55
  export declare function registerListLevels(server: McpServer, bridge: SceneOperations): void;
52
56
  export declare function registerGetLevelSummary(server: McpServer, bridge: SceneOperations): void;
53
57
  export declare function registerGetWalls(server: McpServer, bridge: SceneOperations): void;
@@ -1 +1 @@
1
- {"version":3,"file":"scene-query.d.ts","sourceRoot":"","sources":["../../src/tools/scene-query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAExE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAWpD,eAAO,MAAM,gBAAgB;;CAE5B,CAAA;AAID,eAAO,MAAM,gBAAgB;;;;;;;CAO5B,CAAA;AAED,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;CAcjC,CAAA;AAED,eAAO,MAAM,cAAc;;;CAG1B,CAAA;AAED,eAAO,MAAM,cAAc;;;CAG1B,CAAA;AAED,eAAO,MAAM,iBAAiB;;;;;;;;;;;;CAY7B,CAAA;AA2YD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAyCnF;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAgBxF;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAmBjF;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAmBjF;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAkQpF;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAMxF"}
1
+ {"version":3,"file":"scene-query.d.ts","sourceRoot":"","sources":["../../src/tools/scene-query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAQxE,OAAO,KAAK,EAAE,OAAO,EAAa,MAAM,yBAAyB,CAAA;AAEjE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAWpD,eAAO,MAAM,gBAAgB;;CAE5B,CAAA;AAID,eAAO,MAAM,gBAAgB;;;;;;;CAO5B,CAAA;AAED,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;CAcjC,CAAA;AAED,eAAO,MAAM,cAAc;;;CAG1B,CAAA;AAED,eAAO,MAAM,cAAc;;;CAG1B,CAAA;AAED,eAAO,MAAM,iBAAiB;;;;;;;;;;;;CAY7B,CAAA;AA8CD,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,GACvC,MAAM,CAgBR;AAkWD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAyCnF;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAgBxF;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAmBjF;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAmBjF;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CA4PpF;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAMxF"}
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_LEVEL_HEIGHT, getStoredLevelHeight, getWallPlaneTop, resolveStairTotalRise, resolveWallEffectiveHeight, } from '@pascal-app/core';
2
+ import { computeWallSlabSupport } from '@pascal-app/core/spatial-grid';
1
3
  import { z } from 'zod';
2
4
  import { distance2D, pointInPolygon, polygonArea, polygonContainsPolygon, wallLength, } from './geometry';
3
5
  import { NodeIdSchema } from './schemas';
@@ -71,6 +73,19 @@ function getDefaultLevelId(bridge, requested) {
71
73
  function nodesOnLevel(bridge, levelId) {
72
74
  return Object.values(bridge.getNodes()).filter((node) => node.id !== levelId && bridge.resolveLevelId(node.id) === levelId);
73
75
  }
76
+ export function resolveReportedWallHeight(bridge, wall) {
77
+ const levelId = bridge.resolveLevelId(wall.id);
78
+ // Covering-clamped plane for plane-bound walls; explicit heights pass
79
+ // through resolveWallEffectiveHeight untouched.
80
+ const planeTop = levelId
81
+ ? getWallPlaneTop(wall, levelId, bridge.getNodes())
82
+ : DEFAULT_LEVEL_HEIGHT;
83
+ const levelNodes = levelId ? nodesOnLevel(bridge, levelId) : [];
84
+ const slabs = levelNodes.filter((node) => node.type === 'slab');
85
+ const walls = levelNodes.filter((node) => node.type === 'wall');
86
+ const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId);
87
+ return resolveWallEffectiveHeight(wall, planeTop, support.elevation);
88
+ }
74
89
  function metadataRecord(node) {
75
90
  return typeof node.metadata === 'object' && node.metadata !== null
76
91
  ? node.metadata
@@ -116,6 +131,7 @@ function wallSummary(bridge, wall) {
116
131
  if (wall.type !== 'wall')
117
132
  return null;
118
133
  const length = distance2D(wall.start, wall.end);
134
+ const resolvedHeight = resolveReportedWallHeight(bridge, wall);
119
135
  return {
120
136
  id: wall.id,
121
137
  name: wall.name,
@@ -123,6 +139,8 @@ function wallSummary(bridge, wall) {
123
139
  end: wall.end,
124
140
  length: Math.round(length * 100) / 100,
125
141
  height: wall.height,
142
+ resolvedHeight,
143
+ heightIsExplicit: wall.height !== undefined,
126
144
  thickness: wall.thickness,
127
145
  openings: openingSummaries(bridge, wall.id),
128
146
  };
@@ -235,7 +253,7 @@ function stairFootprintPolygons(bridge, stair) {
235
253
  {
236
254
  width: stair.width ?? 1,
237
255
  length: 3,
238
- height: stair.totalRise ?? 2.5,
256
+ height: resolveStairTotalRise(stair, nodes),
239
257
  stepCount: stair.stepCount ?? 10,
240
258
  attachmentSide: 'front',
241
259
  },
@@ -305,7 +323,7 @@ function parentListsChild(parent, childId) {
305
323
  }
306
324
  function levelSummary(bridge, levelId) {
307
325
  const level = bridge.getNode(levelId);
308
- if (!level || level.type !== 'level') {
326
+ if (level?.type !== 'level') {
309
327
  throw new Error(`Level not found: ${levelId}`);
310
328
  }
311
329
  const nodes = nodesOnLevel(bridge, levelId);
@@ -524,14 +542,9 @@ export function registerVerifyScene(server, bridge) {
524
542
  const summary = levels.find((entry) => entry.levelId === level.id);
525
543
  if (!summary?.isOccupiedStory)
526
544
  continue;
527
- const expectedHeight = typeof level.metadata === 'object' &&
528
- level.metadata !== null &&
529
- 'height' in level.metadata &&
530
- typeof level.metadata.height === 'number'
531
- ? level.metadata.height
532
- : 3.2;
545
+ const expectedHeight = getStoredLevelHeight(level);
533
546
  for (const wall of nodesOnLevel(bridge, level.id).filter((node) => node.type === 'wall')) {
534
- const wallHeight = wall.height ?? 2.5;
547
+ const wallHeight = resolveReportedWallHeight(bridge, wall);
535
548
  if (wallHeight > expectedHeight + 0.25) {
536
549
  issues.push(`Wall ${wall.name ?? wall.id} on ${level.name ?? level.id} is ${wallHeight}m high; multi-story exterior walls should be split into level-owned story walls`);
537
550
  }
@@ -541,7 +554,7 @@ export function registerVerifyScene(server, bridge) {
541
554
  for (const node of Object.values(bridge.getNodes())) {
542
555
  if (node.type === 'door' || node.type === 'window') {
543
556
  const parent = node.parentId ? bridge.getNode(node.parentId) : null;
544
- if (!parent || parent.type !== 'wall') {
557
+ if (parent?.type !== 'wall') {
545
558
  issues.push(`${node.type} ${node.id} is not parented to a wall`);
546
559
  continue;
547
560
  }
@@ -558,7 +571,7 @@ export function registerVerifyScene(server, bridge) {
558
571
  if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) {
559
572
  issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`);
560
573
  }
561
- const wallHeight = parent.height ?? 2.5;
574
+ const wallHeight = resolveReportedWallHeight(bridge, parent);
562
575
  const bottom = node.position[1] - height / 2;
563
576
  const top = node.position[1] + height / 2;
564
577
  if (bottom < -0.01 || top > wallHeight + 0.01) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/mcp",
3
- "version": "0.3.1",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "Model Context Protocol server for Pascal 3D editor",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,21 +48,22 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "tsc --build",
51
- "dev": "tsc --build --watch",
51
+ "dev": "tsgo --build --watch",
52
52
  "start": "bun dist/bin/pascal-mcp.js",
53
53
  "test": "bun test",
54
54
  "smoke": "bun run scripts/smoke.ts",
55
55
  "prepublishOnly": "bun run build && bun test"
56
56
  },
57
57
  "peerDependencies": {
58
- "@pascal-app/core": "^0.9.1"
58
+ "@pascal-app/core": "^1.0.0-beta.1"
59
59
  },
60
60
  "dependencies": {
61
61
  "@modelcontextprotocol/sdk": "^1.29.0",
62
+ "@pascal-app/lingo": "^0.2.0",
62
63
  "zod": "^4.3.5"
63
64
  },
64
65
  "devDependencies": {
65
- "@pascal-app/core": "^0.9.1",
66
+ "@pascal-app/core": "^1.0.0-beta.1",
66
67
  "@pascal/typescript-config": "*",
67
68
  "@types/node": "^22.19.20",
68
69
  "typescript": "6.0.3"