@sealab/mcp-server 1.0.3 → 1.0.4
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.
- package/dist/index.js +2 -1
- package/dist/tools/ai-drawing.js +563 -1
- package/dist/tools/cart-cross-check.js +182 -0
- package/dist/tools/elevation-run-segmentation.js +99 -0
- package/dist/tools/orders.js +2 -0
- package/dist/tools/placement-projection.js +119 -0
- package/dist/tools/projects.js +368 -0
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/tools/ai-drawing.test.ts +464 -44
- package/src/tools/ai-drawing.ts +750 -36
- package/src/tools/cart-cross-check.test.ts +84 -0
- package/src/tools/cart-cross-check.ts +267 -0
- package/src/tools/elevation-run-segmentation.test.ts +64 -0
- package/src/tools/elevation-run-segmentation.ts +175 -0
- package/src/tools/orders.ts +7 -3
- package/src/tools/placement-projection.test.ts +185 -0
- package/src/tools/placement-projection.ts +247 -0
- package/src/tools/projects.test.ts +135 -0
- package/src/tools/projects.ts +370 -0
package/src/tools/ai-drawing.ts
CHANGED
|
@@ -9,6 +9,21 @@ import {
|
|
|
9
9
|
type AiDrawingReviewCanvasType,
|
|
10
10
|
type AiDrawingSessionReviewRenderOptions,
|
|
11
11
|
} from './ai-drawing-session-review-renderer';
|
|
12
|
+
import {
|
|
13
|
+
projectElevationPlacement,
|
|
14
|
+
projectPlanPlacement,
|
|
15
|
+
projectPlanWallAnchoredPlacement,
|
|
16
|
+
isProjectedPlacementOutsideImage,
|
|
17
|
+
type ElevationCalibration,
|
|
18
|
+
type PlanCalibration,
|
|
19
|
+
} from './placement-projection';
|
|
20
|
+
import { segmentElevationRun } from './elevation-run-segmentation';
|
|
21
|
+
import {
|
|
22
|
+
crossCheckPlacementsAgainstCart,
|
|
23
|
+
type CartItemLike,
|
|
24
|
+
type PlacementCheckSpec,
|
|
25
|
+
type CartCrossCheckResult,
|
|
26
|
+
} from './cart-cross-check';
|
|
12
27
|
|
|
13
28
|
const LEGACY_DRAWING_TOOL_NAME = 'the legacy frontend drawing route';
|
|
14
29
|
const STORED_CARTS_TERM = 'stored carts';
|
|
@@ -18,10 +33,10 @@ const AI_CART_TOOL_BOUNDARY =
|
|
|
18
33
|
', or ' +
|
|
19
34
|
LEGACY_DRAWING_TOOL_NAME +
|
|
20
35
|
'. The AI cart is one physical cabinet manifest for the whole AI drawing session, not one manifest per canvas. positionName is the physical cabinet identity and must remain stable across plan and elevation placements. backend aiCartItemId is canonical after creation.';
|
|
21
|
-
const AI_DRAWING_VISUAL_REVIEW_COMPLETION_GUIDANCE =
|
|
22
|
-
'Before reporting completion after canvas, AI cart, or placement writes, call render_ai_drawing_session_review, inspect every returned full-canvas PNG with native vision, apply the visual QA checklist and every returned sourceBoundaryReviewTasks[] item against the actual source drawing pixels, correct wrong placements with existing AI-only placement tools, rerender, and document PASS/FAIL/UNCERTAIN per canvas and placement. A placement may be PASS only when the agent writes explicit LEFT/RIGHT/TOP/BOTTOM boundary evidence from visible source linework; JSON validity is not visual correctness, and bbox non-overlap or bbox adjacency is not visual correctness. If the user asked to create an AI drawing session/cart from drawings, do not stop at a setup-only session with uploaded backgrounds: infer visible cabinet/catalog items from the drawings, create the AI cart items, place every visible cabinet/object on the relevant canvases, and only leave items unplaced when the user explicitly asked for setup-only or a view is genuinely ambiguous and reported UNCERTAIN.';
|
|
23
|
-
const AI_DRAWING_VISUAL_QA_CHECKLIST =
|
|
24
|
-
'Visual QA checklist for every rendered review PNG: compare each rectangle against the visible drawing, not just labels; source drawing pixels are the authority and generated bbox relationships are never proof by themselves; every PASS must cite visible LEFT, RIGHT, TOP, and BOTTOM source linework; each cabinet box must sit inside the actual cabinet face or plan footprint boundaries; reject boxes that overlap other cabinets, appliances, fixtures, counters, toe kicks, walls, dimension strings, or blank/context areas; reject shifted boxes that extend beyond wood/front boundaries or include refrigerator/range/dishwasher/sink appliance surfaces unless the AI cart item is explicitly an appliance panel/opening; verify the same physical cabinet uses the same positionName/aiCartItemId across plan and elevation while plan footprints and elevation front faces stay on their own canvases; verify no obvious visible cabinet is missing; verify no side/return/context-only face is treated as a primary cabinet; verify labels do not hide boundary errors; when any item is ambiguous, mark it unresolved or ask the user instead of declaring the review correct.';
|
|
36
|
+
const AI_DRAWING_VISUAL_REVIEW_COMPLETION_GUIDANCE =
|
|
37
|
+
'Before reporting completion after canvas, AI cart, or placement writes, call render_ai_drawing_session_review, inspect every returned full-canvas PNG with native vision, apply the visual QA checklist and every returned sourceBoundaryReviewTasks[] item against the actual source drawing pixels, correct wrong placements with existing AI-only placement tools, rerender, and document PASS/FAIL/UNCERTAIN per canvas and placement. A placement may be PASS only when the agent writes explicit LEFT/RIGHT/TOP/BOTTOM boundary evidence from visible source linework; JSON validity is not visual correctness, and bbox non-overlap or bbox adjacency is not visual correctness. If the user asked to create an AI drawing session/cart from drawings, do not stop at a setup-only session with uploaded backgrounds: infer visible cabinet/catalog items from the drawings, create the AI cart items, place every visible cabinet/object on the relevant canvases, and only leave items unplaced when the user explicitly asked for setup-only or a view is genuinely ambiguous and reported UNCERTAIN.';
|
|
38
|
+
const AI_DRAWING_VISUAL_QA_CHECKLIST =
|
|
39
|
+
'Visual QA checklist for every rendered review PNG: compare each rectangle against the visible drawing, not just labels; source drawing pixels are the authority and generated bbox relationships are never proof by themselves; every PASS must cite visible LEFT, RIGHT, TOP, and BOTTOM source linework; each cabinet box must sit inside the actual cabinet face or plan footprint boundaries; reject boxes that overlap other cabinets, appliances, fixtures, counters, toe kicks, walls, dimension strings, or blank/context areas; reject shifted boxes that extend beyond wood/front boundaries or include refrigerator/range/dishwasher/sink appliance surfaces unless the AI cart item is explicitly an appliance panel/opening; verify the same physical cabinet uses the same positionName/aiCartItemId across plan and elevation while plan footprints and elevation front faces stay on their own canvases; verify no obvious visible cabinet is missing; verify no side/return/context-only face is treated as a primary cabinet; verify labels do not hide boundary errors; when any item is ambiguous, mark it unresolved or ask the user instead of declaring the review correct.';
|
|
25
40
|
const AI_ARCHITECTURAL_DRAWING_INTERPRETATION_GUIDANCE =
|
|
26
41
|
'Architectural drawing interpretation contract: use native vision and reasoning on the actual drawing images before writing MCP state. Identify each physical cabinet/object bbox from visible boundaries, scale callouts, known dimensions, and repeated modules. Differentiate base cabinets, uppers/wall cabinets, tall/pantry units, fillers, appliance panels/openings, drawer stacks, single-door fronts, and double-door fronts because door/front segmentation changes bbox boundaries and catalog matching. Toekicks are separate plinth objects in the Sealab catalog: create separate AI cart items and separate orange plinth/toekick bboxes for them when present. On any front elevation with base or sink-base cabinets, explicitly inspect the lower support/toekick band: if a visible plinth/toekick band exists, it must be represented as a separate plinth AI cart item and orange bbox before the review can pass. Do not include toekicks or countertops in front-facing cabinet bboxes. Do not include toekicks or countertops in base cabinet height; use called-out scale plus known cabinet/body dimensions to infer the cabinet body height exclusive of plinth/toekick and countertop. A drawing-derived base or sink-base item cannot be marked READY unless drawingClassificationEvidence.baseCabinetBodyHeightEvidence documents the measured cabinet body height, the top/bottom boundary evidence, and explicit exclusion of both countertop and toekick_plinth; the orderReadyPayload height must match that body-height evidence. Cabinet front-face bboxes must not extend below the Z0/floor reference line or into floors, walls, ceilings, counters, labels, dimension strings, or blank/context areas. If a cabinet bbox captures floor, wall, ceiling, countertop, or a toekick/plinth, the visual review must fail and the placement must be patched. Review overlays use blue for cabinet front-facing bboxes and orange for plinth/toekick bboxes.';
|
|
27
42
|
const AI_CART_ORDER_READY_CREATION_GUIDANCE =
|
|
@@ -78,8 +93,8 @@ const AI_REDUX_IMPORT_GUIDANCE =
|
|
|
78
93
|
AI_DRAWING_VISUAL_REVIEW_COMPLETION_GUIDANCE +
|
|
79
94
|
' ' +
|
|
80
95
|
AI_AUTOMATIC_CART_MIRROR_GUIDANCE;
|
|
81
|
-
const AI_DRAWING_VISUAL_REVIEW_GUIDANCE =
|
|
82
|
-
'Render local review PNG artifacts from the actual stored backend AIDrawingTool session state after writing or updating AI cart items and per-canvas placements. The output includes full-canvas review PNGs only; no per-placement crop artifacts are generated. Agents must open every returned full-canvas PNG path with native vision, apply the visual QA checklist to each canvas, and complete every returned sourceBoundaryReviewTasks[] item against the actual source drawing pixels. For each placement, PASS requires a written boundary-evidence sentence naming visible source linework for LEFT, RIGHT, TOP, and BOTTOM and listing excluded non-object context; otherwise mark FAIL or UNCERTAIN. Do not use bbox non-overlap, bbox adjacency, labels, dimensions, or evidence prose as proof that a base cabinet, plinth/toekick, floor/Z0, countertop, plan footprint, or cabinet face boundary is visually correct. Correct bad placements through the existing AI-only placement tools, render again/rerender, and only then report completion. JSON validity and structurally valid placement payloads are not visual correctness. Completion is blocked until the agent records PASS/FAIL/UNCERTAIN for every rendered canvas and placement with required boundary evidence, patches failures, rerenders, and explicitly documents any remaining unresolved issues. ' +
|
|
96
|
+
const AI_DRAWING_VISUAL_REVIEW_GUIDANCE =
|
|
97
|
+
'Render local review PNG artifacts from the actual stored backend AIDrawingTool session state after writing or updating AI cart items and per-canvas placements. The output includes full-canvas review PNGs only; no per-placement crop artifacts are generated. Agents must open every returned full-canvas PNG path with native vision, apply the visual QA checklist to each canvas, and complete every returned sourceBoundaryReviewTasks[] item against the actual source drawing pixels. For each placement, PASS requires a written boundary-evidence sentence naming visible source linework for LEFT, RIGHT, TOP, and BOTTOM and listing excluded non-object context; otherwise mark FAIL or UNCERTAIN. Do not use bbox non-overlap, bbox adjacency, labels, dimensions, or evidence prose as proof that a base cabinet, plinth/toekick, floor/Z0, countertop, plan footprint, or cabinet face boundary is visually correct. Correct bad placements through the existing AI-only placement tools, render again/rerender, and only then report completion. JSON validity and structurally valid placement payloads are not visual correctness. Completion is blocked until the agent records PASS/FAIL/UNCERTAIN for every rendered canvas and placement with required boundary evidence, patches failures, rerenders, and explicitly documents any remaining unresolved issues. ' +
|
|
83
98
|
AI_ARCHITECTURAL_DRAWING_INTERPRETATION_GUIDANCE +
|
|
84
99
|
' ' +
|
|
85
100
|
AI_DRAWING_VISUAL_QA_CHECKLIST +
|
|
@@ -1027,6 +1042,274 @@ export const AiCanvasCabinetPlacementsDeleteInputSchema = AiDrawingSessionIdPara
|
|
|
1027
1042
|
'Delete selected placements from one backend-owned AIDrawingTool canvas. ' + AI_CANVAS_PLACEMENT_GUIDANCE
|
|
1028
1043
|
);
|
|
1029
1044
|
|
|
1045
|
+
// ---------------------------------------------------------------------------
|
|
1046
|
+
// Deterministic placement projection (project_ai_canvas_cabinet_placements)
|
|
1047
|
+
// ---------------------------------------------------------------------------
|
|
1048
|
+
|
|
1049
|
+
const AI_PLACEMENT_PROJECTION_GUIDANCE =
|
|
1050
|
+
'Compute exact per-canvas placement rectangles from one pixels-per-inch calibration plus each cabinet\'s real ' +
|
|
1051
|
+
'inch dimensions, instead of eyeballing raw pixel boxes. This is the preferred way to create placements: a ' +
|
|
1052
|
+
'vision model cannot reliably guess precise pixel rectangles, and per-box guesses drift, overlap, and double up ' +
|
|
1053
|
+
'across rows of similar cabinets. Workflow: (1) read a visible dimension string and the finished-floor/Z0 line on ' +
|
|
1054
|
+
'the actual source image to establish horizontalPixelsPerInch, verticalPixelsPerInch, floorZ0Px, and one horizontal ' +
|
|
1055
|
+
'origin reference; (2) supply each cabinet face by its real along-wall start, width, body height, and ' +
|
|
1056
|
+
'finished-floor-to-bottom height in inches (read width/height from the catalog/order-ready payload and the ' +
|
|
1057
|
+
'drawing dimension strings, never from guessed pixels); (3) this tool projects every rectangle from the same scale ' +
|
|
1058
|
+
'so the whole run stays aligned and gap-correct. imageSizePx, basisImageWidthPx, and basisImageHeightPx must all ' +
|
|
1059
|
+
'equal the true pixel size of the source image the calibration was measured on, so the review renderer rescales ' +
|
|
1060
|
+
'consistently. Elevation faces project along-wall position and height-above-floor; plan footprints project ' +
|
|
1061
|
+
'room/global center X/Y with width/depth. After projecting, render_ai_drawing_session_review and verify against the ' +
|
|
1062
|
+
'source pixels. ' +
|
|
1063
|
+
AI_CANVAS_PLACEMENT_GUIDANCE;
|
|
1064
|
+
|
|
1065
|
+
const ProjectionImageSizePxSchema = z.object({
|
|
1066
|
+
width: z.number().finite().positive().describe('True source image width in pixels.'),
|
|
1067
|
+
height: z.number().finite().positive().describe('True source image height in pixels.'),
|
|
1068
|
+
}).strict();
|
|
1069
|
+
|
|
1070
|
+
const ProjectionPixelPointSchema = z.object({
|
|
1071
|
+
x: z.number().finite().describe('Image pixel X.'),
|
|
1072
|
+
y: z.number().finite().describe('Image pixel Y.'),
|
|
1073
|
+
}).strict();
|
|
1074
|
+
|
|
1075
|
+
const ProjectorElevationCalibrationSchema = z.object({
|
|
1076
|
+
canvasType: ElevationCanvasTypeSchema.describe('Elevation canvas this calibration applies to.'),
|
|
1077
|
+
imageSizePx: ProjectionImageSizePxSchema.describe(
|
|
1078
|
+
'True pixel size of the elevation source image the calibration was measured on.'
|
|
1079
|
+
),
|
|
1080
|
+
horizontalPixelsPerInch: z.number().finite().positive().describe(
|
|
1081
|
+
'Pixels per inch along the wall, derived from a visible horizontal dimension string.'
|
|
1082
|
+
),
|
|
1083
|
+
verticalPixelsPerInch: z.number().finite().positive().describe(
|
|
1084
|
+
'Pixels per inch vertically, derived from a visible vertical dimension (e.g. ceiling height A.F.F.).'
|
|
1085
|
+
),
|
|
1086
|
+
floorZ0Px: z.number().finite().describe('Pixel Y of the finished-floor / Z0 reference line.'),
|
|
1087
|
+
horizontalOriginPx: ProjectionPixelPointSchema.describe(
|
|
1088
|
+
'A pixel point whose along-wall inch coordinate is known (e.g. the left wall corner).'
|
|
1089
|
+
),
|
|
1090
|
+
horizontalOriginCoordinateInches: z.number().finite().default(0).describe(
|
|
1091
|
+
'The along-wall inch coordinate that horizontalOriginPx maps to. Usually 0 at the chosen origin.'
|
|
1092
|
+
),
|
|
1093
|
+
horizontalAxisPositiveDirection: AxisHorizontalDirectionSchema.describe(
|
|
1094
|
+
'Screen direction in which the along-wall inch coordinate increases.'
|
|
1095
|
+
),
|
|
1096
|
+
verticalAxisPositiveDirection: AxisVerticalDirectionSchema.default('up').describe(
|
|
1097
|
+
'Screen direction in which height-above-floor increases. Normally "up".'
|
|
1098
|
+
),
|
|
1099
|
+
}).strict();
|
|
1100
|
+
|
|
1101
|
+
const ProjectorPlanCalibrationSchema = z.object({
|
|
1102
|
+
canvasType: z.literal('plan'),
|
|
1103
|
+
imageSizePx: ProjectionImageSizePxSchema.describe(
|
|
1104
|
+
'True pixel size of the plan source image the calibration was measured on.'
|
|
1105
|
+
),
|
|
1106
|
+
pixelsPerInch: z.number().finite().positive().describe('Pixels per room inch on the plan.'),
|
|
1107
|
+
roomOriginPx: ProjectionPixelPointSchema.describe('Pixel point that maps to room/global origin (0, 0).'),
|
|
1108
|
+
xPositiveDirection: AxisHorizontalDirectionSchema.describe('Screen direction in which room X increases.'),
|
|
1109
|
+
yPositiveDirection: AxisVerticalDirectionSchema.describe('Screen direction in which room Y increases.'),
|
|
1110
|
+
}).strict();
|
|
1111
|
+
|
|
1112
|
+
const ProjectorPlacementSpecSchema = z.object({
|
|
1113
|
+
aiCartItemId: z.string().uuid().optional().describe(
|
|
1114
|
+
'Optional backend AI cart item UUID. Required unless positionName exactly matches an active AI cart item.'
|
|
1115
|
+
),
|
|
1116
|
+
positionName: z.string().min(1).describe('Physical AI cart item positionName and per-canvas placement identity.'),
|
|
1117
|
+
// Elevation fields
|
|
1118
|
+
horizontalStartInches: z.number().finite().optional().describe(
|
|
1119
|
+
'Elevation only: left edge of the face along the wall, in the wall\'s horizontal inch coordinate.'
|
|
1120
|
+
),
|
|
1121
|
+
heightInches: z.number().finite().positive().optional().describe(
|
|
1122
|
+
'Elevation only: cabinet face height in inches (base cabinet body height excludes toekick and countertop).'
|
|
1123
|
+
),
|
|
1124
|
+
bottomFromFloorInches: z.number().finite().nonnegative().default(0).describe(
|
|
1125
|
+
'Elevation only: finished-floor-to-bottom of this face in inches. 0 for a base cabinet on the floor.'
|
|
1126
|
+
),
|
|
1127
|
+
// Plan fields (center mode)
|
|
1128
|
+
centerXInches: z.number().finite().optional().describe('Plan center mode: cabinet center X in room/global inches.'),
|
|
1129
|
+
centerYInches: z.number().finite().optional().describe('Plan center mode: cabinet center Y in room/global inches.'),
|
|
1130
|
+
depthInches: z.number().finite().positive().optional().describe('Plan only: cabinet front-to-back depth in inches.'),
|
|
1131
|
+
rotation: CoordinateRotationSchema.optional().describe('Plan center mode: wall rotation 0/90/180/270; swaps width/depth axes.'),
|
|
1132
|
+
// Plan fields (wall-anchored mode — preferred: pins the back edge to the wall so the box cannot bleed into the wall)
|
|
1133
|
+
wallBackEdgeInches: z.number().finite().optional().describe(
|
|
1134
|
+
'Plan wall-anchored mode: room-inch coordinate of the cabinet BACK edge on the depth axis — i.e. the wall inner face. The footprint grows into the room from here.'
|
|
1135
|
+
),
|
|
1136
|
+
depthAxis: z.enum(['x', 'y']).optional().describe('Plan wall-anchored mode: room axis perpendicular to the wall (the depth direction).'),
|
|
1137
|
+
depthDirection: z.enum(['positive', 'negative']).optional().describe('Plan wall-anchored mode: direction in room coordinates that depth grows from the wall into the room.'),
|
|
1138
|
+
alongWallStartInches: z.number().finite().optional().describe('Plan wall-anchored mode: start of the run along the wall, in room inches on the non-depth axis.'),
|
|
1139
|
+
// Common
|
|
1140
|
+
widthInches: z.number().finite().positive().describe('Cabinet width in inches (along the face / along the wall).'),
|
|
1141
|
+
}).strict();
|
|
1142
|
+
|
|
1143
|
+
export const ProjectAiCanvasCabinetPlacementsInputSchema = AiDrawingSessionIdParamSchema.extend({
|
|
1144
|
+
canvasType: CanvasTypeSchema,
|
|
1145
|
+
elevationCalibration: ProjectorElevationCalibrationSchema.optional().describe(
|
|
1146
|
+
'Required when canvasType is an elevation (north/south/east/west).'
|
|
1147
|
+
),
|
|
1148
|
+
planCalibration: ProjectorPlanCalibrationSchema.optional().describe(
|
|
1149
|
+
'Required when canvasType is plan.'
|
|
1150
|
+
),
|
|
1151
|
+
placements: z.array(ProjectorPlacementSpecSchema).min(1).describe(
|
|
1152
|
+
'Cabinet specs in real inches. Elevation specs need horizontalStartInches + widthInches + heightInches ' +
|
|
1153
|
+
'(+ optional bottomFromFloorInches). Plan specs need centerXInches + centerYInches + widthInches + depthInches ' +
|
|
1154
|
+
'(+ optional rotation).'
|
|
1155
|
+
),
|
|
1156
|
+
writeMode: z.enum(['replace', 'upsert', 'return_only']).default('upsert').describe(
|
|
1157
|
+
'replace: overwrite all placements on this canvas. upsert: create/update by positionName without deleting others. ' +
|
|
1158
|
+
'return_only: compute and return rectangles without writing to the backend (useful for a dry run before committing).'
|
|
1159
|
+
),
|
|
1160
|
+
crossCheckCartItems: z.boolean().default(true).describe(
|
|
1161
|
+
'When true (default), verify each placement against the active AI cart manifest before writing: every placement ' +
|
|
1162
|
+
'must match a real cart item and its width/height must match that cabinet (within tolerance). Blocks the write on ' +
|
|
1163
|
+
'mismatch so a box cannot be stretched to cover multiple cabinets. Set false only for a calibration dry run.'
|
|
1164
|
+
),
|
|
1165
|
+
widthToleranceInches: z.number().finite().positive().optional().describe('Allowed width difference vs the cart item (default 1.0").'),
|
|
1166
|
+
heightToleranceInches: z.number().finite().positive().optional().describe('Allowed height/depth difference vs the cart item (default 1.5").'),
|
|
1167
|
+
source: PlacementSourceSchema.optional(),
|
|
1168
|
+
}).strict().superRefine((input, ctx) => {
|
|
1169
|
+
const isPlan = input.canvasType === 'plan';
|
|
1170
|
+
if (isPlan && !input.planCalibration) {
|
|
1171
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['planCalibration'], message: 'planCalibration is required when canvasType is plan.' });
|
|
1172
|
+
}
|
|
1173
|
+
if (isPlan && input.elevationCalibration) {
|
|
1174
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['elevationCalibration'], message: 'Do not provide elevationCalibration for a plan canvas.' });
|
|
1175
|
+
}
|
|
1176
|
+
if (!isPlan && !input.elevationCalibration) {
|
|
1177
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['elevationCalibration'], message: 'elevationCalibration is required when canvasType is an elevation.' });
|
|
1178
|
+
}
|
|
1179
|
+
if (!isPlan && input.planCalibration) {
|
|
1180
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['planCalibration'], message: 'Do not provide planCalibration for an elevation canvas.' });
|
|
1181
|
+
}
|
|
1182
|
+
if (!isPlan && input.elevationCalibration && input.elevationCalibration.canvasType !== input.canvasType) {
|
|
1183
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['elevationCalibration', 'canvasType'], message: 'elevationCalibration.canvasType must match canvasType.' });
|
|
1184
|
+
}
|
|
1185
|
+
input.placements.forEach((placement, index) => {
|
|
1186
|
+
if (isPlan) {
|
|
1187
|
+
if (placement.depthInches === undefined) {
|
|
1188
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, 'depthInches'], message: 'depthInches is required for plan placements.' });
|
|
1189
|
+
}
|
|
1190
|
+
const hasWallAnchor =
|
|
1191
|
+
placement.wallBackEdgeInches !== undefined ||
|
|
1192
|
+
placement.depthAxis !== undefined ||
|
|
1193
|
+
placement.depthDirection !== undefined ||
|
|
1194
|
+
placement.alongWallStartInches !== undefined;
|
|
1195
|
+
const hasCenter = placement.centerXInches !== undefined || placement.centerYInches !== undefined;
|
|
1196
|
+
if (hasWallAnchor) {
|
|
1197
|
+
// Wall-anchored mode (preferred): all four anchor fields required, and no center fields.
|
|
1198
|
+
for (const field of ['wallBackEdgeInches', 'depthAxis', 'depthDirection', 'alongWallStartInches'] as const) {
|
|
1199
|
+
if (placement[field] === undefined) {
|
|
1200
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, field], message: `${field} is required for wall-anchored plan placements.` });
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (hasCenter) {
|
|
1204
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, 'centerXInches'], message: 'Use either wall-anchored fields or centerXInches/centerYInches, not both.' });
|
|
1205
|
+
}
|
|
1206
|
+
} else {
|
|
1207
|
+
// Center mode (fallback).
|
|
1208
|
+
if (placement.centerXInches === undefined) {
|
|
1209
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, 'centerXInches'], message: 'Provide wall-anchored fields (preferred) or centerXInches for plan placements.' });
|
|
1210
|
+
}
|
|
1211
|
+
if (placement.centerYInches === undefined) {
|
|
1212
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, 'centerYInches'], message: 'Provide wall-anchored fields (preferred) or centerYInches for plan placements.' });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
} else {
|
|
1216
|
+
if (placement.horizontalStartInches === undefined) {
|
|
1217
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, 'horizontalStartInches'], message: 'horizontalStartInches is required for elevation placements.' });
|
|
1218
|
+
}
|
|
1219
|
+
if (placement.heightInches === undefined) {
|
|
1220
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, 'heightInches'], message: 'heightInches is required for elevation placements.' });
|
|
1221
|
+
}
|
|
1222
|
+
if (placement.rotation !== undefined && placement.rotation !== 0) {
|
|
1223
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['placements', index, 'rotation'], message: AI_CANVAS_PLACEMENT_ROTATION_GUIDANCE });
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
validateUniquePositionNamesForCanvas(input.placements, ctx);
|
|
1228
|
+
}).describe(
|
|
1229
|
+
'Project exact AIDrawingTool per-canvas cabinet placement rectangles from a pixels-per-inch calibration and real ' +
|
|
1230
|
+
'cabinet inch dimensions, then optionally write them. ' + AI_PLACEMENT_PROJECTION_GUIDANCE
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
const AI_ELEVATION_RUN_SEGMENTATION_GUIDANCE =
|
|
1234
|
+
'Decompose each dimensioned elevation run into its individual physical cabinets BEFORE placing boxes, so each ' +
|
|
1235
|
+
'cabinet gets its own rectangle instead of one box spanning a whole run. This fixes under-segmentation, where a ' +
|
|
1236
|
+
'multi-cabinet run (for example a 3\'-0" "EQ A | EQ A" upper that is actually two double-door cabinets) is wrongly ' +
|
|
1237
|
+
'treated as a single cabinet. Workflow per run: (1) read the primary dimension string for the run total and the ' +
|
|
1238
|
+
'secondary chain (EQ / EQ A / EQ B subdivisions) plus the "2" TYP." reveals and the visible vertical seams between ' +
|
|
1239
|
+
'cabinets and doors; (2) decide how many physical cabinets the run contains — a vertical seam with its own stiles ' +
|
|
1240
|
+
'and a separate cart item is a separate cabinet, whereas two doors sharing one box with no center stile gap are one ' +
|
|
1241
|
+
'double-door cabinet; (3) enumerate the modules left to right, each with its real width and an explicit frontType ' +
|
|
1242
|
+
'(single_door, double_door, drawer_stack, etc.) and its own positionName/aiCartItemId; (4) this tool tiles them ' +
|
|
1243
|
+
'contiguously, projects one rectangle per module from the shared calibration, and returns segmentationWarnings when ' +
|
|
1244
|
+
'a module looks too wide for one cabinet, a door type is implausible for its width, the module count is fewer than ' +
|
|
1245
|
+
'the equal subdivisions you read, or the widths do not match the run dimension. Resolve every segmentationWarning ' +
|
|
1246
|
+
'by re-reading the source drawing and creating the correct number of cabinets (each with its own AI cart item) ' +
|
|
1247
|
+
'before reporting completion. Each module must correspond to a real AI cart item; do not invent modules to satisfy ' +
|
|
1248
|
+
'the dimension total. ' +
|
|
1249
|
+
AI_PLACEMENT_PROJECTION_GUIDANCE;
|
|
1250
|
+
|
|
1251
|
+
const ProjectorElevationRunModuleSchema = z.object({
|
|
1252
|
+
positionName: z.string().min(1).describe('Physical AI cart item positionName / placement identity for this single cabinet.'),
|
|
1253
|
+
aiCartItemId: z.string().uuid().optional().describe(
|
|
1254
|
+
'Backend AI cart item UUID for this cabinet. Required unless positionName matches an active AI cart item.'
|
|
1255
|
+
),
|
|
1256
|
+
widthInches: z.number().finite().positive().describe('Real cabinet width in inches for this one module.'),
|
|
1257
|
+
heightInches: z.number().finite().positive().describe('Real cabinet face height in inches (base body height excludes toekick/countertop).'),
|
|
1258
|
+
bottomFromFloorInches: z.number().finite().nonnegative().default(0).describe('Finished-floor-to-bottom of this face in inches.'),
|
|
1259
|
+
frontType: z.enum([
|
|
1260
|
+
'single_door',
|
|
1261
|
+
'double_door',
|
|
1262
|
+
'pair_of_doors',
|
|
1263
|
+
'drawer_stack',
|
|
1264
|
+
'door_over_drawer',
|
|
1265
|
+
'open_shelf',
|
|
1266
|
+
'appliance_panel',
|
|
1267
|
+
'filler',
|
|
1268
|
+
]).describe('Front type for this module. Declaring it forces the single-vs-double-door determination per cabinet.'),
|
|
1269
|
+
}).strict();
|
|
1270
|
+
|
|
1271
|
+
const ProjectorElevationRunSchema = z.object({
|
|
1272
|
+
runStartInches: z.number().finite().describe('Along-wall inch coordinate of this run\'s left edge, in the calibration horizontal coordinate.'),
|
|
1273
|
+
modules: z.array(ProjectorElevationRunModuleSchema).min(1).describe('Ordered left-to-right cabinets that make up this run.'),
|
|
1274
|
+
expectedRunTotalInches: z.number().finite().positive().optional().describe(
|
|
1275
|
+
'Run total from the primary dimension string (e.g. 3\'-0" => 36). Used to warn when module widths do not add up.'
|
|
1276
|
+
),
|
|
1277
|
+
declaredSubdivisionCount: z.number().int().positive().optional().describe(
|
|
1278
|
+
'Number of equal subdivisions you read in the secondary chain (e.g. "EQ A | EQ A" => 2). Warns if you provide fewer modules.'
|
|
1279
|
+
),
|
|
1280
|
+
revealBetweenModulesInches: z.number().finite().nonnegative().optional().describe('Reveal/gap between adjacent cabinets in inches (e.g. "2" TYP.").'),
|
|
1281
|
+
maxSingleCabinetWidthInches: z.number().finite().positive().optional().describe('Width above which a module is flagged as suspected multiple cabinets. Default 48.'),
|
|
1282
|
+
}).strict();
|
|
1283
|
+
|
|
1284
|
+
export const ProjectAiCanvasElevationRunInputSchema = AiDrawingSessionIdParamSchema.extend({
|
|
1285
|
+
canvasType: ElevationCanvasTypeSchema.describe('Elevation canvas (north/south/east/west). Run segmentation is elevation-only.'),
|
|
1286
|
+
elevationCalibration: ProjectorElevationCalibrationSchema,
|
|
1287
|
+
runs: z.array(ProjectorElevationRunSchema).min(1).describe('One or more dimensioned runs to decompose and place.'),
|
|
1288
|
+
writeMode: z.enum(['replace', 'upsert', 'return_only']).default('upsert').describe(
|
|
1289
|
+
'replace: overwrite all placements on this canvas. upsert: create/update by positionName. return_only: dry run.'
|
|
1290
|
+
),
|
|
1291
|
+
crossCheckCartItems: z.boolean().default(true).describe(
|
|
1292
|
+
'When true (default), verify each module against the active AI cart manifest before writing: every module must ' +
|
|
1293
|
+
'match a real cart item of matching width/height. Blocks the write on mismatch, so a run can only hold as many ' +
|
|
1294
|
+
'cabinets as there are real items of the right size. Set false only for a calibration dry run.'
|
|
1295
|
+
),
|
|
1296
|
+
widthToleranceInches: z.number().finite().positive().optional().describe('Allowed width difference vs the cart item (default 1.0").'),
|
|
1297
|
+
heightToleranceInches: z.number().finite().positive().optional().describe('Allowed height difference vs the cart item (default 1.5").'),
|
|
1298
|
+
source: PlacementSourceSchema.optional(),
|
|
1299
|
+
}).strict().superRefine((input, ctx) => {
|
|
1300
|
+
if (input.elevationCalibration.canvasType !== input.canvasType) {
|
|
1301
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['elevationCalibration', 'canvasType'], message: 'elevationCalibration.canvasType must match canvasType.' });
|
|
1302
|
+
}
|
|
1303
|
+
const allPositionNames = input.runs.flatMap((run) => run.modules.map((m) => m.positionName));
|
|
1304
|
+
validateUniquePositionNamesForCanvas(
|
|
1305
|
+
allPositionNames.map((positionName) => ({ positionName })),
|
|
1306
|
+
ctx
|
|
1307
|
+
);
|
|
1308
|
+
}).describe(
|
|
1309
|
+
'Decompose dimensioned elevation runs into individual cabinets and project one placement rectangle per cabinet. ' +
|
|
1310
|
+
AI_ELEVATION_RUN_SEGMENTATION_GUIDANCE
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1030
1313
|
const AiCoordinateDerivationEvidenceSchema = z.object({
|
|
1031
1314
|
basis: z.enum(['validated_plan_placement', 'user_confirmed']).describe(
|
|
1032
1315
|
'How this canonical x/y/z coordinate was derived. MCP must not write canonical coordinates from guessed elevation rectangles.'
|
|
@@ -1092,9 +1375,36 @@ function validatePlacementRotationsForCanvas(
|
|
|
1092
1375
|
});
|
|
1093
1376
|
}
|
|
1094
1377
|
|
|
1378
|
+
function validateUniquePositionNamesForCanvas(
|
|
1379
|
+
placements: Array<{ positionName?: string }>,
|
|
1380
|
+
ctx: z.RefinementCtx
|
|
1381
|
+
): void {
|
|
1382
|
+
const seenIndexByPositionName = new Map<string, number>();
|
|
1383
|
+
placements.forEach((placement, index) => {
|
|
1384
|
+
const positionName = placement.positionName;
|
|
1385
|
+
if (positionName === undefined) {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
const firstIndex = seenIndexByPositionName.get(positionName);
|
|
1389
|
+
if (firstIndex !== undefined) {
|
|
1390
|
+
ctx.addIssue({
|
|
1391
|
+
code: z.ZodIssueCode.custom,
|
|
1392
|
+
path: ['placements', index, 'positionName'],
|
|
1393
|
+
message:
|
|
1394
|
+
`Duplicate positionName "${positionName}" in one canvas write. Each physical cabinet must have exactly ` +
|
|
1395
|
+
'one placement per canvas; duplicates render as stacked/doubled boxes. Use one placement per positionName ' +
|
|
1396
|
+
'on this canvas and reuse the same positionName on other canvases instead.',
|
|
1397
|
+
});
|
|
1398
|
+
} else {
|
|
1399
|
+
seenIndexByPositionName.set(positionName, index);
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1095
1404
|
function validatePlacementOperationForCanvas(
|
|
1096
1405
|
canvasType: string,
|
|
1097
1406
|
placements: Array<{
|
|
1407
|
+
positionName?: string;
|
|
1098
1408
|
centerX?: number;
|
|
1099
1409
|
centerY?: number;
|
|
1100
1410
|
width?: number;
|
|
@@ -1105,6 +1415,7 @@ function validatePlacementOperationForCanvas(
|
|
|
1105
1415
|
ctx: z.RefinementCtx
|
|
1106
1416
|
): void {
|
|
1107
1417
|
validatePlacementRotationsForCanvas(canvasType, placements, ctx);
|
|
1418
|
+
validateUniquePositionNamesForCanvas(placements, ctx);
|
|
1108
1419
|
}
|
|
1109
1420
|
|
|
1110
1421
|
export const AiCabinetCoordinateInputSchema = z.object({
|
|
@@ -1164,6 +1475,8 @@ export type AiCanvasCabinetPlacementsReplaceInput = z.infer<typeof AiCanvasCabin
|
|
|
1164
1475
|
export type AiCanvasCabinetPlacementsUpsertInput = z.infer<typeof AiCanvasCabinetPlacementsUpsertInputSchema>;
|
|
1165
1476
|
export type AiCanvasCabinetPlacementsPatchInput = z.infer<typeof AiCanvasCabinetPlacementsPatchInputSchema>;
|
|
1166
1477
|
export type AiCanvasCabinetPlacementsDeleteInput = z.infer<typeof AiCanvasCabinetPlacementsDeleteInputSchema>;
|
|
1478
|
+
export type ProjectAiCanvasCabinetPlacementsInput = z.infer<typeof ProjectAiCanvasCabinetPlacementsInputSchema>;
|
|
1479
|
+
export type ProjectAiCanvasElevationRunInput = z.infer<typeof ProjectAiCanvasElevationRunInputSchema>;
|
|
1167
1480
|
export type AiCabinetCoordinateInput = z.infer<typeof AiCabinetCoordinateInputSchema>;
|
|
1168
1481
|
export type AiCabinetCoordinatePatchInput = z.infer<typeof AiCabinetCoordinatePatchInputSchema>;
|
|
1169
1482
|
export type AiCabinetCoordinatesReplaceInput = z.infer<typeof AiCabinetCoordinatesReplaceInputSchema>;
|
|
@@ -1197,42 +1510,42 @@ export async function renderAiDrawingSessionReviewTool(input: AiDrawingSessionRe
|
|
|
1197
1510
|
renderOptions.imagePathByCanvas = imagePathByCanvas;
|
|
1198
1511
|
}
|
|
1199
1512
|
|
|
1200
|
-
const manifest = renderAiDrawingSessionReview(data, renderOptions);
|
|
1201
|
-
const sourceBoundaryReviewTasks = manifest.canvases.flatMap((canvas) => canvas.sourceBoundaryReviewTasks);
|
|
1202
|
-
const reviewImages = manifest.canvases
|
|
1203
|
-
.filter((canvas) => Boolean(canvas.outputImagePath))
|
|
1204
|
-
.map((canvas) => ({
|
|
1513
|
+
const manifest = renderAiDrawingSessionReview(data, renderOptions);
|
|
1514
|
+
const sourceBoundaryReviewTasks = manifest.canvases.flatMap((canvas) => canvas.sourceBoundaryReviewTasks);
|
|
1515
|
+
const reviewImages = manifest.canvases
|
|
1516
|
+
.filter((canvas) => Boolean(canvas.outputImagePath))
|
|
1517
|
+
.map((canvas) => ({
|
|
1205
1518
|
canvasType: canvas.canvasType,
|
|
1206
|
-
outputImagePath: canvas.outputImagePath,
|
|
1207
|
-
imageWidth: canvas.imageWidth,
|
|
1208
|
-
imageHeight: canvas.imageHeight,
|
|
1209
|
-
renderedPlacementCount: canvas.renderedPlacementCount,
|
|
1210
|
-
sourceBoundaryReviewTaskCount: canvas.sourceBoundaryReviewTasks.length,
|
|
1211
|
-
issueCount: canvas.issues.length,
|
|
1212
|
-
}));
|
|
1213
|
-
|
|
1214
|
-
return JSON.stringify({
|
|
1519
|
+
outputImagePath: canvas.outputImagePath,
|
|
1520
|
+
imageWidth: canvas.imageWidth,
|
|
1521
|
+
imageHeight: canvas.imageHeight,
|
|
1522
|
+
renderedPlacementCount: canvas.renderedPlacementCount,
|
|
1523
|
+
sourceBoundaryReviewTaskCount: canvas.sourceBoundaryReviewTasks.length,
|
|
1524
|
+
issueCount: canvas.issues.length,
|
|
1525
|
+
}));
|
|
1526
|
+
|
|
1527
|
+
return JSON.stringify({
|
|
1215
1528
|
sessionId: input.sessionId,
|
|
1216
1529
|
outputDir: input.outputDir,
|
|
1217
1530
|
visualReviewRequired: true,
|
|
1218
1531
|
visualReviewStatus: manifest.visualReviewStatus,
|
|
1219
1532
|
completionBlockedUntil: manifest.completionBlockedUntil,
|
|
1220
|
-
machineValidationScope:
|
|
1221
|
-
'Renderer issues include deterministic structural checks plus blocking source_boundary_review_required tasks. aggregateIssueCount 0 is not visual approval and does not prove cabinet boundaries are correct. sourceBoundaryReviewTasks must be checked against the full source image with native vision.',
|
|
1222
|
-
completionPolicy:
|
|
1223
|
-
'Open every reviewImages[].outputImagePath with native vision. Resolve every source_boundary_review_required issue by completing every sourceBoundaryReviewTasks[] item against the visible source drawing pixels, not generated bbox relationships. Record PASS/FAIL/UNCERTAIN for each canvas and placement. PASS requires explicit LEFT/RIGHT/TOP/BOTTOM source-linework evidence using each task passRequiresEvidenceFormat; if any side cannot be named from visible drawing geometry, mark FAIL or UNCERTAIN. Fix any overlap, shifted box, missing placement, wrong object, wrong canvas, wrong-surface placement, wrong plan footprint, wrong cabinet face, wrong plinth/toekick, wrong floor/Z0 boundary, or wrong countertop boundary, rerender, and only then report completion or document unresolved issues.',
|
|
1224
|
-
visualQaChecklist: manifest.visualInspectionChecklist,
|
|
1225
|
-
reviewImages,
|
|
1226
|
-
sourceBoundaryReviewTasks,
|
|
1227
|
-
canvases: manifest.canvases.map((canvas) => ({
|
|
1228
|
-
canvasType: canvas.canvasType,
|
|
1229
|
-
outputImagePath: canvas.outputImagePath,
|
|
1230
|
-
imageWidth: canvas.imageWidth,
|
|
1231
|
-
imageHeight: canvas.imageHeight,
|
|
1232
|
-
renderedPlacementCount: canvas.renderedPlacementCount,
|
|
1233
|
-
sourceBoundaryReviewTasks: canvas.sourceBoundaryReviewTasks,
|
|
1234
|
-
issues: canvas.issues,
|
|
1235
|
-
})),
|
|
1533
|
+
machineValidationScope:
|
|
1534
|
+
'Renderer issues include deterministic structural checks plus blocking source_boundary_review_required tasks. aggregateIssueCount 0 is not visual approval and does not prove cabinet boundaries are correct. sourceBoundaryReviewTasks must be checked against the full source image with native vision.',
|
|
1535
|
+
completionPolicy:
|
|
1536
|
+
'Open every reviewImages[].outputImagePath with native vision. Resolve every source_boundary_review_required issue by completing every sourceBoundaryReviewTasks[] item against the visible source drawing pixels, not generated bbox relationships. Record PASS/FAIL/UNCERTAIN for each canvas and placement. PASS requires explicit LEFT/RIGHT/TOP/BOTTOM source-linework evidence using each task passRequiresEvidenceFormat; if any side cannot be named from visible drawing geometry, mark FAIL or UNCERTAIN. Fix any overlap, shifted box, missing placement, wrong object, wrong canvas, wrong-surface placement, wrong plan footprint, wrong cabinet face, wrong plinth/toekick, wrong floor/Z0 boundary, or wrong countertop boundary, rerender, and only then report completion or document unresolved issues.',
|
|
1537
|
+
visualQaChecklist: manifest.visualInspectionChecklist,
|
|
1538
|
+
reviewImages,
|
|
1539
|
+
sourceBoundaryReviewTasks,
|
|
1540
|
+
canvases: manifest.canvases.map((canvas) => ({
|
|
1541
|
+
canvasType: canvas.canvasType,
|
|
1542
|
+
outputImagePath: canvas.outputImagePath,
|
|
1543
|
+
imageWidth: canvas.imageWidth,
|
|
1544
|
+
imageHeight: canvas.imageHeight,
|
|
1545
|
+
renderedPlacementCount: canvas.renderedPlacementCount,
|
|
1546
|
+
sourceBoundaryReviewTasks: canvas.sourceBoundaryReviewTasks,
|
|
1547
|
+
issues: canvas.issues,
|
|
1548
|
+
})),
|
|
1236
1549
|
perCanvasIssues: Object.fromEntries(
|
|
1237
1550
|
manifest.canvases.map((canvas) => [canvas.canvasType, canvas.issues])
|
|
1238
1551
|
),
|
|
@@ -1541,6 +1854,386 @@ export async function deleteAiCanvasCabinetPlacements(input: AiCanvasCabinetPlac
|
|
|
1541
1854
|
}
|
|
1542
1855
|
}
|
|
1543
1856
|
|
|
1857
|
+
async function fetchActiveAiCartItems(
|
|
1858
|
+
sessionId: string
|
|
1859
|
+
): Promise<{ items: CartItemLike[] } | { error: string }> {
|
|
1860
|
+
try {
|
|
1861
|
+
const { data } = await aiDrawingClient.get(`/sessions/${sessionId}/state`);
|
|
1862
|
+
const items = (data as any)?.activeCart?.items;
|
|
1863
|
+
if (!Array.isArray(items)) {
|
|
1864
|
+
return {
|
|
1865
|
+
error:
|
|
1866
|
+
'Could not read an active AI cart for this session, so placements cannot be cross-checked against the ' +
|
|
1867
|
+
'manifest. Create the AI cart, link it to the session, and sync items first (create_ai_cart / ' +
|
|
1868
|
+
'link_ai_cart_to_drawing_session / sync_ai_cart_snapshot), or rerun with crossCheckCartItems false for a dry run.',
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
return { items: items as CartItemLike[] };
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
try { handleAxiosError(error); } catch (e: any) { return { error: e.message }; }
|
|
1874
|
+
return { error: 'Unexpected error reading AI drawing session state for cart cross-check.' };
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function buildCrossCheckBlockedResult(
|
|
1879
|
+
sessionId: string,
|
|
1880
|
+
canvasType: string,
|
|
1881
|
+
result: CartCrossCheckResult,
|
|
1882
|
+
extra?: Record<string, unknown>
|
|
1883
|
+
): string {
|
|
1884
|
+
return JSON.stringify(
|
|
1885
|
+
{
|
|
1886
|
+
sessionId,
|
|
1887
|
+
canvasType,
|
|
1888
|
+
wrote: false,
|
|
1889
|
+
crossCheckBlocked: true,
|
|
1890
|
+
matchedCartItemCount: result.matchedCount,
|
|
1891
|
+
cartCrossCheckBlocking: result.blocking,
|
|
1892
|
+
cartCrossCheckWarnings: result.warnings,
|
|
1893
|
+
...extra,
|
|
1894
|
+
note:
|
|
1895
|
+
'Write blocked: placements did not reconcile with the active AI cart manifest. Each placement must match a ' +
|
|
1896
|
+
'real cart item and its real-world dimensions. Fix the cabinet count/widths (or the cart items) and rerun. ' +
|
|
1897
|
+
'This guarantees a run cannot hold more cabinets than there are real items of the right size.',
|
|
1898
|
+
},
|
|
1899
|
+
null,
|
|
1900
|
+
2
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
export async function projectAiCanvasCabinetPlacements(
|
|
1905
|
+
input: ProjectAiCanvasCabinetPlacementsInput
|
|
1906
|
+
): Promise<string> {
|
|
1907
|
+
const isPlan = input.canvasType === 'plan';
|
|
1908
|
+
const imageSizePx = isPlan
|
|
1909
|
+
? input.planCalibration!.imageSizePx
|
|
1910
|
+
: input.elevationCalibration!.imageSizePx;
|
|
1911
|
+
|
|
1912
|
+
const elevationCalibration: ElevationCalibration | undefined = isPlan
|
|
1913
|
+
? undefined
|
|
1914
|
+
: {
|
|
1915
|
+
canvasType: input.elevationCalibration!.canvasType,
|
|
1916
|
+
imageSizePx: input.elevationCalibration!.imageSizePx,
|
|
1917
|
+
horizontalPixelsPerInch: input.elevationCalibration!.horizontalPixelsPerInch,
|
|
1918
|
+
verticalPixelsPerInch: input.elevationCalibration!.verticalPixelsPerInch,
|
|
1919
|
+
floorZ0Px: input.elevationCalibration!.floorZ0Px,
|
|
1920
|
+
horizontalOriginPx: input.elevationCalibration!.horizontalOriginPx,
|
|
1921
|
+
horizontalOriginCoordinateInches: input.elevationCalibration!.horizontalOriginCoordinateInches,
|
|
1922
|
+
horizontalAxisPositiveDirection: input.elevationCalibration!.horizontalAxisPositiveDirection,
|
|
1923
|
+
verticalAxisPositiveDirection: input.elevationCalibration!.verticalAxisPositiveDirection,
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
const planCalibration: PlanCalibration | undefined = isPlan
|
|
1927
|
+
? {
|
|
1928
|
+
canvasType: 'plan',
|
|
1929
|
+
imageSizePx: input.planCalibration!.imageSizePx,
|
|
1930
|
+
pixelsPerInch: input.planCalibration!.pixelsPerInch,
|
|
1931
|
+
roomOriginPx: input.planCalibration!.roomOriginPx,
|
|
1932
|
+
xPositiveDirection: input.planCalibration!.xPositiveDirection,
|
|
1933
|
+
yPositiveDirection: input.planCalibration!.yPositiveDirection,
|
|
1934
|
+
}
|
|
1935
|
+
: undefined;
|
|
1936
|
+
|
|
1937
|
+
const outOfBounds: Array<{ positionName: string; reason: string }> = [];
|
|
1938
|
+
const projectedPlacements: AiCanvasCabinetPlacementInput[] = input.placements.map((spec) => {
|
|
1939
|
+
const isWallAnchored = isPlan && spec.wallBackEdgeInches !== undefined;
|
|
1940
|
+
const projected = isWallAnchored
|
|
1941
|
+
? projectPlanWallAnchoredPlacement(
|
|
1942
|
+
{
|
|
1943
|
+
wallBackEdgeInches: spec.wallBackEdgeInches!,
|
|
1944
|
+
depthAxis: spec.depthAxis!,
|
|
1945
|
+
depthDirection: spec.depthDirection!,
|
|
1946
|
+
alongWallStartInches: spec.alongWallStartInches!,
|
|
1947
|
+
widthInches: spec.widthInches,
|
|
1948
|
+
depthInches: spec.depthInches!,
|
|
1949
|
+
},
|
|
1950
|
+
planCalibration!
|
|
1951
|
+
)
|
|
1952
|
+
: isPlan
|
|
1953
|
+
? projectPlanPlacement(
|
|
1954
|
+
{
|
|
1955
|
+
centerXInches: spec.centerXInches!,
|
|
1956
|
+
centerYInches: spec.centerYInches!,
|
|
1957
|
+
widthInches: spec.widthInches,
|
|
1958
|
+
depthInches: spec.depthInches!,
|
|
1959
|
+
rotation: spec.rotation,
|
|
1960
|
+
},
|
|
1961
|
+
planCalibration!
|
|
1962
|
+
)
|
|
1963
|
+
: projectElevationPlacement(
|
|
1964
|
+
{
|
|
1965
|
+
horizontalStartInches: spec.horizontalStartInches!,
|
|
1966
|
+
widthInches: spec.widthInches,
|
|
1967
|
+
heightInches: spec.heightInches!,
|
|
1968
|
+
bottomFromFloorInches: spec.bottomFromFloorInches,
|
|
1969
|
+
},
|
|
1970
|
+
elevationCalibration!
|
|
1971
|
+
);
|
|
1972
|
+
|
|
1973
|
+
if (isProjectedPlacementOutsideImage(projected, imageSizePx)) {
|
|
1974
|
+
outOfBounds.push({
|
|
1975
|
+
positionName: spec.positionName,
|
|
1976
|
+
reason:
|
|
1977
|
+
`Projected rectangle (centerX ${projected.centerX}, centerY ${projected.centerY}, width ${projected.width}, ` +
|
|
1978
|
+
`height ${projected.height}) extends outside the ${imageSizePx.width}x${imageSizePx.height} calibration image. ` +
|
|
1979
|
+
'Re-check the calibration (pixels-per-inch, origin, floor line) or the cabinet inch dimensions against the source drawing.',
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
return {
|
|
1984
|
+
aiCartItemId: spec.aiCartItemId,
|
|
1985
|
+
positionName: spec.positionName,
|
|
1986
|
+
centerX: projected.centerX,
|
|
1987
|
+
centerY: projected.centerY,
|
|
1988
|
+
width: projected.width,
|
|
1989
|
+
height: projected.height,
|
|
1990
|
+
rotation: projected.rotation,
|
|
1991
|
+
placementSpace: 'IMAGE_PIXELS' as const,
|
|
1992
|
+
basisImageWidthPx: imageSizePx.width,
|
|
1993
|
+
basisImageHeightPx: imageSizePx.height,
|
|
1994
|
+
source: input.source,
|
|
1995
|
+
};
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
let crossCheckExtra: Record<string, unknown> | undefined;
|
|
1999
|
+
if (input.crossCheckCartItems) {
|
|
2000
|
+
const cart = await fetchActiveAiCartItems(input.sessionId);
|
|
2001
|
+
if ('error' in cart) {
|
|
2002
|
+
return cart.error;
|
|
2003
|
+
}
|
|
2004
|
+
const checkSpecs: PlacementCheckSpec[] = input.placements.map((spec) => ({
|
|
2005
|
+
positionName: spec.positionName,
|
|
2006
|
+
aiCartItemId: spec.aiCartItemId,
|
|
2007
|
+
widthInches: spec.widthInches,
|
|
2008
|
+
heightInches: isPlan ? undefined : spec.heightInches,
|
|
2009
|
+
depthInches: isPlan ? spec.depthInches : undefined,
|
|
2010
|
+
}));
|
|
2011
|
+
const result = crossCheckPlacementsAgainstCart(checkSpecs, cart.items, {
|
|
2012
|
+
widthToleranceInches: input.widthToleranceInches,
|
|
2013
|
+
heightToleranceInches: input.heightToleranceInches,
|
|
2014
|
+
depthToleranceInches: input.heightToleranceInches,
|
|
2015
|
+
reportCoverage: input.writeMode === 'replace',
|
|
2016
|
+
});
|
|
2017
|
+
if (result.blocking.length > 0) {
|
|
2018
|
+
return buildCrossCheckBlockedResult(input.sessionId, input.canvasType, result);
|
|
2019
|
+
}
|
|
2020
|
+
crossCheckExtra = {
|
|
2021
|
+
matchedCartItemCount: result.matchedCount,
|
|
2022
|
+
cartCrossCheckWarnings: result.warnings,
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
return commitProjectedPlacements({
|
|
2027
|
+
sessionId: input.sessionId,
|
|
2028
|
+
canvasType: input.canvasType,
|
|
2029
|
+
writeMode: input.writeMode,
|
|
2030
|
+
imageSizePx,
|
|
2031
|
+
projectedPlacements,
|
|
2032
|
+
outOfBounds,
|
|
2033
|
+
extra: crossCheckExtra,
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
async function commitProjectedPlacements(params: {
|
|
2038
|
+
sessionId: string;
|
|
2039
|
+
canvasType: string;
|
|
2040
|
+
writeMode: 'replace' | 'upsert' | 'return_only';
|
|
2041
|
+
imageSizePx: { width: number; height: number };
|
|
2042
|
+
projectedPlacements: AiCanvasCabinetPlacementInput[];
|
|
2043
|
+
outOfBounds: Array<{ positionName: string; reason: string }>;
|
|
2044
|
+
extra?: Record<string, unknown>;
|
|
2045
|
+
}): Promise<string> {
|
|
2046
|
+
const { sessionId, canvasType, writeMode, imageSizePx, projectedPlacements, outOfBounds, extra } = params;
|
|
2047
|
+
|
|
2048
|
+
if (writeMode === 'return_only') {
|
|
2049
|
+
return JSON.stringify(
|
|
2050
|
+
{
|
|
2051
|
+
sessionId,
|
|
2052
|
+
canvasType,
|
|
2053
|
+
writeMode,
|
|
2054
|
+
wrote: false,
|
|
2055
|
+
basisImageWidthPx: imageSizePx.width,
|
|
2056
|
+
basisImageHeightPx: imageSizePx.height,
|
|
2057
|
+
projectedPlacements: projectedPlacements.map(toBackendPlacementPayload),
|
|
2058
|
+
outOfBoundsWarnings: outOfBounds,
|
|
2059
|
+
...extra,
|
|
2060
|
+
note:
|
|
2061
|
+
'return_only dry run: nothing was written. Inspect projectedPlacements, outOfBoundsWarnings, and any ' +
|
|
2062
|
+
'segmentationWarnings, then rerun with writeMode upsert or replace to persist. After writing, call ' +
|
|
2063
|
+
'render_ai_drawing_session_review and verify against the source image.',
|
|
2064
|
+
},
|
|
2065
|
+
null,
|
|
2066
|
+
2
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
try {
|
|
2071
|
+
const path = `/sessions/${sessionId}/placements/${canvasType}`;
|
|
2072
|
+
const body = { placements: projectedPlacements.map(toBackendPlacementPayload) };
|
|
2073
|
+
const { data } =
|
|
2074
|
+
writeMode === 'replace' ? await aiDrawingClient.put(path, body) : await aiDrawingClient.post(path, body);
|
|
2075
|
+
return JSON.stringify(
|
|
2076
|
+
{
|
|
2077
|
+
sessionId,
|
|
2078
|
+
canvasType,
|
|
2079
|
+
writeMode,
|
|
2080
|
+
wrote: true,
|
|
2081
|
+
basisImageWidthPx: imageSizePx.width,
|
|
2082
|
+
basisImageHeightPx: imageSizePx.height,
|
|
2083
|
+
projectedPlacementCount: projectedPlacements.length,
|
|
2084
|
+
outOfBoundsWarnings: outOfBounds,
|
|
2085
|
+
...extra,
|
|
2086
|
+
backendResponse: data,
|
|
2087
|
+
note:
|
|
2088
|
+
'Placements were projected from calibration and written. Resolve any segmentationWarnings, then call ' +
|
|
2089
|
+
'render_ai_drawing_session_review and verify each rectangle against the visible source linework before reporting completion.',
|
|
2090
|
+
},
|
|
2091
|
+
null,
|
|
2092
|
+
2
|
|
2093
|
+
);
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
2096
|
+
return 'Unexpected error projecting and writing AI canvas cabinet placements.';
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
export async function projectAiCanvasElevationRun(
|
|
2101
|
+
input: ProjectAiCanvasElevationRunInput
|
|
2102
|
+
): Promise<string> {
|
|
2103
|
+
const cal = input.elevationCalibration;
|
|
2104
|
+
const imageSizePx = cal.imageSizePx;
|
|
2105
|
+
const elevationCalibration: ElevationCalibration = {
|
|
2106
|
+
canvasType: cal.canvasType,
|
|
2107
|
+
imageSizePx: cal.imageSizePx,
|
|
2108
|
+
horizontalPixelsPerInch: cal.horizontalPixelsPerInch,
|
|
2109
|
+
verticalPixelsPerInch: cal.verticalPixelsPerInch,
|
|
2110
|
+
floorZ0Px: cal.floorZ0Px,
|
|
2111
|
+
horizontalOriginPx: cal.horizontalOriginPx,
|
|
2112
|
+
horizontalOriginCoordinateInches: cal.horizontalOriginCoordinateInches,
|
|
2113
|
+
horizontalAxisPositiveDirection: cal.horizontalAxisPositiveDirection,
|
|
2114
|
+
verticalAxisPositiveDirection: cal.verticalAxisPositiveDirection,
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
// Decompose each run into tiled modules — one placement per physical cabinet.
|
|
2118
|
+
const segmentationWarnings: string[] = [];
|
|
2119
|
+
const segmentedModules: Array<{
|
|
2120
|
+
positionName: string;
|
|
2121
|
+
aiCartItemId?: string;
|
|
2122
|
+
horizontalStartInches: number;
|
|
2123
|
+
widthInches: number;
|
|
2124
|
+
heightInches: number;
|
|
2125
|
+
bottomFromFloorInches?: number;
|
|
2126
|
+
frontType: string;
|
|
2127
|
+
runIndex: number;
|
|
2128
|
+
}> = [];
|
|
2129
|
+
|
|
2130
|
+
input.runs.forEach((run, runIndex) => {
|
|
2131
|
+
const result = segmentElevationRun({
|
|
2132
|
+
runStartInches: run.runStartInches,
|
|
2133
|
+
modules: run.modules,
|
|
2134
|
+
expectedRunTotalInches: run.expectedRunTotalInches,
|
|
2135
|
+
declaredSubdivisionCount: run.declaredSubdivisionCount,
|
|
2136
|
+
revealBetweenModulesInches: run.revealBetweenModulesInches,
|
|
2137
|
+
maxSingleCabinetWidthInches: run.maxSingleCabinetWidthInches,
|
|
2138
|
+
});
|
|
2139
|
+
for (const warning of result.warnings) {
|
|
2140
|
+
segmentationWarnings.push(`run[${runIndex}]: ${warning}`);
|
|
2141
|
+
}
|
|
2142
|
+
for (const m of result.modules) {
|
|
2143
|
+
segmentedModules.push({
|
|
2144
|
+
positionName: m.positionName,
|
|
2145
|
+
aiCartItemId: m.aiCartItemId,
|
|
2146
|
+
horizontalStartInches: m.horizontalStartInches,
|
|
2147
|
+
widthInches: m.widthInches,
|
|
2148
|
+
heightInches: m.heightInches,
|
|
2149
|
+
bottomFromFloorInches: m.bottomFromFloorInches,
|
|
2150
|
+
frontType: m.frontType,
|
|
2151
|
+
runIndex,
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
const outOfBounds: Array<{ positionName: string; reason: string }> = [];
|
|
2157
|
+
const projectedPlacements: AiCanvasCabinetPlacementInput[] = segmentedModules.map((m) => {
|
|
2158
|
+
const projected = projectElevationPlacement(
|
|
2159
|
+
{
|
|
2160
|
+
horizontalStartInches: m.horizontalStartInches,
|
|
2161
|
+
widthInches: m.widthInches,
|
|
2162
|
+
heightInches: m.heightInches,
|
|
2163
|
+
bottomFromFloorInches: m.bottomFromFloorInches,
|
|
2164
|
+
},
|
|
2165
|
+
elevationCalibration
|
|
2166
|
+
);
|
|
2167
|
+
if (isProjectedPlacementOutsideImage(projected, imageSizePx)) {
|
|
2168
|
+
outOfBounds.push({
|
|
2169
|
+
positionName: m.positionName,
|
|
2170
|
+
reason:
|
|
2171
|
+
`Projected rectangle for ${m.positionName} extends outside the ${imageSizePx.width}x${imageSizePx.height} ` +
|
|
2172
|
+
'calibration image. Re-check the calibration or this module\'s width/height/start against the source drawing.',
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
return {
|
|
2176
|
+
aiCartItemId: m.aiCartItemId,
|
|
2177
|
+
positionName: m.positionName,
|
|
2178
|
+
centerX: projected.centerX,
|
|
2179
|
+
centerY: projected.centerY,
|
|
2180
|
+
width: projected.width,
|
|
2181
|
+
height: projected.height,
|
|
2182
|
+
rotation: 0,
|
|
2183
|
+
placementSpace: 'IMAGE_PIXELS' as const,
|
|
2184
|
+
basisImageWidthPx: imageSizePx.width,
|
|
2185
|
+
basisImageHeightPx: imageSizePx.height,
|
|
2186
|
+
source: input.source,
|
|
2187
|
+
};
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
const segmentationExtra: Record<string, unknown> = {
|
|
2191
|
+
moduleCount: segmentedModules.length,
|
|
2192
|
+
runCount: input.runs.length,
|
|
2193
|
+
segmentationWarnings,
|
|
2194
|
+
moduleSummary: segmentedModules.map((m) => ({
|
|
2195
|
+
positionName: m.positionName,
|
|
2196
|
+
runIndex: m.runIndex,
|
|
2197
|
+
frontType: m.frontType,
|
|
2198
|
+
widthInches: m.widthInches,
|
|
2199
|
+
horizontalStartInches: m.horizontalStartInches,
|
|
2200
|
+
})),
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
if (input.crossCheckCartItems) {
|
|
2204
|
+
const cart = await fetchActiveAiCartItems(input.sessionId);
|
|
2205
|
+
if ('error' in cart) {
|
|
2206
|
+
return cart.error;
|
|
2207
|
+
}
|
|
2208
|
+
const checkSpecs: PlacementCheckSpec[] = segmentedModules.map((m) => ({
|
|
2209
|
+
positionName: m.positionName,
|
|
2210
|
+
aiCartItemId: m.aiCartItemId,
|
|
2211
|
+
widthInches: m.widthInches,
|
|
2212
|
+
heightInches: m.heightInches,
|
|
2213
|
+
}));
|
|
2214
|
+
const result = crossCheckPlacementsAgainstCart(checkSpecs, cart.items, {
|
|
2215
|
+
widthToleranceInches: input.widthToleranceInches,
|
|
2216
|
+
heightToleranceInches: input.heightToleranceInches,
|
|
2217
|
+
reportCoverage: input.writeMode === 'replace',
|
|
2218
|
+
});
|
|
2219
|
+
if (result.blocking.length > 0) {
|
|
2220
|
+
return buildCrossCheckBlockedResult(input.sessionId, input.canvasType, result, segmentationExtra);
|
|
2221
|
+
}
|
|
2222
|
+
segmentationExtra.matchedCartItemCount = result.matchedCount;
|
|
2223
|
+
segmentationExtra.cartCrossCheckWarnings = result.warnings;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
return commitProjectedPlacements({
|
|
2227
|
+
sessionId: input.sessionId,
|
|
2228
|
+
canvasType: input.canvasType,
|
|
2229
|
+
writeMode: input.writeMode,
|
|
2230
|
+
imageSizePx,
|
|
2231
|
+
projectedPlacements,
|
|
2232
|
+
outOfBounds,
|
|
2233
|
+
extra: segmentationExtra,
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
|
|
1544
2237
|
export async function replaceAiCabinetCoordinates(input: AiCabinetCoordinatesReplaceInput): Promise<string> {
|
|
1545
2238
|
try {
|
|
1546
2239
|
const { data } = await aiDrawingClient.put(`/sessions/${input.sessionId}/coordinates`, {
|
|
@@ -2291,6 +2984,27 @@ export const aiDrawingTools = [
|
|
|
2291
2984
|
inputSchema: AiCanvasCabinetPlacementsDeleteInputSchema,
|
|
2292
2985
|
handler: deleteAiCanvasCabinetPlacements,
|
|
2293
2986
|
},
|
|
2987
|
+
{
|
|
2988
|
+
name: 'project_ai_canvas_cabinet_placements',
|
|
2989
|
+
description:
|
|
2990
|
+
'Compute and write exact backend-owned AIDrawingTool per-canvas cabinet placement rectangles from a ' +
|
|
2991
|
+
'pixels-per-inch calibration plus real cabinet inch dimensions. Prefer this over hand-writing centerX/centerY/' +
|
|
2992
|
+
'width/height placement boxes: it removes pixel-guessing error and keeps a whole run of cabinets aligned and ' +
|
|
2993
|
+
'gap-correct from one shared scale. ' +
|
|
2994
|
+
AI_PLACEMENT_PROJECTION_GUIDANCE,
|
|
2995
|
+
inputSchema: ProjectAiCanvasCabinetPlacementsInputSchema,
|
|
2996
|
+
handler: projectAiCanvasCabinetPlacements,
|
|
2997
|
+
},
|
|
2998
|
+
{
|
|
2999
|
+
name: 'project_ai_canvas_elevation_run',
|
|
3000
|
+
description:
|
|
3001
|
+
'Decompose dimensioned elevation runs into individual cabinets and write one placement rectangle per cabinet. ' +
|
|
3002
|
+
'Use this whenever an elevation run could contain more than one cabinet (almost always): it forces you to ' +
|
|
3003
|
+
'enumerate each cabinet with its width and front type so a multi-cabinet run is never collapsed into one box. ' +
|
|
3004
|
+
AI_ELEVATION_RUN_SEGMENTATION_GUIDANCE,
|
|
3005
|
+
inputSchema: ProjectAiCanvasElevationRunInputSchema,
|
|
3006
|
+
handler: projectAiCanvasElevationRun,
|
|
3007
|
+
},
|
|
2294
3008
|
{
|
|
2295
3009
|
name: 'replace_ai_cabinet_coordinates',
|
|
2296
3010
|
description:
|