@pascal-app/mcp 1.0.0-beta.3 → 1.0.0-beta.5

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 (45) hide show
  1. package/README.md +56 -44
  2. package/dist/bin/pascal-mcp.d.ts +1 -1
  3. package/dist/bin/pascal-mcp.js +19 -12
  4. package/dist/bridge/scene-bridge.d.ts +6 -2
  5. package/dist/bridge/scene-bridge.d.ts.map +1 -1
  6. package/dist/bridge/scene-bridge.js +21 -7
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/operations/scene-operations.js +1 -0
  11. package/dist/prompts/from-brief.d.ts.map +1 -1
  12. package/dist/prompts/from-brief.js +4 -3
  13. package/dist/resources/agent-guide.d.ts.map +1 -1
  14. package/dist/resources/agent-guide.js +2 -0
  15. package/dist/server.d.ts.map +1 -1
  16. package/dist/server.js +3 -2
  17. package/dist/storage/sqlite-scene-store.d.ts.map +1 -1
  18. package/dist/storage/sqlite-scene-store.js +10 -1
  19. package/dist/templates/garden-house.d.ts.map +1 -1
  20. package/dist/templates/garden-house.js +4 -1
  21. package/dist/tools/check-collisions.d.ts.map +1 -1
  22. package/dist/tools/check-collisions.js +23 -35
  23. package/dist/tools/door-clearance.d.ts +119 -0
  24. package/dist/tools/door-clearance.d.ts.map +1 -0
  25. package/dist/tools/door-clearance.js +240 -0
  26. package/dist/tools/layout-clearance.d.ts +103 -0
  27. package/dist/tools/layout-clearance.d.ts.map +1 -0
  28. package/dist/tools/layout-clearance.js +186 -0
  29. package/dist/tools/measure.d.ts.map +1 -1
  30. package/dist/tools/measure.js +6 -2
  31. package/dist/tools/room-tools.d.ts.map +1 -1
  32. package/dist/tools/room-tools.js +90 -34
  33. package/dist/tools/scene-query.d.ts +1 -0
  34. package/dist/tools/scene-query.d.ts.map +1 -1
  35. package/dist/tools/scene-query.js +6 -0
  36. package/dist/tools/templates/create-from-template.d.ts +2 -0
  37. package/dist/tools/templates/create-from-template.d.ts.map +1 -1
  38. package/dist/tools/templates/create-from-template.js +2 -0
  39. package/dist/transports/http.d.ts +6 -1
  40. package/dist/transports/http.d.ts.map +1 -1
  41. package/dist/transports/http.js +52 -9
  42. package/dist/version.d.ts +2 -0
  43. package/dist/version.d.ts.map +1 -0
  44. package/dist/version.js +3 -0
  45. package/package.json +3 -3
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Door access keep-outs for MCP layout tools.
3
+ *
4
+ * Furniture that overlaps a door clear zone is reported as blocking the door.
5
+ * Used by furnish_room (skip placements) and verify_scene (layout issues).
6
+ *
7
+ * See docs/layout-clearance-error-log.md for pitfalls (levels, gap sign, scale).
8
+ */
9
+ import type { AnyNode } from '@pascal-app/core/schema';
10
+ import { type Vec2 } from './geometry';
11
+ export type PlanAabb = {
12
+ minX: number;
13
+ maxX: number;
14
+ minZ: number;
15
+ maxZ: number;
16
+ };
17
+ export type DoorKeepout = {
18
+ doorId: string;
19
+ wallId: string;
20
+ levelId: string | null;
21
+ /** World-space AABB on both sides of the wall opening. */
22
+ aabb: PlanAabb;
23
+ width: number;
24
+ localX: number;
25
+ };
26
+ /**
27
+ * Wall shape these helpers accept. Deliberately structural rather than
28
+ * `Pick<WallNode, …>`: `keepoutForPolygonEdge` feeds in synthetic `edge-N`
29
+ * segments for room edges that do not have a wall node yet, so the id cannot
30
+ * be the branded `wall_${string}`.
31
+ */
32
+ export type WallSegmentLike = {
33
+ id: string;
34
+ start: Vec2;
35
+ end: Vec2;
36
+ };
37
+ /**
38
+ * Door shape these helpers accept — a real `DoorNode` or a planned opening.
39
+ * `Pick<AnyNode, 'position' | 'width'>` does not work: those keys exist on
40
+ * only some members of the `AnyNode` union, so `Pick` rejects them.
41
+ */
42
+ export type DoorOpeningLike = {
43
+ id: string;
44
+ position?: readonly number[];
45
+ width?: number;
46
+ };
47
+ /** Plan depth (m) cleared on each side of the wall face through the opening. */
48
+ export declare const DEFAULT_DOOR_CLEAR_DEPTH = 0.65;
49
+ /** Extra half-width (m) beyond the door leaf along the wall. */
50
+ export declare const DEFAULT_DOOR_SIDE_PAD = 0.05;
51
+ /**
52
+ * True when A and B come closer than `gap` meters (including penetration).
53
+ * `gap` is the **minimum free space required** between boxes:
54
+ * expand each box by gap/2, then test intersection.
55
+ */
56
+ export declare function aabbsOverlap(a: PlanAabb, b: PlanAabb, gap?: number): boolean;
57
+ /**
58
+ * Walk parentId chain to the enclosing level id (pure; no bridge required).
59
+ */
60
+ export declare function resolveNodeLevelId(nodeId: string, byId: Map<string, AnyNode>): string | null;
61
+ /**
62
+ * Axis-aligned item footprint in plan (x/z), rotation-aware.
63
+ * Prefer scaled dimensions when the node is available.
64
+ */
65
+ export declare function itemPlanAabb(position: [number, number, number] | number[], dimensions: [number, number, number] | number[] | undefined, rotationYRad?: number): PlanAabb;
66
+ /** Footprint for a scene item node (uses getScaledDimensions). */
67
+ export declare function itemNodePlanAabb(node: AnyNode): PlanAabb | null;
68
+ /**
69
+ * Build a rectangular keep-out around a wall door, extruded perpendicular to the wall
70
+ * on both faces so either swing side is protected.
71
+ */
72
+ export declare function doorKeepoutFromWall(wall: WallSegmentLike, door: DoorOpeningLike, options?: {
73
+ clearDepth?: number;
74
+ sidePad?: number;
75
+ levelId?: string | null;
76
+ }): DoorKeepout | null;
77
+ export declare function collectDoorKeepouts(nodes: Iterable<AnyNode>, options?: {
78
+ clearDepth?: number;
79
+ sidePad?: number;
80
+ levelId?: string;
81
+ }): DoorKeepout[];
82
+ export declare function itemBlocksDoorKeepout(itemAabb: PlanAabb, keepout: DoorKeepout): boolean;
83
+ export type BlockedDoorIssue = {
84
+ doorId: string;
85
+ wallId: string;
86
+ itemId: string;
87
+ levelId?: string | null;
88
+ itemName?: string;
89
+ message: string;
90
+ };
91
+ export declare function findBlockedDoors(args: {
92
+ nodes: Iterable<AnyNode>;
93
+ clearDepth?: number;
94
+ sidePad?: number;
95
+ /** When set, only doors and items on this level are considered. */
96
+ levelId?: string;
97
+ }): BlockedDoorIssue[];
98
+ /**
99
+ * Synthetic keep-outs for a room polygon edge that will host a door (before doors exist).
100
+ * Used by furnish_room when doorWallIndex is known.
101
+ */
102
+ export declare function keepoutForPolygonEdge(polygon: Vec2[], edgeIndex: number, options?: {
103
+ t?: number;
104
+ width?: number;
105
+ clearDepth?: number;
106
+ sidePad?: number;
107
+ }): PlanAabb | null;
108
+ /**
109
+ * Whether an existing keep-out already covers this room-edge planned zone
110
+ * (so we do not double-count a real door on that edge).
111
+ *
112
+ * Requires the planned keep-out **center** to lie inside the existing keep-out,
113
+ * and the existing box to cover a large fraction of the planned area.
114
+ * Plain AABB overlap is not enough (nearby hallway doors must not suppress
115
+ * this room's entrance keep-out).
116
+ */
117
+ export declare function keepoutCoversPlanned(existing: PlanAabb, planned: PlanAabb): boolean;
118
+ export declare function aabbFromPlan(a: PlanAabb): PlanAabb;
119
+ //# sourceMappingURL=door-clearance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"door-clearance.d.ts","sourceRoot":"","sources":["../../src/tools/door-clearance.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AAEtD,OAAO,EAAE,KAAK,IAAI,EAAc,MAAM,YAAY,CAAA;AAElD,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,0DAA0D;IAC1D,IAAI,EAAE,QAAQ,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,IAAI,CAAA;CAAE,CAAA;AAEpE;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,OAAO,CAAA;AAC5C,gEAAgE;AAChE,eAAO,MAAM,qBAAqB,OAAO,CAAA;AAEzC;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,EAAE,GAAG,SAAI,GAAG,OAAO,CAGvE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CAY5F;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,EAAE,EAC7C,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,EAAE,GAAG,SAAS,EAC3D,YAAY,SAAI,GACf,QAAQ,CAcV;AAED,kEAAkE;AAClE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CAK/D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,eAAe,EACrB,OAAO,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAC3E,WAAW,GAAG,IAAI,CAyCpB;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,EACxB,OAAO,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACpE,WAAW,EAAE,CAqBf;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAEvF;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,GAAG,gBAAgB,EAAE,CAoCrB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,IAAI,EAAE,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE;IAAE,CAAC,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9E,QAAQ,GAAG,IAAI,CAyBjB;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,GAAG,OAAO,CAgBnF;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAElD"}
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Door access keep-outs for MCP layout tools.
3
+ *
4
+ * Furniture that overlaps a door clear zone is reported as blocking the door.
5
+ * Used by furnish_room (skip placements) and verify_scene (layout issues).
6
+ *
7
+ * See docs/layout-clearance-error-log.md for pitfalls (levels, gap sign, scale).
8
+ */
9
+ import { getScaledDimensions } from '@pascal-app/core/schema';
10
+ import { wallLength } from './geometry';
11
+ /** Plan depth (m) cleared on each side of the wall face through the opening. */
12
+ export const DEFAULT_DOOR_CLEAR_DEPTH = 0.65;
13
+ /** Extra half-width (m) beyond the door leaf along the wall. */
14
+ export const DEFAULT_DOOR_SIDE_PAD = 0.05;
15
+ /**
16
+ * True when A and B come closer than `gap` meters (including penetration).
17
+ * `gap` is the **minimum free space required** between boxes:
18
+ * expand each box by gap/2, then test intersection.
19
+ */
20
+ export function aabbsOverlap(a, b, gap = 0) {
21
+ const g = gap;
22
+ return a.maxX + g > b.minX && a.minX - g < b.maxX && a.maxZ + g > b.minZ && a.minZ - g < b.maxZ;
23
+ }
24
+ /**
25
+ * Walk parentId chain to the enclosing level id (pure; no bridge required).
26
+ */
27
+ export function resolveNodeLevelId(nodeId, byId) {
28
+ let current = byId.get(nodeId);
29
+ const seen = new Set();
30
+ while (current) {
31
+ if (seen.has(current.id))
32
+ return null;
33
+ seen.add(current.id);
34
+ if (current.type === 'level')
35
+ return current.id;
36
+ const parentId = current.parentId;
37
+ if (!parentId)
38
+ return null;
39
+ current = byId.get(parentId);
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Axis-aligned item footprint in plan (x/z), rotation-aware.
45
+ * Prefer scaled dimensions when the node is available.
46
+ */
47
+ export function itemPlanAabb(position, dimensions, rotationYRad = 0) {
48
+ const x = position[0] ?? 0;
49
+ const z = position[2] ?? 0;
50
+ const [w = 1, , d = 1] = dimensions ?? [1, 1, 1];
51
+ const cos = Math.abs(Math.cos(rotationYRad));
52
+ const sin = Math.abs(Math.sin(rotationYRad));
53
+ const halfW = (w * cos + d * sin) / 2;
54
+ const halfD = (w * sin + d * cos) / 2;
55
+ return {
56
+ minX: x - halfW,
57
+ maxX: x + halfW,
58
+ minZ: z - halfD,
59
+ maxZ: z + halfD,
60
+ };
61
+ }
62
+ /** Footprint for a scene item node (uses getScaledDimensions). */
63
+ export function itemNodePlanAabb(node) {
64
+ if (node.type !== 'item')
65
+ return null;
66
+ const [w, , d] = getScaledDimensions(node);
67
+ const rotY = Array.isArray(node.rotation) ? (node.rotation[1] ?? 0) : 0;
68
+ return itemPlanAabb(node.position, [w, 0, d], rotY);
69
+ }
70
+ /**
71
+ * Build a rectangular keep-out around a wall door, extruded perpendicular to the wall
72
+ * on both faces so either swing side is protected.
73
+ */
74
+ export function doorKeepoutFromWall(wall, door, options) {
75
+ const clearDepth = options?.clearDepth ?? DEFAULT_DOOR_CLEAR_DEPTH;
76
+ const sidePad = options?.sidePad ?? DEFAULT_DOOR_SIDE_PAD;
77
+ const length = wallLength(wall);
78
+ if (length <= 1e-6)
79
+ return null;
80
+ const width = typeof door.width === 'number' && door.width > 0 ? door.width : 0.9;
81
+ const localX = Array.isArray(door.position) ? (door.position[0] ?? length / 2) : length / 2;
82
+ const [sx, sz] = wall.start;
83
+ const [ex, ez] = wall.end;
84
+ const dx = (ex - sx) / length;
85
+ const dz = (ez - sz) / length;
86
+ const nx = -dz;
87
+ const nz = dx;
88
+ const half = width / 2 + sidePad;
89
+ const corners = [];
90
+ for (const along of [localX - half, localX + half]) {
91
+ const cx = sx + dx * along;
92
+ const cz = sz + dz * along;
93
+ for (const side of [-clearDepth, clearDepth]) {
94
+ corners.push([cx + nx * side, cz + nz * side]);
95
+ }
96
+ }
97
+ const xs = corners.map((c) => c[0]);
98
+ const zs = corners.map((c) => c[1]);
99
+ return {
100
+ doorId: door.id,
101
+ wallId: wall.id,
102
+ levelId: options?.levelId ?? null,
103
+ width,
104
+ localX,
105
+ aabb: {
106
+ minX: Math.min(...xs),
107
+ maxX: Math.max(...xs),
108
+ minZ: Math.min(...zs),
109
+ maxZ: Math.max(...zs),
110
+ },
111
+ };
112
+ }
113
+ export function collectDoorKeepouts(nodes, options) {
114
+ const byId = new Map();
115
+ for (const node of nodes)
116
+ byId.set(node.id, node);
117
+ const keepouts = [];
118
+ for (const node of byId.values()) {
119
+ if (node.type !== 'door')
120
+ continue;
121
+ const wallId = node.wallId ?? node.parentId;
122
+ if (!wallId)
123
+ continue;
124
+ const wall = byId.get(wallId);
125
+ if (wall?.type !== 'wall')
126
+ continue;
127
+ const levelId = resolveNodeLevelId(wall.id, byId) ?? resolveNodeLevelId(node.id, byId);
128
+ if (options?.levelId && levelId !== options.levelId)
129
+ continue;
130
+ const keepout = doorKeepoutFromWall(wall, node, {
131
+ clearDepth: options?.clearDepth,
132
+ sidePad: options?.sidePad,
133
+ levelId,
134
+ });
135
+ if (keepout)
136
+ keepouts.push(keepout);
137
+ }
138
+ return keepouts;
139
+ }
140
+ export function itemBlocksDoorKeepout(itemAabb, keepout) {
141
+ return aabbsOverlap(itemAabb, keepout.aabb, 0.02);
142
+ }
143
+ export function findBlockedDoors(args) {
144
+ const nodes = [...args.nodes];
145
+ const byId = new Map(nodes.map((n) => [n.id, n]));
146
+ const keepouts = collectDoorKeepouts(nodes, {
147
+ clearDepth: args.clearDepth,
148
+ sidePad: args.sidePad,
149
+ levelId: args.levelId,
150
+ });
151
+ if (keepouts.length === 0)
152
+ return [];
153
+ const issues = [];
154
+ for (const node of nodes) {
155
+ if (node.type !== 'item')
156
+ continue;
157
+ const itemLevel = resolveNodeLevelId(node.id, byId);
158
+ if (args.levelId && itemLevel !== args.levelId)
159
+ continue;
160
+ const aabb = itemNodePlanAabb(node);
161
+ if (!aabb)
162
+ continue;
163
+ for (const keepout of keepouts) {
164
+ // Same-level only (multi-story safety).
165
+ if (keepout.levelId && itemLevel && keepout.levelId !== itemLevel)
166
+ continue;
167
+ if (args.levelId && keepout.levelId && keepout.levelId !== args.levelId)
168
+ continue;
169
+ if (!itemBlocksDoorKeepout(aabb, keepout))
170
+ continue;
171
+ const itemName = node.name ?? node.asset?.name ?? node.id;
172
+ issues.push({
173
+ doorId: keepout.doorId,
174
+ wallId: keepout.wallId,
175
+ itemId: node.id,
176
+ levelId: itemLevel,
177
+ itemName: typeof itemName === 'string' ? itemName : undefined,
178
+ message: `Door ${keepout.doorId} on wall ${keepout.wallId} is blocked by item ${itemName} (${node.id})`,
179
+ });
180
+ }
181
+ }
182
+ return issues;
183
+ }
184
+ /**
185
+ * Synthetic keep-outs for a room polygon edge that will host a door (before doors exist).
186
+ * Used by furnish_room when doorWallIndex is known.
187
+ */
188
+ export function keepoutForPolygonEdge(polygon, edgeIndex, options) {
189
+ if (polygon.length < 3)
190
+ return null;
191
+ const i = ((edgeIndex % polygon.length) + polygon.length) % polygon.length;
192
+ const start = polygon[i];
193
+ const end = polygon[(i + 1) % polygon.length];
194
+ const wall = {
195
+ id: `edge-${i}`,
196
+ start,
197
+ end,
198
+ };
199
+ const length = wallLength(wall);
200
+ if (length <= 1e-6)
201
+ return null;
202
+ const width = options?.width ?? 0.9;
203
+ const t = options?.t ?? 0.5;
204
+ const localX = Math.min(Math.max(t * length, width / 2), length - width / 2);
205
+ const keepout = doorKeepoutFromWall(wall, {
206
+ id: `planned-door-${i}`,
207
+ position: [localX, 1.05, 0],
208
+ width,
209
+ }, { clearDepth: options?.clearDepth, sidePad: options?.sidePad });
210
+ return keepout?.aabb ?? null;
211
+ }
212
+ /**
213
+ * Whether an existing keep-out already covers this room-edge planned zone
214
+ * (so we do not double-count a real door on that edge).
215
+ *
216
+ * Requires the planned keep-out **center** to lie inside the existing keep-out,
217
+ * and the existing box to cover a large fraction of the planned area.
218
+ * Plain AABB overlap is not enough (nearby hallway doors must not suppress
219
+ * this room's entrance keep-out).
220
+ */
221
+ export function keepoutCoversPlanned(existing, planned) {
222
+ const cx = (planned.minX + planned.maxX) / 2;
223
+ const cz = (planned.minZ + planned.maxZ) / 2;
224
+ const centerInside = cx >= existing.minX && cx <= existing.maxX && cz >= existing.minZ && cz <= existing.maxZ;
225
+ if (!centerInside)
226
+ return false;
227
+ // Intersection area / planned area must be substantial (same opening, not a glancing touch).
228
+ const ix0 = Math.max(existing.minX, planned.minX);
229
+ const ix1 = Math.min(existing.maxX, planned.maxX);
230
+ const iz0 = Math.max(existing.minZ, planned.minZ);
231
+ const iz1 = Math.min(existing.maxZ, planned.maxZ);
232
+ if (ix1 <= ix0 || iz1 <= iz0)
233
+ return false;
234
+ const inter = (ix1 - ix0) * (iz1 - iz0);
235
+ const plannedArea = Math.max(1e-9, (planned.maxX - planned.minX) * (planned.maxZ - planned.minZ));
236
+ return inter / plannedArea >= 0.5;
237
+ }
238
+ export function aabbFromPlan(a) {
239
+ return a;
240
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared plan-layout clearance for MCP tools.
3
+ *
4
+ * - Door keep-outs (level-scoped)
5
+ * - Item–item AABB overlap (rotation + scale aware)
6
+ * - Placement candidate search when primary pose is blocked
7
+ *
8
+ * See docs/layout-clearance-error-log.md for regression checklist.
9
+ */
10
+ import type { AnyNode } from '@pascal-app/core/schema';
11
+ import { type PlanAabb } from './door-clearance';
12
+ export { aabbsOverlap, collectDoorKeepouts, findBlockedDoors, itemNodePlanAabb, itemPlanAabb, type PlanAabb, resolveNodeLevelId, } from './door-clearance';
13
+ /** Minimum free space (m) required between item footprints. */
14
+ export declare const DEFAULT_ITEM_GAP = 0.08;
15
+ export type OccupiedFootprint = {
16
+ id: string;
17
+ name?: string;
18
+ levelId?: string | null;
19
+ aabb: PlanAabb;
20
+ };
21
+ export type ItemCollision = {
22
+ aId: string;
23
+ bId: string;
24
+ aName?: string;
25
+ bName?: string;
26
+ levelId?: string | null;
27
+ kind: 'item-aabb';
28
+ message: string;
29
+ };
30
+ export declare function nodeItemAabb(node: AnyNode): PlanAabb | null;
31
+ export declare function collectOccupiedFootprints(nodes: Iterable<AnyNode>, options?: {
32
+ levelId?: string;
33
+ excludeIds?: Set<string>;
34
+ floorOnly?: boolean;
35
+ }): OccupiedFootprint[];
36
+ export declare function findItemItemCollisions(args: {
37
+ nodes: Iterable<AnyNode>;
38
+ levelId?: string;
39
+ gap?: number;
40
+ }): ItemCollision[];
41
+ export type PlacementCandidate = {
42
+ x: number;
43
+ z: number;
44
+ rotationDeg: number;
45
+ };
46
+ export type PlacementRejectReason = 'outside_bounds' | 'blocks_door_clearance' | 'overlaps_item' | 'ok';
47
+ export declare function classifyPlacement(args: {
48
+ aabb: PlanAabb;
49
+ doorKeepouts: PlanAabb[];
50
+ occupied: PlanAabb[];
51
+ roomBounds?: {
52
+ minX: number;
53
+ maxX: number;
54
+ minZ: number;
55
+ maxZ: number;
56
+ };
57
+ padding?: number;
58
+ itemGap?: number;
59
+ doorGap?: number;
60
+ }): PlacementRejectReason;
61
+ export declare function generatePlacementCandidates(primary: PlacementCandidate, options?: {
62
+ lateralsM?: number[];
63
+ insetsM?: number[];
64
+ inward?: {
65
+ x: number;
66
+ z: number;
67
+ };
68
+ along?: {
69
+ x: number;
70
+ z: number;
71
+ };
72
+ }): PlacementCandidate[];
73
+ export declare function findValidPlacement(args: {
74
+ primary: PlacementCandidate;
75
+ dimensions: [number, number, number] | number[] | undefined;
76
+ doorKeepouts: PlanAabb[];
77
+ occupied: PlanAabb[];
78
+ roomBounds?: {
79
+ minX: number;
80
+ maxX: number;
81
+ minZ: number;
82
+ maxZ: number;
83
+ };
84
+ along?: {
85
+ x: number;
86
+ z: number;
87
+ };
88
+ inward?: {
89
+ x: number;
90
+ z: number;
91
+ };
92
+ }): {
93
+ candidate: PlacementCandidate;
94
+ reason: PlacementRejectReason;
95
+ } | {
96
+ candidate: null;
97
+ reason: PlacementRejectReason;
98
+ };
99
+ /**
100
+ * Collect layout issues, scoped per level so stacked floors do not false-positive.
101
+ */
102
+ export declare function layoutIssuesFromScene(nodes: Iterable<AnyNode>): string[];
103
+ //# sourceMappingURL=layout-clearance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout-clearance.d.ts","sourceRoot":"","sources":["../../src/tools/layout-clearance.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EAKL,KAAK,QAAQ,EAEd,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,KAAK,QAAQ,EACb,kBAAkB,GACnB,MAAM,kBAAkB,CAAA;AAEzB,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB,OAAO,CAAA;AAEpC,MAAM,MAAM,iBAAiB,GAAG;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,IAAI,EAAE,QAAQ,CAAA;CACf,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,IAAI,EAAE,WAAW,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CAE3D;AAED,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,EACxB,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,GAC5E,iBAAiB,EAAE,CAiCrB;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IACxB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,GAAG,aAAa,EAAE,CAsBlB;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAC7B,gBAAgB,GAChB,uBAAuB,GACvB,eAAe,GACf,IAAI,CAAA;AAER,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,IAAI,EAAE,QAAQ,CAAA;IACd,YAAY,EAAE,QAAQ,EAAE,CAAA;IACxB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,UAAU,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACvE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,GAAG,qBAAqB,CAsBxB;AAED,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,kBAAkB,EAC3B,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,MAAM,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACjC,KAAK,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACjC,GACA,kBAAkB,EAAE,CAkBtB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACvC,OAAO,EAAE,kBAAkB,CAAA;IAC3B,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,EAAE,GAAG,SAAS,CAAA;IAC3D,YAAY,EAAE,QAAQ,EAAE,CAAA;IACxB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,UAAU,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACvE,KAAK,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAChC,MAAM,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAClC,GACG;IAAE,SAAS,EAAE,kBAAkB,CAAC;IAAC,MAAM,EAAE,qBAAqB,CAAA;CAAE,GAChE;IAAE,SAAS,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,qBAAqB,CAAA;CAAE,CAmCrD;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,MAAM,EAAE,CA2BxE"}
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Shared plan-layout clearance for MCP tools.
3
+ *
4
+ * - Door keep-outs (level-scoped)
5
+ * - Item–item AABB overlap (rotation + scale aware)
6
+ * - Placement candidate search when primary pose is blocked
7
+ *
8
+ * See docs/layout-clearance-error-log.md for regression checklist.
9
+ */
10
+ import { aabbsOverlap, findBlockedDoors, itemNodePlanAabb, itemPlanAabb, resolveNodeLevelId, } from './door-clearance';
11
+ export { aabbsOverlap, collectDoorKeepouts, findBlockedDoors, itemNodePlanAabb, itemPlanAabb, resolveNodeLevelId, } from './door-clearance';
12
+ /** Minimum free space (m) required between item footprints. */
13
+ export const DEFAULT_ITEM_GAP = 0.08;
14
+ export function nodeItemAabb(node) {
15
+ return itemNodePlanAabb(node);
16
+ }
17
+ export function collectOccupiedFootprints(nodes, options) {
18
+ const list = [...nodes];
19
+ const byId = new Map(list.map((n) => [n.id, n]));
20
+ const out = [];
21
+ for (const node of list) {
22
+ if (node.type !== 'item')
23
+ continue;
24
+ if (options?.excludeIds?.has(node.id))
25
+ continue;
26
+ const attach = node.asset?.attachTo;
27
+ if (options?.floorOnly &&
28
+ (attach === 'wall' || attach === 'wall-side' || attach === 'ceiling')) {
29
+ continue;
30
+ }
31
+ const levelId = resolveNodeLevelId(node.id, byId);
32
+ if (options?.levelId && levelId !== options.levelId)
33
+ continue;
34
+ // Floor packing: prefer level-parented items (not wall-hosted children).
35
+ if (options?.floorOnly && node.parentId && levelId && node.parentId !== levelId) {
36
+ if (attach === 'wall' || attach === 'wall-side' || attach === 'ceiling')
37
+ continue;
38
+ // Wall-parented items without attachTo still skipped for floor packing.
39
+ if (byId.get(node.parentId)?.type === 'wall')
40
+ continue;
41
+ }
42
+ const aabb = nodeItemAabb(node);
43
+ if (!aabb)
44
+ continue;
45
+ const name = node.name ?? node.asset?.name;
46
+ out.push({
47
+ id: node.id,
48
+ name: typeof name === 'string' ? name : undefined,
49
+ levelId,
50
+ aabb,
51
+ });
52
+ }
53
+ return out;
54
+ }
55
+ export function findItemItemCollisions(args) {
56
+ const gap = args.gap ?? DEFAULT_ITEM_GAP;
57
+ const footprints = collectOccupiedFootprints(args.nodes, { levelId: args.levelId });
58
+ const collisions = [];
59
+ for (let i = 0; i < footprints.length; i++) {
60
+ for (let j = i + 1; j < footprints.length; j++) {
61
+ const a = footprints[i];
62
+ const b = footprints[j];
63
+ if (a.levelId && b.levelId && a.levelId !== b.levelId)
64
+ continue;
65
+ if (!aabbsOverlap(a.aabb, b.aabb, gap))
66
+ continue;
67
+ collisions.push({
68
+ aId: a.id,
69
+ bId: b.id,
70
+ aName: a.name,
71
+ bName: b.name,
72
+ levelId: a.levelId ?? b.levelId,
73
+ kind: 'item-aabb',
74
+ message: `Items overlap: ${a.name ?? a.id} (${a.id}) and ${b.name ?? b.id} (${b.id})`,
75
+ });
76
+ }
77
+ }
78
+ return collisions;
79
+ }
80
+ export function classifyPlacement(args) {
81
+ const padding = args.padding ?? 0.05;
82
+ const itemGap = args.itemGap ?? DEFAULT_ITEM_GAP;
83
+ const doorGap = args.doorGap ?? 0.02;
84
+ if (args.roomBounds) {
85
+ const b = args.roomBounds;
86
+ if (args.aabb.minX < b.minX + padding ||
87
+ args.aabb.maxX > b.maxX - padding ||
88
+ args.aabb.minZ < b.minZ + padding ||
89
+ args.aabb.maxZ > b.maxZ - padding) {
90
+ return 'outside_bounds';
91
+ }
92
+ }
93
+ if (args.doorKeepouts.some((k) => aabbsOverlap(args.aabb, k, doorGap))) {
94
+ return 'blocks_door_clearance';
95
+ }
96
+ if (args.occupied.some((o) => aabbsOverlap(args.aabb, o, itemGap))) {
97
+ return 'overlaps_item';
98
+ }
99
+ return 'ok';
100
+ }
101
+ export function generatePlacementCandidates(primary, options) {
102
+ const laterals = options?.lateralsM ?? [0, -0.4, 0.4, -0.8, 0.8, -1.2, 1.2];
103
+ const insets = options?.insetsM ?? [0, 0.25, 0.5, 0.75];
104
+ const along = options?.along ?? { x: 1, z: 0 };
105
+ const inward = options?.inward ?? { x: 0, z: 1 };
106
+ const out = [];
107
+ const seen = new Set();
108
+ for (const lat of laterals) {
109
+ for (const inset of insets) {
110
+ const x = primary.x + along.x * lat + inward.x * inset;
111
+ const z = primary.z + along.z * lat + inward.z * inset;
112
+ const key = `${x.toFixed(3)},${z.toFixed(3)},${primary.rotationDeg}`;
113
+ if (seen.has(key))
114
+ continue;
115
+ seen.add(key);
116
+ out.push({ x, z, rotationDeg: primary.rotationDeg });
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ export function findValidPlacement(args) {
122
+ const candidates = generatePlacementCandidates(args.primary, {
123
+ along: args.along,
124
+ inward: args.inward,
125
+ });
126
+ // Prefer reporting why the **primary** pose failed (door/overlap), not the
127
+ // last lateral/inset candidate (often outside_bounds after large nudges).
128
+ let primaryReason = null;
129
+ const reasonPriority = [
130
+ 'blocks_door_clearance',
131
+ 'overlaps_item',
132
+ 'outside_bounds',
133
+ ];
134
+ let bestFailReason = 'overlaps_item';
135
+ for (let i = 0; i < candidates.length; i++) {
136
+ const c = candidates[i];
137
+ const rot = (c.rotationDeg * Math.PI) / 180;
138
+ const aabb = itemPlanAabb([c.x, 0, c.z], args.dimensions, rot);
139
+ const reason = classifyPlacement({
140
+ aabb,
141
+ doorKeepouts: args.doorKeepouts,
142
+ occupied: args.occupied,
143
+ roomBounds: args.roomBounds,
144
+ });
145
+ if (reason === 'ok')
146
+ return { candidate: c, reason };
147
+ if (i === 0)
148
+ primaryReason = reason;
149
+ const prevRank = reasonPriority.indexOf(bestFailReason);
150
+ const nextRank = reasonPriority.indexOf(reason);
151
+ if (nextRank >= 0 && (prevRank < 0 || nextRank < prevRank)) {
152
+ bestFailReason = reason;
153
+ }
154
+ }
155
+ return { candidate: null, reason: primaryReason ?? bestFailReason };
156
+ }
157
+ /**
158
+ * Collect layout issues, scoped per level so stacked floors do not false-positive.
159
+ */
160
+ export function layoutIssuesFromScene(nodes) {
161
+ const list = [...nodes];
162
+ const byId = new Map(list.map((n) => [n.id, n]));
163
+ const levelIds = new Set();
164
+ for (const n of list) {
165
+ if (n.type === 'level')
166
+ levelIds.add(n.id);
167
+ }
168
+ // Also collect levels referenced by walls/items (in case filter missed)
169
+ for (const n of list) {
170
+ const lid = resolveNodeLevelId(n.id, byId);
171
+ if (lid)
172
+ levelIds.add(lid);
173
+ }
174
+ const issues = [];
175
+ const levels = levelIds.size > 0 ? [...levelIds] : [undefined];
176
+ for (const levelId of levels) {
177
+ for (const b of findBlockedDoors({ nodes: list, levelId })) {
178
+ issues.push(b.message);
179
+ }
180
+ for (const c of findItemItemCollisions({ nodes: list, levelId })) {
181
+ issues.push(c.message);
182
+ }
183
+ }
184
+ // Deduplicate (node may appear under multiple walks)
185
+ return [...new Set(issues)];
186
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"measure.d.ts","sourceRoot":"","sources":["../../src/tools/measure.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;AAIpD,eAAO,MAAM,YAAY;;;CAGxB,CAAA;AAED,eAAO,MAAM,aAAa;;;;CAIzB,CAAA;AAsED,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAmEhF"}
1
+ {"version":3,"file":"measure.d.ts","sourceRoot":"","sources":["../../src/tools/measure.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;AAIpD,eAAO,MAAM,YAAY;;;CAGxB,CAAA;AAED,eAAO,MAAM,aAAa;;;;CAIzB,CAAA;AAsED,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAuEhF"}
@@ -82,7 +82,7 @@ function shoelaceArea(polygon) {
82
82
  export function registerMeasure(server, bridge) {
83
83
  server.registerTool('measure', {
84
84
  title: 'Measure',
85
- description: 'Measure distance (in meters) between two nodes, or the area of a polygon node when fromId === toId.',
85
+ description: 'Measure distance (in meters) between two nodes, or the net area of a polygon node when fromId === toId.',
86
86
  inputSchema: measureInput,
87
87
  outputSchema: measureOutput,
88
88
  }, async ({ fromId, toId }) => {
@@ -98,7 +98,11 @@ export function registerMeasure(server, bridge) {
98
98
  if (fromId === toId) {
99
99
  const n = from;
100
100
  if (n.type === 'zone' || n.type === 'slab' || n.type === 'ceiling') {
101
- const area = shoelaceArea(n.polygon);
101
+ const holes = n.type === 'zone' ? [] : n.holes;
102
+ const holeArea = Array.isArray(holes)
103
+ ? holes.reduce((sum, hole) => sum + shoelaceArea(hole), 0)
104
+ : 0;
105
+ const area = Math.max(0, shoelaceArea(n.polygon) - holeArea);
102
106
  const payload = {
103
107
  distanceMeters: 0,
104
108
  areaSqMeters: area,