@sealab/mcp-server 1.0.1 → 1.0.3
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/client/api-client.js +8 -1
- package/dist/index.js +6 -2
- package/dist/schema-normalizer.js +74 -0
- package/dist/tools/ai-drawing-overlay.js +4 -0
- package/dist/tools/ai-drawing-session-review-renderer.js +964 -0
- package/dist/tools/ai-drawing.js +2080 -0
- package/dist/tools/orders.js +4 -4
- package/dist/tools/permissions.js +180 -0
- package/package.json +5 -3
- package/src/client/api-client.ts +15 -7
- package/src/index.ts +6 -2
- package/src/schema-normalizer.test.ts +107 -0
- package/src/schema-normalizer.ts +86 -0
- package/src/tools/ai-drawing-overlay.test.ts +9 -0
- package/src/tools/ai-drawing-overlay.ts +8 -0
- package/src/tools/ai-drawing-session-review-renderer.test.ts +516 -0
- package/src/tools/ai-drawing-session-review-renderer.ts +1297 -0
- package/src/tools/ai-drawing.test.ts +3061 -0
- package/src/tools/ai-drawing.ts +2445 -0
- package/src/tools/orders.ts +1 -1
- package/src/tools/permissions.ts +169 -0
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { PNG } from 'pngjs';
|
|
4
|
+
|
|
5
|
+
export type AiDrawingReviewCanvasType = 'plan' | 'north' | 'south' | 'east' | 'west';
|
|
6
|
+
|
|
7
|
+
export type AiDrawingSessionReviewIssueCode =
|
|
8
|
+
| 'missing_background_image'
|
|
9
|
+
| 'unsupported_background_image_reference'
|
|
10
|
+
| 'background_image_read_failed'
|
|
11
|
+
| 'placement_outside_image_bounds'
|
|
12
|
+
| 'missing_or_invalid_basis_image_dimensions'
|
|
13
|
+
| 'invalid_placement_dimensions'
|
|
14
|
+
| 'non_image_pixels_placement_space'
|
|
15
|
+
| 'placement_without_matching_cart_item'
|
|
16
|
+
| 'cart_item_without_placement'
|
|
17
|
+
| 'duplicate_position_name_on_canvas'
|
|
18
|
+
| 'placement_overlap'
|
|
19
|
+
| 'placement_category_conflict'
|
|
20
|
+
| 'base_elevation_without_plinth_placement'
|
|
21
|
+
| 'source_boundary_review_required'
|
|
22
|
+
| 'canvas_with_no_placements';
|
|
23
|
+
|
|
24
|
+
export interface AiDrawingSessionReviewIssue {
|
|
25
|
+
code: AiDrawingSessionReviewIssueCode;
|
|
26
|
+
severity: 'warning' | 'error';
|
|
27
|
+
message: string;
|
|
28
|
+
canvasType?: AiDrawingReviewCanvasType;
|
|
29
|
+
positionName?: string;
|
|
30
|
+
relatedPositionName?: string;
|
|
31
|
+
aiCartItemId?: string;
|
|
32
|
+
imageReference?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AiDrawingSessionReviewCanvasResult {
|
|
36
|
+
canvasType: AiDrawingReviewCanvasType;
|
|
37
|
+
outputImagePath?: string;
|
|
38
|
+
imageWidth?: number;
|
|
39
|
+
imageHeight?: number;
|
|
40
|
+
renderedPlacementCount: number;
|
|
41
|
+
sourceBoundaryReviewTasks: AiDrawingSourceBoundaryReviewTask[];
|
|
42
|
+
issues: AiDrawingSessionReviewIssue[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AiDrawingSessionReviewManifest {
|
|
46
|
+
sessionId?: string;
|
|
47
|
+
generatedAt: string;
|
|
48
|
+
visualInspectionRequired: true;
|
|
49
|
+
visualReviewStatus: 'requires_native_vision';
|
|
50
|
+
completionBlockedUntil: string;
|
|
51
|
+
visualInspectionChecklist: string[];
|
|
52
|
+
canvases: AiDrawingSessionReviewCanvasResult[];
|
|
53
|
+
issues: AiDrawingSessionReviewIssue[];
|
|
54
|
+
manifestPath?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AiDrawingSourceBoundaryReviewTask {
|
|
58
|
+
canvasType: AiDrawingReviewCanvasType;
|
|
59
|
+
positionName: string;
|
|
60
|
+
aiCartItemId?: string;
|
|
61
|
+
placementCategory: 'plan_footprint' | 'cabinet_face' | 'base_cabinet_body' | 'plinth_toekick';
|
|
62
|
+
sourceImageAuthority: true;
|
|
63
|
+
passRequiresEvidenceFormat: string[];
|
|
64
|
+
failIf: string[];
|
|
65
|
+
checklist: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AiDrawingSessionReviewRenderOptions {
|
|
69
|
+
outputDir: string;
|
|
70
|
+
canvases?: AiDrawingReviewCanvasType[];
|
|
71
|
+
imagePathByCanvas?: Partial<Record<AiDrawingReviewCanvasType, string>>;
|
|
72
|
+
localArtifactRoots?: string[];
|
|
73
|
+
writeJsonManifest?: boolean;
|
|
74
|
+
manifestFileName?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface AiDrawingStateLike {
|
|
78
|
+
session?: {
|
|
79
|
+
id?: unknown;
|
|
80
|
+
sessionId?: unknown;
|
|
81
|
+
} | null;
|
|
82
|
+
activeCart?: {
|
|
83
|
+
id?: unknown;
|
|
84
|
+
items?: unknown;
|
|
85
|
+
} | null;
|
|
86
|
+
canvasImages?: unknown;
|
|
87
|
+
canvasCabinetPlacements?: unknown;
|
|
88
|
+
activeCanvases?: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface CanvasImageLike {
|
|
92
|
+
canvasType?: unknown;
|
|
93
|
+
imageType?: unknown;
|
|
94
|
+
s3Key?: unknown;
|
|
95
|
+
imagePath?: unknown;
|
|
96
|
+
path?: unknown;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface CartItemLike {
|
|
100
|
+
id?: unknown;
|
|
101
|
+
aiCartItemId?: unknown;
|
|
102
|
+
positionName?: unknown;
|
|
103
|
+
serialNumber?: unknown;
|
|
104
|
+
displayName?: unknown;
|
|
105
|
+
itemPayload?: unknown;
|
|
106
|
+
originalItemPayload?: unknown;
|
|
107
|
+
orderReadyPayload?: unknown;
|
|
108
|
+
drawingClassificationEvidence?: unknown;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface PlacementLike {
|
|
112
|
+
id?: unknown;
|
|
113
|
+
aiCartItemId?: unknown;
|
|
114
|
+
canvasType?: unknown;
|
|
115
|
+
positionName?: unknown;
|
|
116
|
+
centerX?: unknown;
|
|
117
|
+
centerY?: unknown;
|
|
118
|
+
width?: unknown;
|
|
119
|
+
height?: unknown;
|
|
120
|
+
rotation?: unknown;
|
|
121
|
+
placementSpace?: unknown;
|
|
122
|
+
basisImageWidthPx?: unknown;
|
|
123
|
+
basisImageHeightPx?: unknown;
|
|
124
|
+
objectType?: unknown;
|
|
125
|
+
drawingClassificationEvidence?: unknown;
|
|
126
|
+
viewAssociationEvidence?: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface RenderablePlacement {
|
|
130
|
+
placement: PlacementLike;
|
|
131
|
+
positionName: string;
|
|
132
|
+
aiCartItemId?: string;
|
|
133
|
+
centerX: number;
|
|
134
|
+
centerY: number;
|
|
135
|
+
width: number;
|
|
136
|
+
height: number;
|
|
137
|
+
rotation: number;
|
|
138
|
+
scaleX: number;
|
|
139
|
+
scaleY: number;
|
|
140
|
+
label: string;
|
|
141
|
+
placementCategory: 'cabinet' | 'plinth';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const CANVAS_TYPES: AiDrawingReviewCanvasType[] = ['plan', 'north', 'south', 'east', 'west'];
|
|
145
|
+
|
|
146
|
+
type Rgba = { r: number; g: number; b: number; a: number };
|
|
147
|
+
|
|
148
|
+
const CABINET_PLACEMENT_FILL: Rgba = { r: 37, g: 99, b: 235, a: 70 };
|
|
149
|
+
const CABINET_PLACEMENT_STROKE: Rgba = { r: 29, g: 78, b: 216, a: 255 };
|
|
150
|
+
const PLINTH_PLACEMENT_FILL: Rgba = { r: 255, g: 146, b: 43, a: 80 };
|
|
151
|
+
const PLINTH_PLACEMENT_STROKE: Rgba = { r: 225, g: 89, b: 12, a: 255 };
|
|
152
|
+
const LABEL_BACKGROUND = { r: 255, g: 255, b: 255, a: 220 };
|
|
153
|
+
const LABEL_TEXT = { r: 20, g: 27, b: 35, a: 255 };
|
|
154
|
+
const ISSUE_MARK = { r: 180, g: 20, b: 20, a: 255 };
|
|
155
|
+
const VISUAL_INSPECTION_CHECKLIST = [
|
|
156
|
+
'Open every full-canvas overlay with native vision.',
|
|
157
|
+
'Compare the rectangle edges to the visible cabinet face or plan footprint, not to labels or evidence text.',
|
|
158
|
+
'Source drawing pixels are the authority: do not treat non-overlap, adjacency, dimensions, labels, or evidence prose as proof that a bbox is visually correct.',
|
|
159
|
+
'For every placement, write a boundary evidence sentence naming the visible source linework at LEFT, RIGHT, TOP, and BOTTOM before assigning PASS.',
|
|
160
|
+
'If any side cannot be tied to visible cabinet/appliance/plinth linework, mark FAIL or UNCERTAIN; do not infer the side from labels, generated boxes, nearby dimensions, or expected cabinet size.',
|
|
161
|
+
'Blue overlays are cabinet front-facing bboxes; orange overlays are separate plinth/toekick bboxes.',
|
|
162
|
+
'Mark FAIL if a cabinet box is shifted, oversized, undersized, overlaps another cabinet, or includes appliance, fixture, counter, toekick/plinth, floor, wall, ceiling, annotation, or blank/context area.',
|
|
163
|
+
'Mark FAIL if a base cabinet bbox includes toekick/plinth or countertop height; base body height must be exclusive of both.',
|
|
164
|
+
'Mark FAIL if any cabinet bbox extends below the Z0/floor reference line or into floor, wall, or ceiling area.',
|
|
165
|
+
'Mark FAIL if a visible toekick is merged into a cabinet instead of represented as a separate orange plinth placement and AI cart item.',
|
|
166
|
+
'On elevations with base or sink-base cabinets, verify whether the lower support/toekick band is visible; if visible, require a separate orange plinth placement before PASS.',
|
|
167
|
+
'Mark FAIL if a visible cabinet on that canvas is missing, or if a side/return/context face is treated as a primary cabinet face.',
|
|
168
|
+
'Mark FAIL if plan footprint geometry is reused as an elevation face, or elevation face geometry is reused as a plan footprint.',
|
|
169
|
+
'Use called-out scale, known dimensions, and repeated modules to sanity-check cabinet body dimensions, single-door/double-door splits, drawer stacks, plinths, and gaps.',
|
|
170
|
+
'Patch bad placements with AI-only placement tools, rerender, and reinspect before completion.',
|
|
171
|
+
'If visual evidence is ambiguous, report UNCERTAIN and ask the user instead of declaring the review correct.',
|
|
172
|
+
];
|
|
173
|
+
const SOURCE_BOUNDARY_PASS_EVIDENCE_FORMAT = [
|
|
174
|
+
'PASS evidence must use this form: LEFT=<visible source linework>, RIGHT=<visible source linework>, TOP=<visible source linework>, BOTTOM=<visible source linework>, EXCLUDED=<labels/dimensions/wall/tile/counter/floor/toekick/context excluded>.',
|
|
175
|
+
'Each side must cite visible drawing geometry, such as cabinet side stile, door/front edge, carcass side, plan footprint edge, appliance front edge, plinth top/bottom line, countertop underside/top exclusion, or floor/Z0 line.',
|
|
176
|
+
'Do not write PASS when a side is justified by label position, generated bbox adjacency, non-overlap, broad run region, expected catalog size, or a dimension string alone.',
|
|
177
|
+
];
|
|
178
|
+
const SOURCE_BOUNDARY_FAIL_CONDITIONS = [
|
|
179
|
+
'Any bbox edge floats in blank/context area instead of on visible source linework.',
|
|
180
|
+
'Any bbox edge is aligned to label text, broad run area, tile hatch/backsplash, dimension string, generated bbox adjacency, or expected size rather than the visible object boundary.',
|
|
181
|
+
'The bbox includes wall, ceiling, floor, countertop, tile/backsplash hatch, appliance/hood context, neighboring cabinet, labels, or dimension text that is not part of the represented object.',
|
|
182
|
+
'The reviewer cannot name visible LEFT/RIGHT/TOP/BOTTOM source boundaries for the placement.',
|
|
183
|
+
];
|
|
184
|
+
const BASE_SOURCE_BOUNDARY_CHECKLIST = [
|
|
185
|
+
'Verify against the full source image, not against the orange plinth bbox or another generated bbox.',
|
|
186
|
+
'Blue base cabinet body bottom must stop at the visible transition from cabinet/front/carcass body to the separate toekick/plinth band.',
|
|
187
|
+
'Blue base cabinet body top must exclude any visible countertop line, slab, overhang, or counter thickness.',
|
|
188
|
+
'Blue base cabinet body must not extend below the visible floor/Z0 line or into floor/wall/context area.',
|
|
189
|
+
'If the visible drawing does not clearly show the body bottom, plinth band, floor/Z0, and countertop relationship, mark UNCERTAIN instead of PASS.',
|
|
190
|
+
];
|
|
191
|
+
const PLAN_FOOTPRINT_SOURCE_BOUNDARY_CHECKLIST = [
|
|
192
|
+
'Verify against the full source plan image, not against labels, broad run regions, dimensions, or evidence prose.',
|
|
193
|
+
'Blue plan bbox must cover only the actual cabinet/appliance footprint linework in plan view.',
|
|
194
|
+
'Blue plan bbox must not include labels, room/floor area, walls, dimension strings, dashed clearance lines, counter overhang context, neighboring cabinets, or unrelated broad run areas.',
|
|
195
|
+
'If the visible plan footprint is obscured or not clearly bounded, mark UNCERTAIN instead of PASS.',
|
|
196
|
+
];
|
|
197
|
+
const ELEVATION_FACE_SOURCE_BOUNDARY_CHECKLIST = [
|
|
198
|
+
'Verify against the full source elevation image, not against labels, broad run regions, dimensions, or evidence prose.',
|
|
199
|
+
'Blue elevation bbox must cover only the visible cabinet/appliance front face represented by that placement.',
|
|
200
|
+
'Blue elevation bbox must not include tile/backsplash hatch, wall, ceiling, floor, countertop, hood, appliance context, adjacent cabinets, labels, dimensions, or blank/context area.',
|
|
201
|
+
'If the visible face boundary is obscured or not clearly bounded, mark UNCERTAIN instead of PASS.',
|
|
202
|
+
];
|
|
203
|
+
const PLINTH_SOURCE_BOUNDARY_CHECKLIST = [
|
|
204
|
+
'Verify against the full source image, not against the blue base cabinet bbox.',
|
|
205
|
+
'Orange plinth/toekick bbox must cover the actual visible toekick/plinth band in the drawing, not a synthetic strip placed under the generated base bbox.',
|
|
206
|
+
'Orange plinth/toekick bbox must not extend below the visible floor/Z0 line or include floor/wall/context area.',
|
|
207
|
+
'Orange plinth/toekick bbox should align with the visible plinth band height and run width shown by source linework.',
|
|
208
|
+
'If the visible drawing does not clearly show the plinth/toekick band, mark UNCERTAIN instead of PASS.',
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
export function renderAiDrawingSessionReview(
|
|
212
|
+
state: AiDrawingStateLike,
|
|
213
|
+
options: AiDrawingSessionReviewRenderOptions
|
|
214
|
+
): AiDrawingSessionReviewManifest {
|
|
215
|
+
if (!options.outputDir?.trim()) {
|
|
216
|
+
throw new Error('outputDir is required for AI drawing session review artifacts');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fs.mkdirSync(options.outputDir, { recursive: true });
|
|
220
|
+
|
|
221
|
+
const requestedCanvases = normalizeRequestedCanvases(state, options.canvases);
|
|
222
|
+
const cartItems = normalizeArray<CartItemLike>(state.activeCart?.items);
|
|
223
|
+
const placements = normalizeArray<PlacementLike>(state.canvasCabinetPlacements);
|
|
224
|
+
const results = requestedCanvases.map((canvasType) => renderCanvasReview(
|
|
225
|
+
state,
|
|
226
|
+
canvasType,
|
|
227
|
+
placements.filter((placement) => placement.canvasType === canvasType),
|
|
228
|
+
cartItems,
|
|
229
|
+
options
|
|
230
|
+
));
|
|
231
|
+
|
|
232
|
+
const cartItemIssues = buildCartItemsWithoutPlacementIssues(cartItems, placements);
|
|
233
|
+
|
|
234
|
+
const manifest: AiDrawingSessionReviewManifest = {
|
|
235
|
+
sessionId: stringifyId(state.session?.id ?? state.session?.sessionId),
|
|
236
|
+
generatedAt: new Date().toISOString(),
|
|
237
|
+
visualInspectionRequired: true,
|
|
238
|
+
visualReviewStatus: 'requires_native_vision',
|
|
239
|
+
completionBlockedUntil:
|
|
240
|
+
'Resolve every source_boundary_review_required issue by opening every full-canvas outputImagePath with native vision, comparing each placement against source drawing pixels, recording PASS/FAIL/UNCERTAIN per canvas and placement, patching failures, and rerendering.',
|
|
241
|
+
visualInspectionChecklist: VISUAL_INSPECTION_CHECKLIST,
|
|
242
|
+
canvases: results,
|
|
243
|
+
issues: [...results.flatMap((result) => result.issues), ...cartItemIssues],
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (options.writeJsonManifest) {
|
|
247
|
+
const manifestPath = path.join(options.outputDir, options.manifestFileName ?? 'ai-drawing-session-review-manifest.json');
|
|
248
|
+
fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, manifestPath }, null, 2), 'utf8');
|
|
249
|
+
manifest.manifestPath = manifestPath;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return manifest;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderCanvasReview(
|
|
256
|
+
state: AiDrawingStateLike,
|
|
257
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
258
|
+
placements: PlacementLike[],
|
|
259
|
+
cartItems: CartItemLike[],
|
|
260
|
+
options: AiDrawingSessionReviewRenderOptions
|
|
261
|
+
): AiDrawingSessionReviewCanvasResult {
|
|
262
|
+
const issues: AiDrawingSessionReviewIssue[] = [];
|
|
263
|
+
const background = resolveBackgroundImage(state, canvasType, options);
|
|
264
|
+
|
|
265
|
+
if (!background.path) {
|
|
266
|
+
issues.push(background.issue ?? {
|
|
267
|
+
code: 'missing_background_image',
|
|
268
|
+
severity: 'warning',
|
|
269
|
+
canvasType,
|
|
270
|
+
message: `Canvas ${canvasType} has no renderable local background image.`,
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
canvasType,
|
|
274
|
+
renderedPlacementCount: 0,
|
|
275
|
+
sourceBoundaryReviewTasks: [],
|
|
276
|
+
issues: addCanvasPlacementIssues(canvasType, placements, cartItems, undefined, issues),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rawImage = readPngBackground(background.path, canvasType, background.reference, issues);
|
|
281
|
+
if (!rawImage) {
|
|
282
|
+
return {
|
|
283
|
+
canvasType,
|
|
284
|
+
renderedPlacementCount: 0,
|
|
285
|
+
sourceBoundaryReviewTasks: [],
|
|
286
|
+
issues: addCanvasPlacementIssues(canvasType, placements, cartItems, undefined, issues),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const image = clonePng(rawImage);
|
|
291
|
+
const renderablePlacements = buildRenderablePlacements(canvasType, placements, cartItems, image.width, image.height, issues);
|
|
292
|
+
addPlacementOverlapIssues(canvasType, renderablePlacements, issues);
|
|
293
|
+
addPlacementCategoryConflictIssues(canvasType, renderablePlacements, issues);
|
|
294
|
+
addMissingPlinthIssues(canvasType, renderablePlacements, issues);
|
|
295
|
+
const sourceBoundaryReviewTasks = buildSourceBoundaryReviewTasks(canvasType, renderablePlacements);
|
|
296
|
+
addSourceBoundaryReviewRequiredIssues(canvasType, sourceBoundaryReviewTasks, issues);
|
|
297
|
+
for (const renderable of renderablePlacements) {
|
|
298
|
+
drawPlacement(image, renderable);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const outputImagePath = path.join(
|
|
302
|
+
options.outputDir,
|
|
303
|
+
`ai-drawing-session-review-${sanitizeFilePart(stringifyId(state.session?.id ?? state.session?.sessionId) ?? 'session')}-${canvasType}.png`
|
|
304
|
+
);
|
|
305
|
+
fs.writeFileSync(outputImagePath, PNG.sync.write(image));
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
canvasType,
|
|
309
|
+
outputImagePath,
|
|
310
|
+
imageWidth: image.width,
|
|
311
|
+
imageHeight: image.height,
|
|
312
|
+
renderedPlacementCount: renderablePlacements.length,
|
|
313
|
+
sourceBoundaryReviewTasks,
|
|
314
|
+
issues: addCanvasPlacementIssues(canvasType, placements, cartItems, { width: image.width, height: image.height }, issues),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeRequestedCanvases(
|
|
319
|
+
state: AiDrawingStateLike,
|
|
320
|
+
requestedCanvases: AiDrawingReviewCanvasType[] | undefined
|
|
321
|
+
): AiDrawingReviewCanvasType[] {
|
|
322
|
+
const source = requestedCanvases?.length
|
|
323
|
+
? requestedCanvases
|
|
324
|
+
: normalizeArray<unknown>(state.activeCanvases).filter(isCanvasType);
|
|
325
|
+
const normalized = source.filter(isCanvasType);
|
|
326
|
+
return normalized.length > 0 ? [...new Set(normalized)] : ['plan'];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function resolveBackgroundImage(
|
|
330
|
+
state: AiDrawingStateLike,
|
|
331
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
332
|
+
options: AiDrawingSessionReviewRenderOptions
|
|
333
|
+
): { path?: string; reference?: string; issue?: AiDrawingSessionReviewIssue } {
|
|
334
|
+
const explicitPath = options.imagePathByCanvas?.[canvasType];
|
|
335
|
+
if (explicitPath) {
|
|
336
|
+
return resolveLocalImageReference(explicitPath, canvasType, options);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const image = normalizeArray<CanvasImageLike>(state.canvasImages).find((candidate) => (
|
|
340
|
+
candidate.canvasType === canvasType &&
|
|
341
|
+
String(candidate.imageType ?? '').toLowerCase() === 'background'
|
|
342
|
+
));
|
|
343
|
+
|
|
344
|
+
const reference = stringifyId(image?.imagePath ?? image?.path ?? image?.s3Key);
|
|
345
|
+
if (!reference) {
|
|
346
|
+
return {
|
|
347
|
+
issue: {
|
|
348
|
+
code: 'missing_background_image',
|
|
349
|
+
severity: 'warning',
|
|
350
|
+
canvasType,
|
|
351
|
+
message: `Canvas ${canvasType} has no background image reference.`,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return resolveLocalImageReference(reference, canvasType, options);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function resolveLocalImageReference(
|
|
360
|
+
reference: string,
|
|
361
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
362
|
+
options: AiDrawingSessionReviewRenderOptions
|
|
363
|
+
): { path?: string; reference: string; issue?: AiDrawingSessionReviewIssue } {
|
|
364
|
+
if (/^https?:\/\//i.test(reference)) {
|
|
365
|
+
return {
|
|
366
|
+
reference,
|
|
367
|
+
issue: {
|
|
368
|
+
code: 'unsupported_background_image_reference',
|
|
369
|
+
severity: 'warning',
|
|
370
|
+
canvasType,
|
|
371
|
+
imageReference: reference,
|
|
372
|
+
message: `Canvas ${canvasType} background image is not a local file reference and cannot be rendered in this phase.`,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const withoutFileScheme = reference.startsWith('file://')
|
|
378
|
+
? decodeURIComponent(reference.slice('file://'.length))
|
|
379
|
+
: reference;
|
|
380
|
+
const candidates = [
|
|
381
|
+
withoutFileScheme,
|
|
382
|
+
...((options.localArtifactRoots ?? []).map((root) => path.join(root, withoutFileScheme))),
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
const resolved = candidates.find((candidate) => path.isAbsolute(candidate) && fs.existsSync(candidate));
|
|
386
|
+
if (!resolved) {
|
|
387
|
+
return {
|
|
388
|
+
reference,
|
|
389
|
+
issue: {
|
|
390
|
+
code: 'unsupported_background_image_reference',
|
|
391
|
+
severity: 'warning',
|
|
392
|
+
canvasType,
|
|
393
|
+
imageReference: reference,
|
|
394
|
+
message: `Canvas ${canvasType} background image reference is not a readable local file.`,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (path.extname(resolved).toLowerCase() !== '.png') {
|
|
400
|
+
return {
|
|
401
|
+
reference,
|
|
402
|
+
issue: {
|
|
403
|
+
code: 'unsupported_background_image_reference',
|
|
404
|
+
severity: 'warning',
|
|
405
|
+
canvasType,
|
|
406
|
+
imageReference: reference,
|
|
407
|
+
message: `Canvas ${canvasType} background image is local but not a PNG file supported by this renderer.`,
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { path: resolved, reference };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function readPngBackground(
|
|
416
|
+
imagePath: string,
|
|
417
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
418
|
+
reference: string | undefined,
|
|
419
|
+
issues: AiDrawingSessionReviewIssue[]
|
|
420
|
+
): PNG | null {
|
|
421
|
+
try {
|
|
422
|
+
return PNG.sync.read(fs.readFileSync(imagePath));
|
|
423
|
+
} catch (error) {
|
|
424
|
+
issues.push({
|
|
425
|
+
code: 'background_image_read_failed',
|
|
426
|
+
severity: 'error',
|
|
427
|
+
canvasType,
|
|
428
|
+
imageReference: reference ?? imagePath,
|
|
429
|
+
message: `Canvas ${canvasType} background image could not be read as PNG: ${error instanceof Error ? error.message : String(error)}`,
|
|
430
|
+
});
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function buildRenderablePlacements(
|
|
436
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
437
|
+
placements: PlacementLike[],
|
|
438
|
+
cartItems: CartItemLike[],
|
|
439
|
+
imageWidth: number,
|
|
440
|
+
imageHeight: number,
|
|
441
|
+
issues: AiDrawingSessionReviewIssue[]
|
|
442
|
+
): RenderablePlacement[] {
|
|
443
|
+
const cartLookup = buildCartLookup(cartItems);
|
|
444
|
+
const renderable: RenderablePlacement[] = [];
|
|
445
|
+
|
|
446
|
+
for (const placement of placements) {
|
|
447
|
+
const positionName = stringifyId(placement.positionName);
|
|
448
|
+
const aiCartItemId = stringifyId(placement.aiCartItemId);
|
|
449
|
+
if (!positionName) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const placementSpace = String(placement.placementSpace ?? 'IMAGE_PIXELS').toUpperCase();
|
|
454
|
+
if (placementSpace !== 'IMAGE_PIXELS') {
|
|
455
|
+
issues.push({
|
|
456
|
+
code: 'non_image_pixels_placement_space',
|
|
457
|
+
severity: 'error',
|
|
458
|
+
canvasType,
|
|
459
|
+
positionName,
|
|
460
|
+
aiCartItemId,
|
|
461
|
+
message: `Placement ${positionName} on ${canvasType} uses unsupported placementSpace ${placement.placementSpace}.`,
|
|
462
|
+
});
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const centerX = toFiniteNumber(placement.centerX);
|
|
467
|
+
const centerY = toFiniteNumber(placement.centerY);
|
|
468
|
+
const width = toFiniteNumber(placement.width);
|
|
469
|
+
const height = toFiniteNumber(placement.height);
|
|
470
|
+
if (centerX === null || centerY === null || width === null || height === null || width <= 0 || height <= 0) {
|
|
471
|
+
issues.push({
|
|
472
|
+
code: 'invalid_placement_dimensions',
|
|
473
|
+
severity: 'error',
|
|
474
|
+
canvasType,
|
|
475
|
+
positionName,
|
|
476
|
+
aiCartItemId,
|
|
477
|
+
message: `Placement ${positionName} on ${canvasType} has missing, zero, negative, or non-finite rectangle dimensions.`,
|
|
478
|
+
});
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const basisWidth = toFiniteNumber(placement.basisImageWidthPx);
|
|
483
|
+
const basisHeight = toFiniteNumber(placement.basisImageHeightPx);
|
|
484
|
+
const hasValidBasis = basisWidth !== null && basisHeight !== null && basisWidth > 0 && basisHeight > 0;
|
|
485
|
+
if (!hasValidBasis) {
|
|
486
|
+
issues.push({
|
|
487
|
+
code: 'missing_or_invalid_basis_image_dimensions',
|
|
488
|
+
severity: 'warning',
|
|
489
|
+
canvasType,
|
|
490
|
+
positionName,
|
|
491
|
+
aiCartItemId,
|
|
492
|
+
message: `Placement ${positionName} on ${canvasType} has missing or invalid basis image dimensions.`,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const scaleX = hasValidBasis ? imageWidth / basisWidth : 1;
|
|
497
|
+
const scaleY = hasValidBasis ? imageHeight / basisHeight : 1;
|
|
498
|
+
const rotation = toFiniteNumber(placement.rotation) ?? 0;
|
|
499
|
+
const matchingCartItem = findMatchingCartItem(placement, cartLookup);
|
|
500
|
+
const placementCategory: RenderablePlacement['placementCategory'] = isPlinthPlacement(placement, matchingCartItem)
|
|
501
|
+
? 'plinth'
|
|
502
|
+
: 'cabinet';
|
|
503
|
+
|
|
504
|
+
if (!matchingCartItem) {
|
|
505
|
+
issues.push({
|
|
506
|
+
code: 'placement_without_matching_cart_item',
|
|
507
|
+
severity: 'warning',
|
|
508
|
+
canvasType,
|
|
509
|
+
positionName,
|
|
510
|
+
aiCartItemId,
|
|
511
|
+
message: `Placement ${positionName} on ${canvasType} does not match an active AI cart item.`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const scaledPlacement = {
|
|
516
|
+
placement,
|
|
517
|
+
positionName,
|
|
518
|
+
aiCartItemId,
|
|
519
|
+
centerX,
|
|
520
|
+
centerY,
|
|
521
|
+
width,
|
|
522
|
+
height,
|
|
523
|
+
rotation,
|
|
524
|
+
scaleX,
|
|
525
|
+
scaleY,
|
|
526
|
+
label: buildPlacementLabel(positionName, matchingCartItem, aiCartItemId),
|
|
527
|
+
placementCategory,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
if (isPlacementOutsideImageBounds(scaledPlacement, imageWidth, imageHeight)) {
|
|
531
|
+
issues.push({
|
|
532
|
+
code: 'placement_outside_image_bounds',
|
|
533
|
+
severity: 'warning',
|
|
534
|
+
canvasType,
|
|
535
|
+
positionName,
|
|
536
|
+
aiCartItemId,
|
|
537
|
+
message: `Placement ${positionName} on ${canvasType} extends outside the background image bounds.`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
renderable.push(scaledPlacement);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return renderable;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function addCanvasPlacementIssues(
|
|
548
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
549
|
+
placements: PlacementLike[],
|
|
550
|
+
cartItems: CartItemLike[],
|
|
551
|
+
imageSize: { width: number; height: number } | undefined,
|
|
552
|
+
issues: AiDrawingSessionReviewIssue[]
|
|
553
|
+
): AiDrawingSessionReviewIssue[] {
|
|
554
|
+
const nextIssues = [...issues];
|
|
555
|
+
if (placements.length === 0) {
|
|
556
|
+
nextIssues.push({
|
|
557
|
+
code: 'canvas_with_no_placements',
|
|
558
|
+
severity: cartItems.length > 0 ? 'error' : 'warning',
|
|
559
|
+
canvasType,
|
|
560
|
+
message: cartItems.length > 0
|
|
561
|
+
? `Canvas ${canvasType} has AI cart items but no stored per-canvas cabinet placements. A drawing session/cart creation pass is not complete until visible cabinets are placed and reviewed.`
|
|
562
|
+
: `Canvas ${canvasType} has no stored per-canvas cabinet placements.`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const seenPositionNames = new Set<string>();
|
|
567
|
+
const duplicatePositionNames = new Set<string>();
|
|
568
|
+
for (const placement of placements) {
|
|
569
|
+
const positionName = stringifyId(placement.positionName);
|
|
570
|
+
if (!positionName) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (seenPositionNames.has(positionName)) {
|
|
574
|
+
duplicatePositionNames.add(positionName);
|
|
575
|
+
}
|
|
576
|
+
seenPositionNames.add(positionName);
|
|
577
|
+
}
|
|
578
|
+
for (const positionName of duplicatePositionNames) {
|
|
579
|
+
nextIssues.push({
|
|
580
|
+
code: 'duplicate_position_name_on_canvas',
|
|
581
|
+
severity: 'error',
|
|
582
|
+
canvasType,
|
|
583
|
+
positionName,
|
|
584
|
+
message: `Canvas ${canvasType} has duplicate placement positionName ${positionName}.`,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!imageSize) {
|
|
589
|
+
const cartLookup = buildCartLookup(cartItems);
|
|
590
|
+
for (const placement of placements) {
|
|
591
|
+
const positionName = stringifyId(placement.positionName);
|
|
592
|
+
if (positionName && !findMatchingCartItem(placement, cartLookup)) {
|
|
593
|
+
nextIssues.push({
|
|
594
|
+
code: 'placement_without_matching_cart_item',
|
|
595
|
+
severity: 'warning',
|
|
596
|
+
canvasType,
|
|
597
|
+
positionName,
|
|
598
|
+
aiCartItemId: stringifyId(placement.aiCartItemId),
|
|
599
|
+
message: `Placement ${positionName} on ${canvasType} does not match an active AI cart item.`,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return nextIssues;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function addPlacementOverlapIssues(
|
|
609
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
610
|
+
renderablePlacements: RenderablePlacement[],
|
|
611
|
+
issues: AiDrawingSessionReviewIssue[]
|
|
612
|
+
): void {
|
|
613
|
+
for (let firstIndex = 0; firstIndex < renderablePlacements.length; firstIndex += 1) {
|
|
614
|
+
for (let secondIndex = firstIndex + 1; secondIndex < renderablePlacements.length; secondIndex += 1) {
|
|
615
|
+
const first = renderablePlacements[firstIndex];
|
|
616
|
+
const second = renderablePlacements[secondIndex];
|
|
617
|
+
if (first.positionName === second.positionName) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const overlap = calculateAxisAlignedOverlap(first, second);
|
|
622
|
+
if (!overlap) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const smallerArea = Math.min(overlap.firstArea, overlap.secondArea);
|
|
627
|
+
const overlapRatio = smallerArea > 0 ? overlap.area / smallerArea : 0;
|
|
628
|
+
if (overlap.area < 16 || overlapRatio < 0.02) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
issues.push({
|
|
633
|
+
code: 'placement_overlap',
|
|
634
|
+
severity: 'warning',
|
|
635
|
+
canvasType,
|
|
636
|
+
positionName: first.positionName,
|
|
637
|
+
relatedPositionName: second.positionName,
|
|
638
|
+
aiCartItemId: first.aiCartItemId,
|
|
639
|
+
message:
|
|
640
|
+
`Canvas ${canvasType} placements ${first.positionName} and ${second.positionName} overlap by ` +
|
|
641
|
+
`${Math.round(overlap.area)} rendered pixels (${Math.round(overlapRatio * 100)}% of the smaller rectangle). ` +
|
|
642
|
+
'Verify this is intentional; cabinet placement boxes should not overlap separate cabinets, appliances, fixtures, counters, toe kicks, walls, labels, or blank/context areas.',
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function addMissingPlinthIssues(
|
|
649
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
650
|
+
renderablePlacements: RenderablePlacement[],
|
|
651
|
+
issues: AiDrawingSessionReviewIssue[]
|
|
652
|
+
): void {
|
|
653
|
+
if (canvasType === 'plan') {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const basePlacements = renderablePlacements.filter(isBaseCabinetPlacement);
|
|
658
|
+
if (basePlacements.length === 0 || renderablePlacements.some((placement) => placement.placementCategory === 'plinth')) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
issues.push({
|
|
663
|
+
code: 'base_elevation_without_plinth_placement',
|
|
664
|
+
severity: 'warning',
|
|
665
|
+
canvasType,
|
|
666
|
+
positionName: basePlacements.map((placement) => placement.positionName).join(', '),
|
|
667
|
+
message:
|
|
668
|
+
`Canvas ${canvasType} has base/sink cabinet placements but no separate orange plinth/toekick placement. ` +
|
|
669
|
+
'If a lower support/toekick band is visible, create one or more separate plinth AI cart items and orange placements, ' +
|
|
670
|
+
'then shrink the blue base cabinet boxes so they exclude plinth/toekick height. Do not mark review PASS until the plinth decision is visually verified.',
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function addPlacementCategoryConflictIssues(
|
|
675
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
676
|
+
renderablePlacements: RenderablePlacement[],
|
|
677
|
+
issues: AiDrawingSessionReviewIssue[]
|
|
678
|
+
): void {
|
|
679
|
+
for (const placement of renderablePlacements) {
|
|
680
|
+
if (placement.placementCategory === 'plinth' && isBaseCabinetPlacement(placement)) {
|
|
681
|
+
issues.push({
|
|
682
|
+
code: 'placement_category_conflict',
|
|
683
|
+
severity: 'error',
|
|
684
|
+
canvasType,
|
|
685
|
+
positionName: placement.positionName,
|
|
686
|
+
aiCartItemId: placement.aiCartItemId,
|
|
687
|
+
message:
|
|
688
|
+
`Placement ${placement.positionName} on ${canvasType} is linked to a base/sink cabinet but was categorized as plinth. ` +
|
|
689
|
+
'Review cannot pass with base cabinet overlays rendered orange; classify the base cabinet as a blue cabinet placement and keep the plinth/toekick as a separate orange placement.',
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function buildSourceBoundaryReviewTasks(
|
|
696
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
697
|
+
renderablePlacements: RenderablePlacement[]
|
|
698
|
+
): AiDrawingSourceBoundaryReviewTask[] {
|
|
699
|
+
const tasks: AiDrawingSourceBoundaryReviewTask[] = [];
|
|
700
|
+
for (const placement of renderablePlacements) {
|
|
701
|
+
if (canvasType === 'plan') {
|
|
702
|
+
tasks.push({
|
|
703
|
+
canvasType,
|
|
704
|
+
positionName: placement.positionName,
|
|
705
|
+
aiCartItemId: placement.aiCartItemId,
|
|
706
|
+
placementCategory: 'plan_footprint',
|
|
707
|
+
sourceImageAuthority: true,
|
|
708
|
+
passRequiresEvidenceFormat: SOURCE_BOUNDARY_PASS_EVIDENCE_FORMAT,
|
|
709
|
+
failIf: SOURCE_BOUNDARY_FAIL_CONDITIONS,
|
|
710
|
+
checklist: PLAN_FOOTPRINT_SOURCE_BOUNDARY_CHECKLIST,
|
|
711
|
+
});
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (isBaseCabinetPlacement(placement)) {
|
|
716
|
+
tasks.push({
|
|
717
|
+
canvasType,
|
|
718
|
+
positionName: placement.positionName,
|
|
719
|
+
aiCartItemId: placement.aiCartItemId,
|
|
720
|
+
placementCategory: 'base_cabinet_body',
|
|
721
|
+
sourceImageAuthority: true,
|
|
722
|
+
passRequiresEvidenceFormat: SOURCE_BOUNDARY_PASS_EVIDENCE_FORMAT,
|
|
723
|
+
failIf: SOURCE_BOUNDARY_FAIL_CONDITIONS,
|
|
724
|
+
checklist: BASE_SOURCE_BOUNDARY_CHECKLIST,
|
|
725
|
+
});
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (placement.placementCategory === 'plinth') {
|
|
730
|
+
tasks.push({
|
|
731
|
+
canvasType,
|
|
732
|
+
positionName: placement.positionName,
|
|
733
|
+
aiCartItemId: placement.aiCartItemId,
|
|
734
|
+
placementCategory: 'plinth_toekick',
|
|
735
|
+
sourceImageAuthority: true,
|
|
736
|
+
passRequiresEvidenceFormat: SOURCE_BOUNDARY_PASS_EVIDENCE_FORMAT,
|
|
737
|
+
failIf: SOURCE_BOUNDARY_FAIL_CONDITIONS,
|
|
738
|
+
checklist: PLINTH_SOURCE_BOUNDARY_CHECKLIST,
|
|
739
|
+
});
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
tasks.push({
|
|
744
|
+
canvasType,
|
|
745
|
+
positionName: placement.positionName,
|
|
746
|
+
aiCartItemId: placement.aiCartItemId,
|
|
747
|
+
placementCategory: 'cabinet_face',
|
|
748
|
+
sourceImageAuthority: true,
|
|
749
|
+
passRequiresEvidenceFormat: SOURCE_BOUNDARY_PASS_EVIDENCE_FORMAT,
|
|
750
|
+
failIf: SOURCE_BOUNDARY_FAIL_CONDITIONS,
|
|
751
|
+
checklist: ELEVATION_FACE_SOURCE_BOUNDARY_CHECKLIST,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return tasks;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function addSourceBoundaryReviewRequiredIssues(
|
|
759
|
+
canvasType: AiDrawingReviewCanvasType,
|
|
760
|
+
tasks: AiDrawingSourceBoundaryReviewTask[],
|
|
761
|
+
issues: AiDrawingSessionReviewIssue[]
|
|
762
|
+
): void {
|
|
763
|
+
for (const task of tasks) {
|
|
764
|
+
issues.push({
|
|
765
|
+
code: 'source_boundary_review_required',
|
|
766
|
+
severity: 'error',
|
|
767
|
+
canvasType,
|
|
768
|
+
positionName: task.positionName,
|
|
769
|
+
aiCartItemId: task.aiCartItemId,
|
|
770
|
+
message:
|
|
771
|
+
`Canvas ${canvasType} placement ${task.positionName} requires full-source-image boundary review for ${task.placementCategory}. ` +
|
|
772
|
+
'This is a blocking review task: non-overlap, adjacency, labels, dimensions, and evidence prose do not prove visual correctness. ' +
|
|
773
|
+
'Open the full-canvas overlay, compare against the source drawing pixels, and record PASS/FAIL/UNCERTAIN before claiming completion.',
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function calculateAxisAlignedOverlap(
|
|
779
|
+
first: RenderablePlacement,
|
|
780
|
+
second: RenderablePlacement
|
|
781
|
+
): { area: number; firstArea: number; secondArea: number } | null {
|
|
782
|
+
const firstBounds = getAxisAlignedBounds(first);
|
|
783
|
+
const secondBounds = getAxisAlignedBounds(second);
|
|
784
|
+
const width = Math.max(0, Math.min(firstBounds.right, secondBounds.right) - Math.max(firstBounds.left, secondBounds.left));
|
|
785
|
+
const height = Math.max(0, Math.min(firstBounds.bottom, secondBounds.bottom) - Math.max(firstBounds.top, secondBounds.top));
|
|
786
|
+
const area = width * height;
|
|
787
|
+
return area > 0
|
|
788
|
+
? { area, firstArea: firstBounds.area, secondArea: secondBounds.area }
|
|
789
|
+
: null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function getAxisAlignedBounds(renderable: RenderablePlacement): {
|
|
793
|
+
left: number;
|
|
794
|
+
top: number;
|
|
795
|
+
right: number;
|
|
796
|
+
bottom: number;
|
|
797
|
+
area: number;
|
|
798
|
+
} {
|
|
799
|
+
const corners = getScaledRotatedCorners(renderable);
|
|
800
|
+
const left = Math.min(...corners.map((corner) => corner.x));
|
|
801
|
+
const right = Math.max(...corners.map((corner) => corner.x));
|
|
802
|
+
const top = Math.min(...corners.map((corner) => corner.y));
|
|
803
|
+
const bottom = Math.max(...corners.map((corner) => corner.y));
|
|
804
|
+
return {
|
|
805
|
+
left,
|
|
806
|
+
top,
|
|
807
|
+
right,
|
|
808
|
+
bottom,
|
|
809
|
+
area: Math.max(0, right - left) * Math.max(0, bottom - top),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function buildCartItemsWithoutPlacementIssues(
|
|
814
|
+
cartItems: CartItemLike[],
|
|
815
|
+
placements: PlacementLike[]
|
|
816
|
+
): AiDrawingSessionReviewIssue[] {
|
|
817
|
+
const issues: AiDrawingSessionReviewIssue[] = [];
|
|
818
|
+
for (const item of cartItems) {
|
|
819
|
+
const itemId = stringifyId(item.id ?? item.aiCartItemId);
|
|
820
|
+
const positionName = stringifyId(item.positionName);
|
|
821
|
+
const hasPlacement = placements.some((placement) => (
|
|
822
|
+
(itemId && stringifyId(placement.aiCartItemId) === itemId) ||
|
|
823
|
+
(positionName && stringifyId(placement.positionName) === positionName)
|
|
824
|
+
));
|
|
825
|
+
if (!hasPlacement) {
|
|
826
|
+
issues.push({
|
|
827
|
+
code: 'cart_item_without_placement',
|
|
828
|
+
severity: 'warning',
|
|
829
|
+
positionName,
|
|
830
|
+
aiCartItemId: itemId,
|
|
831
|
+
message: `AI cart item ${positionName ?? itemId ?? 'unknown'} has no stored placement on any canvas.`,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return issues;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function drawPlacement(image: PNG, renderable: RenderablePlacement): void {
|
|
839
|
+
const fill = renderable.placementCategory === 'plinth' ? PLINTH_PLACEMENT_FILL : CABINET_PLACEMENT_FILL;
|
|
840
|
+
const stroke = renderable.placementCategory === 'plinth' ? PLINTH_PLACEMENT_STROKE : CABINET_PLACEMENT_STROKE;
|
|
841
|
+
const corners = getScaledRotatedCorners(renderable);
|
|
842
|
+
fillPolygon(image, corners, fill);
|
|
843
|
+
drawPolygonOutline(image, corners, stroke, 2);
|
|
844
|
+
|
|
845
|
+
const centerX = Math.round(renderable.centerX * renderable.scaleX);
|
|
846
|
+
const centerY = Math.round(renderable.centerY * renderable.scaleY);
|
|
847
|
+
drawLine(image, centerX - 4, centerY, centerX + 4, centerY, ISSUE_MARK, 1);
|
|
848
|
+
drawLine(image, centerX, centerY - 4, centerX, centerY + 4, ISSUE_MARK, 1);
|
|
849
|
+
drawLabel(image, renderable.label, centerX, centerY);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function clonePng(image: PNG): PNG {
|
|
853
|
+
const cloned = new PNG({ width: image.width, height: image.height });
|
|
854
|
+
image.data.copy(cloned.data);
|
|
855
|
+
return cloned;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function getScaledRotatedCorners(renderable: RenderablePlacement): Array<{ x: number; y: number }> {
|
|
859
|
+
const centerX = renderable.centerX * renderable.scaleX;
|
|
860
|
+
const centerY = renderable.centerY * renderable.scaleY;
|
|
861
|
+
const halfWidth = (renderable.width * renderable.scaleX) / 2;
|
|
862
|
+
const halfHeight = (renderable.height * renderable.scaleY) / 2;
|
|
863
|
+
const angle = (renderable.rotation * Math.PI) / 180;
|
|
864
|
+
const cos = Math.cos(angle);
|
|
865
|
+
const sin = Math.sin(angle);
|
|
866
|
+
|
|
867
|
+
return [
|
|
868
|
+
{ x: -halfWidth, y: -halfHeight },
|
|
869
|
+
{ x: halfWidth, y: -halfHeight },
|
|
870
|
+
{ x: halfWidth, y: halfHeight },
|
|
871
|
+
{ x: -halfWidth, y: halfHeight },
|
|
872
|
+
].map((point) => ({
|
|
873
|
+
x: centerX + point.x * cos - point.y * sin,
|
|
874
|
+
y: centerY + point.x * sin + point.y * cos,
|
|
875
|
+
}));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function isPlacementOutsideImageBounds(renderable: RenderablePlacement, imageWidth: number, imageHeight: number): boolean {
|
|
879
|
+
return getScaledRotatedCorners(renderable).some((corner) => (
|
|
880
|
+
corner.x < 0 || corner.y < 0 || corner.x > imageWidth || corner.y > imageHeight
|
|
881
|
+
));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function fillPolygon(image: PNG, points: Array<{ x: number; y: number }>, color: Rgba): void {
|
|
885
|
+
const minX = Math.max(0, Math.floor(Math.min(...points.map((point) => point.x))));
|
|
886
|
+
const maxX = Math.min(image.width - 1, Math.ceil(Math.max(...points.map((point) => point.x))));
|
|
887
|
+
const minY = Math.max(0, Math.floor(Math.min(...points.map((point) => point.y))));
|
|
888
|
+
const maxY = Math.min(image.height - 1, Math.ceil(Math.max(...points.map((point) => point.y))));
|
|
889
|
+
|
|
890
|
+
for (let y = minY; y <= maxY; y += 1) {
|
|
891
|
+
for (let x = minX; x <= maxX; x += 1) {
|
|
892
|
+
if (pointInPolygon(x + 0.5, y + 0.5, points)) {
|
|
893
|
+
blendPixel(image, x, y, color);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function drawPolygonOutline(
|
|
900
|
+
image: PNG,
|
|
901
|
+
points: Array<{ x: number; y: number }>,
|
|
902
|
+
color: Rgba,
|
|
903
|
+
thickness: number
|
|
904
|
+
): void {
|
|
905
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
906
|
+
const start = points[index];
|
|
907
|
+
const end = points[(index + 1) % points.length];
|
|
908
|
+
drawLine(image, start.x, start.y, end.x, end.y, color, thickness);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function drawLine(
|
|
913
|
+
image: PNG,
|
|
914
|
+
x1: number,
|
|
915
|
+
y1: number,
|
|
916
|
+
x2: number,
|
|
917
|
+
y2: number,
|
|
918
|
+
color: Rgba,
|
|
919
|
+
thickness: number
|
|
920
|
+
): void {
|
|
921
|
+
const dx = x2 - x1;
|
|
922
|
+
const dy = y2 - y1;
|
|
923
|
+
const steps = Math.max(Math.abs(dx), Math.abs(dy), 1);
|
|
924
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
925
|
+
const x = Math.round(x1 + (dx * step) / steps);
|
|
926
|
+
const y = Math.round(y1 + (dy * step) / steps);
|
|
927
|
+
for (let tx = -thickness; tx <= thickness; tx += 1) {
|
|
928
|
+
for (let ty = -thickness; ty <= thickness; ty += 1) {
|
|
929
|
+
if (tx * tx + ty * ty <= thickness * thickness) {
|
|
930
|
+
blendPixel(image, x + tx, y + ty, color);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function drawLabel(image: PNG, label: string, centerX: number, centerY: number): void {
|
|
938
|
+
const compactLabel = label.toUpperCase().slice(0, 30);
|
|
939
|
+
const charWidth = 4;
|
|
940
|
+
const charHeight = 5;
|
|
941
|
+
const scale = 2;
|
|
942
|
+
const spacing = 1;
|
|
943
|
+
const labelWidth = compactLabel.length * charWidth * scale + Math.max(0, compactLabel.length - 1) * spacing * scale;
|
|
944
|
+
const labelHeight = charHeight * scale;
|
|
945
|
+
const x = Math.round(centerX - labelWidth / 2);
|
|
946
|
+
const y = Math.round(centerY - labelHeight / 2);
|
|
947
|
+
|
|
948
|
+
fillRect(image, x - 3, y - 3, labelWidth + 6, labelHeight + 6, LABEL_BACKGROUND);
|
|
949
|
+
|
|
950
|
+
let cursorX = x;
|
|
951
|
+
for (const char of compactLabel) {
|
|
952
|
+
drawChar(image, char, cursorX, y, scale, LABEL_TEXT);
|
|
953
|
+
cursorX += (charWidth + spacing) * scale;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function drawChar(
|
|
958
|
+
image: PNG,
|
|
959
|
+
char: string,
|
|
960
|
+
x: number,
|
|
961
|
+
y: number,
|
|
962
|
+
scale: number,
|
|
963
|
+
color: typeof LABEL_TEXT
|
|
964
|
+
): void {
|
|
965
|
+
const pattern = FONT_3X5[char] ?? FONT_3X5['?'];
|
|
966
|
+
for (let row = 0; row < pattern.length; row += 1) {
|
|
967
|
+
for (let column = 0; column < pattern[row].length; column += 1) {
|
|
968
|
+
if (pattern[row][column] === '1') {
|
|
969
|
+
fillRect(image, x + column * scale, y + row * scale, scale, scale, color);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function fillRect(
|
|
976
|
+
image: PNG,
|
|
977
|
+
x: number,
|
|
978
|
+
y: number,
|
|
979
|
+
width: number,
|
|
980
|
+
height: number,
|
|
981
|
+
color: typeof LABEL_BACKGROUND
|
|
982
|
+
): void {
|
|
983
|
+
for (let py = Math.max(0, y); py < Math.min(image.height, y + height); py += 1) {
|
|
984
|
+
for (let px = Math.max(0, x); px < Math.min(image.width, x + width); px += 1) {
|
|
985
|
+
blendPixel(image, px, py, color);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function blendPixel(
|
|
991
|
+
image: PNG,
|
|
992
|
+
x: number,
|
|
993
|
+
y: number,
|
|
994
|
+
color: { r: number; g: number; b: number; a: number }
|
|
995
|
+
): void {
|
|
996
|
+
if (x < 0 || y < 0 || x >= image.width || y >= image.height) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const offset = (Math.floor(y) * image.width + Math.floor(x)) * 4;
|
|
1000
|
+
const sourceAlpha = color.a / 255;
|
|
1001
|
+
const targetAlpha = image.data[offset + 3] / 255;
|
|
1002
|
+
const outAlpha = sourceAlpha + targetAlpha * (1 - sourceAlpha);
|
|
1003
|
+
if (outAlpha <= 0) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
image.data[offset] = Math.round((color.r * sourceAlpha + image.data[offset] * targetAlpha * (1 - sourceAlpha)) / outAlpha);
|
|
1008
|
+
image.data[offset + 1] = Math.round((color.g * sourceAlpha + image.data[offset + 1] * targetAlpha * (1 - sourceAlpha)) / outAlpha);
|
|
1009
|
+
image.data[offset + 2] = Math.round((color.b * sourceAlpha + image.data[offset + 2] * targetAlpha * (1 - sourceAlpha)) / outAlpha);
|
|
1010
|
+
image.data[offset + 3] = Math.round(outAlpha * 255);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function pointInPolygon(x: number, y: number, points: Array<{ x: number; y: number }>): boolean {
|
|
1014
|
+
let inside = false;
|
|
1015
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
|
1016
|
+
const intersects = ((points[i].y > y) !== (points[j].y > y)) &&
|
|
1017
|
+
(x < ((points[j].x - points[i].x) * (y - points[i].y)) / (points[j].y - points[i].y) + points[i].x);
|
|
1018
|
+
if (intersects) {
|
|
1019
|
+
inside = !inside;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return inside;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function buildCartLookup(cartItems: CartItemLike[]): {
|
|
1026
|
+
byId: Map<string, CartItemLike>;
|
|
1027
|
+
byPositionName: Map<string, CartItemLike>;
|
|
1028
|
+
} {
|
|
1029
|
+
const byId = new Map<string, CartItemLike>();
|
|
1030
|
+
const byPositionName = new Map<string, CartItemLike>();
|
|
1031
|
+
for (const item of cartItems) {
|
|
1032
|
+
const itemId = stringifyId(item.id ?? item.aiCartItemId);
|
|
1033
|
+
const positionName = stringifyId(item.positionName);
|
|
1034
|
+
if (itemId) {
|
|
1035
|
+
byId.set(itemId, item);
|
|
1036
|
+
}
|
|
1037
|
+
if (positionName) {
|
|
1038
|
+
byPositionName.set(positionName, item);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return { byId, byPositionName };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function findMatchingCartItem(
|
|
1045
|
+
placement: PlacementLike,
|
|
1046
|
+
cartLookup: ReturnType<typeof buildCartLookup>
|
|
1047
|
+
): CartItemLike | undefined {
|
|
1048
|
+
const placementItemId = stringifyId(placement.aiCartItemId);
|
|
1049
|
+
const positionName = stringifyId(placement.positionName);
|
|
1050
|
+
return (placementItemId ? cartLookup.byId.get(placementItemId) : undefined) ??
|
|
1051
|
+
(positionName ? cartLookup.byPositionName.get(positionName) : undefined);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function isPlinthPlacement(placement: PlacementLike, cartItem: CartItemLike | undefined): boolean {
|
|
1055
|
+
const serialNumber = stringifyId(cartItem?.serialNumber)?.toLowerCase();
|
|
1056
|
+
if (serialNumber && isBaseOrSinkSerial(serialNumber)) {
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
if (serialNumber && isPlinthSerial(serialNumber)) {
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const identityValues = getPlacementIdentityStrings(placement, cartItem);
|
|
1064
|
+
if (identityValues.some(isBaseOrSinkIdentityValue)) {
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return identityValues.some((value) => (
|
|
1069
|
+
value === 'plinth' ||
|
|
1070
|
+
value === 'toekick' ||
|
|
1071
|
+
value === 'toe_kick' ||
|
|
1072
|
+
value === 'toe-kick' ||
|
|
1073
|
+
value.includes('plinth') ||
|
|
1074
|
+
value.includes('toekick') ||
|
|
1075
|
+
value.includes('toe kick') ||
|
|
1076
|
+
value.includes('toe_kick') ||
|
|
1077
|
+
value.includes('toe-kick')
|
|
1078
|
+
));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function isBaseCabinetPlacement(renderable: RenderablePlacement): boolean {
|
|
1082
|
+
const searchableValues = [
|
|
1083
|
+
...getPlacementIdentityStrings(renderable.placement, undefined),
|
|
1084
|
+
...getPlacementSemanticStrings(renderable.placement, undefined),
|
|
1085
|
+
];
|
|
1086
|
+
return searchableValues.some((value) => (
|
|
1087
|
+
isBaseOrSinkIdentityValue(value) ||
|
|
1088
|
+
value.includes('base cabinet') ||
|
|
1089
|
+
value.includes('sink base') ||
|
|
1090
|
+
value.includes('base_')
|
|
1091
|
+
)) || /BC_/i.test(renderable.label) || /(^|[^a-z])base([^a-z]|$)/i.test(renderable.label);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function getPlacementIdentityStrings(placement: PlacementLike, cartItem: CartItemLike | undefined): string[] {
|
|
1095
|
+
return [
|
|
1096
|
+
stringifyId(placement.objectType),
|
|
1097
|
+
stringifyId(placement.positionName),
|
|
1098
|
+
stringifyId(cartItem?.positionName),
|
|
1099
|
+
stringifyId(cartItem?.serialNumber),
|
|
1100
|
+
stringifyId(cartItem?.displayName),
|
|
1101
|
+
...getReviewedObjectTypeStrings(placement.drawingClassificationEvidence),
|
|
1102
|
+
...getReviewedObjectTypeStrings(placement.viewAssociationEvidence),
|
|
1103
|
+
...getReviewedObjectTypeStrings(cartItem?.drawingClassificationEvidence),
|
|
1104
|
+
...getReviewedObjectTypeStrings(cartItem?.itemPayload),
|
|
1105
|
+
...getReviewedObjectTypeStrings(cartItem?.originalItemPayload),
|
|
1106
|
+
...getReviewedObjectTypeStrings(cartItem?.orderReadyPayload),
|
|
1107
|
+
]
|
|
1108
|
+
.filter(Boolean)
|
|
1109
|
+
.map((value) => value!.toLowerCase());
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function isBaseOrSinkSerial(value: string): boolean {
|
|
1113
|
+
return /^bc[_-]/.test(value) ||
|
|
1114
|
+
/^sb\d*/.test(value) ||
|
|
1115
|
+
/^uc[_-][a-z0-9]+[_-]b\d+/i.test(value) ||
|
|
1116
|
+
/^uc[_-].*[_-]base/i.test(value);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function isPlinthSerial(value: string): boolean {
|
|
1120
|
+
return /^lp[_-]pl[_-]/.test(value) || /^lp[_-].*plinth/.test(value);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function isBaseOrSinkIdentityValue(value: string): boolean {
|
|
1124
|
+
return value === 'base_cabinet' ||
|
|
1125
|
+
value === 'sink_base' ||
|
|
1126
|
+
isBaseOrSinkSerial(value) ||
|
|
1127
|
+
/^uc[_-][a-z0-9]+[_-]b\d+/i.test(value) ||
|
|
1128
|
+
/^base([_\s.-]|$)/.test(value) ||
|
|
1129
|
+
/(^|[^a-z])base([^a-z]|$)/.test(value) ||
|
|
1130
|
+
/(^|[^a-z])sink base([^a-z]|$)/.test(value);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function getPlacementSemanticStrings(placement: PlacementLike, cartItem: CartItemLike | undefined): string[] {
|
|
1134
|
+
return [
|
|
1135
|
+
stringifyId(placement.objectType),
|
|
1136
|
+
stringifyId(placement.positionName),
|
|
1137
|
+
stringifyId(cartItem?.positionName),
|
|
1138
|
+
stringifyId(cartItem?.serialNumber),
|
|
1139
|
+
stringifyId(cartItem?.displayName),
|
|
1140
|
+
...collectSemanticStrings(placement.drawingClassificationEvidence),
|
|
1141
|
+
...collectSemanticStrings(placement.viewAssociationEvidence),
|
|
1142
|
+
...collectSemanticStrings(cartItem?.drawingClassificationEvidence),
|
|
1143
|
+
...collectSemanticStrings(cartItem?.itemPayload),
|
|
1144
|
+
...collectSemanticStrings(cartItem?.originalItemPayload),
|
|
1145
|
+
...collectSemanticStrings(cartItem?.orderReadyPayload),
|
|
1146
|
+
]
|
|
1147
|
+
.filter(Boolean)
|
|
1148
|
+
.map((value) => value!.toLowerCase());
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function getReviewedObjectTypeStrings(value: unknown, depth = 0): string[] {
|
|
1152
|
+
if (value === null || value === undefined || depth > 4) {
|
|
1153
|
+
return [];
|
|
1154
|
+
}
|
|
1155
|
+
if (typeof value === 'string') {
|
|
1156
|
+
const parsed = parseJsonObject(value);
|
|
1157
|
+
return parsed ? getReviewedObjectTypeStrings(parsed, depth + 1) : [];
|
|
1158
|
+
}
|
|
1159
|
+
if (Array.isArray(value)) {
|
|
1160
|
+
return value.flatMap((item) => getReviewedObjectTypeStrings(item, depth + 1));
|
|
1161
|
+
}
|
|
1162
|
+
if (typeof value !== 'object') {
|
|
1163
|
+
return [];
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const record = value as Record<string, unknown>;
|
|
1167
|
+
const directObjectType = stringifyId(record.objectType);
|
|
1168
|
+
const objectTypeValues = directObjectType ? [directObjectType] : [];
|
|
1169
|
+
return [
|
|
1170
|
+
...objectTypeValues,
|
|
1171
|
+
...getReviewedObjectTypeStrings(record.sourceElevationObject, depth + 1),
|
|
1172
|
+
...getReviewedObjectTypeStrings(record.sourcePlanObject, depth + 1),
|
|
1173
|
+
];
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function collectSemanticStrings(value: unknown, depth = 0): string[] {
|
|
1177
|
+
if (value === null || value === undefined || depth > 4) {
|
|
1178
|
+
return [];
|
|
1179
|
+
}
|
|
1180
|
+
if (typeof value === 'string') {
|
|
1181
|
+
const parsed = parseJsonObject(value);
|
|
1182
|
+
return parsed ? collectSemanticStrings(parsed, depth + 1) : [value];
|
|
1183
|
+
}
|
|
1184
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1185
|
+
return [String(value)];
|
|
1186
|
+
}
|
|
1187
|
+
if (Array.isArray(value)) {
|
|
1188
|
+
return value.flatMap((item) => collectSemanticStrings(item, depth + 1));
|
|
1189
|
+
}
|
|
1190
|
+
if (typeof value === 'object') {
|
|
1191
|
+
return Object.values(value as Record<string, unknown>).flatMap((item) => collectSemanticStrings(item, depth + 1));
|
|
1192
|
+
}
|
|
1193
|
+
return [];
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function parseJsonObject(value: string): Record<string, unknown> | unknown[] | null {
|
|
1197
|
+
const trimmed = value.trim();
|
|
1198
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1201
|
+
try {
|
|
1202
|
+
const parsed = JSON.parse(trimmed);
|
|
1203
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
1204
|
+
} catch {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function buildPlacementLabel(positionName: string, cartItem: CartItemLike | undefined, aiCartItemId: string | undefined): string {
|
|
1210
|
+
const serialNumber = stringifyId(cartItem?.serialNumber);
|
|
1211
|
+
if (serialNumber) {
|
|
1212
|
+
return `${positionName} ${serialNumber}`;
|
|
1213
|
+
}
|
|
1214
|
+
const traceId = aiCartItemId ? shortId(aiCartItemId) : undefined;
|
|
1215
|
+
return traceId ? `${positionName} ${traceId}` : positionName;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function normalizeArray<T>(value: unknown): T[] {
|
|
1219
|
+
if (!value) {
|
|
1220
|
+
return [];
|
|
1221
|
+
}
|
|
1222
|
+
return Array.isArray(value) ? value as T[] : [value as T];
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function isCanvasType(value: unknown): value is AiDrawingReviewCanvasType {
|
|
1226
|
+
return typeof value === 'string' && (CANVAS_TYPES as string[]).includes(value);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function stringifyId(value: unknown): string | undefined {
|
|
1230
|
+
if (value === null || value === undefined) {
|
|
1231
|
+
return undefined;
|
|
1232
|
+
}
|
|
1233
|
+
const stringValue = String(value).trim();
|
|
1234
|
+
return stringValue.length > 0 ? stringValue : undefined;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function toFiniteNumber(value: unknown): number | null {
|
|
1238
|
+
if (typeof value === 'number') {
|
|
1239
|
+
return Number.isFinite(value) ? value : null;
|
|
1240
|
+
}
|
|
1241
|
+
if (typeof value === 'string') {
|
|
1242
|
+
const parsed = Number(value);
|
|
1243
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function shortId(value: string): string {
|
|
1249
|
+
return value.replace(/-/g, '').slice(0, 8);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function sanitizeFilePart(value: string): string {
|
|
1253
|
+
return value.replace(/[^a-z0-9_-]+/gi, '_').slice(0, 80);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const FONT_3X5: Record<string, string[]> = {
|
|
1257
|
+
' ': ['000', '000', '000', '000', '000'],
|
|
1258
|
+
'?': ['111', '001', '011', '000', '010'],
|
|
1259
|
+
'-': ['000', '000', '111', '000', '000'],
|
|
1260
|
+
'_': ['000', '000', '000', '000', '111'],
|
|
1261
|
+
'0': ['111', '101', '101', '101', '111'],
|
|
1262
|
+
'1': ['010', '110', '010', '010', '111'],
|
|
1263
|
+
'2': ['111', '001', '111', '100', '111'],
|
|
1264
|
+
'3': ['111', '001', '111', '001', '111'],
|
|
1265
|
+
'4': ['101', '101', '111', '001', '001'],
|
|
1266
|
+
'5': ['111', '100', '111', '001', '111'],
|
|
1267
|
+
'6': ['111', '100', '111', '101', '111'],
|
|
1268
|
+
'7': ['111', '001', '010', '010', '010'],
|
|
1269
|
+
'8': ['111', '101', '111', '101', '111'],
|
|
1270
|
+
'9': ['111', '101', '111', '001', '111'],
|
|
1271
|
+
'A': ['010', '101', '111', '101', '101'],
|
|
1272
|
+
'B': ['110', '101', '110', '101', '110'],
|
|
1273
|
+
'C': ['111', '100', '100', '100', '111'],
|
|
1274
|
+
'D': ['110', '101', '101', '101', '110'],
|
|
1275
|
+
'E': ['111', '100', '110', '100', '111'],
|
|
1276
|
+
'F': ['111', '100', '110', '100', '100'],
|
|
1277
|
+
'G': ['111', '100', '101', '101', '111'],
|
|
1278
|
+
'H': ['101', '101', '111', '101', '101'],
|
|
1279
|
+
'I': ['111', '010', '010', '010', '111'],
|
|
1280
|
+
'J': ['001', '001', '001', '101', '111'],
|
|
1281
|
+
'K': ['101', '101', '110', '101', '101'],
|
|
1282
|
+
'L': ['100', '100', '100', '100', '111'],
|
|
1283
|
+
'M': ['101', '111', '111', '101', '101'],
|
|
1284
|
+
'N': ['101', '111', '111', '111', '101'],
|
|
1285
|
+
'O': ['111', '101', '101', '101', '111'],
|
|
1286
|
+
'P': ['111', '101', '111', '100', '100'],
|
|
1287
|
+
'Q': ['111', '101', '101', '111', '001'],
|
|
1288
|
+
'R': ['111', '101', '111', '110', '101'],
|
|
1289
|
+
'S': ['111', '100', '111', '001', '111'],
|
|
1290
|
+
'T': ['111', '010', '010', '010', '010'],
|
|
1291
|
+
'U': ['101', '101', '101', '101', '111'],
|
|
1292
|
+
'V': ['101', '101', '101', '101', '010'],
|
|
1293
|
+
'W': ['101', '101', '111', '111', '101'],
|
|
1294
|
+
'X': ['101', '101', '010', '101', '101'],
|
|
1295
|
+
'Y': ['101', '101', '010', '010', '010'],
|
|
1296
|
+
'Z': ['111', '001', '010', '100', '111'],
|
|
1297
|
+
};
|