hoi4-agent-tools 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +20 -8
- package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
- package/dist/hoi4_agent_tools/core/index.js +1 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +19 -1
- package/dist/hoi4_agent_tools/core/operation-result.js +4 -1
- package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
- package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
- package/dist/hoi4_agent_tools/event/operations.js +32 -10
- package/dist/hoi4_agent_tools/event/service.js +21 -2
- package/dist/hoi4_agent_tools/focus/compact.js +241 -27
- package/dist/hoi4_agent_tools/focus/layout.js +119 -8
- package/dist/hoi4_agent_tools/focus/render.js +62 -0
- package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
- package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
- package/dist/hoi4_agent_tools/gui/assets.js +54 -4
- package/dist/hoi4_agent_tools/gui/layout.js +935 -104
- package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
- package/dist/hoi4_agent_tools/gui/shader.js +515 -0
- package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
- package/dist/hoi4_agent_tools/gui/studio.js +245 -19
- package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
- package/dist/hoi4_agent_tools/gui/validators.js +41 -12
- package/dist/hoi4_agent_tools/map/operations.js +70 -1
- package/dist/hoi4_agent_tools/map/render.js +70 -1
- package/dist/hoi4_agent_tools/map/service.js +30 -6
- package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
- package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
- package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
- package/dist/hoi4_agent_tools/mechanic/interpreter.js +91 -30
- package/dist/hoi4_agent_tools/mechanic/source.js +19 -4
- package/dist/hoi4_agent_tools/probability/evaluation.js +3 -0
- package/dist/hoi4_agent_tools/probability/operations.js +8 -0
- package/dist/hoi4_agent_tools/probability/service.js +33 -12
- package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
- package/dist/hoi4_agent_tools/schemas/event.js +8 -0
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
- package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
- package/dist/hoi4_agent_tools/technology/graph.js +25 -10
- package/dist/hoi4_agent_tools/technology/operations.js +29 -1
- package/dist/hoi4_agent_tools/technology/render.js +287 -31
- package/dist/hoi4_agent_tools/technology/service.js +56 -8
- package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/events.md +4 -2
- package/docs/focus.md +10 -0
- package/docs/gui.md +50 -3
- package/docs/jobs.md +1 -0
- package/docs/map.md +8 -0
- package/docs/mechanics.md +2 -1
- package/docs/probability.md +3 -1
- package/docs/technology.md +11 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/server.json +2 -2
|
@@ -226,18 +226,155 @@ function straightenLinearChains(plan, preferredX, preferredY) {
|
|
|
226
226
|
preferredY.set(focus.id, parentY + 1);
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
|
-
function
|
|
229
|
+
function validatedSelection(plan, selection) {
|
|
230
|
+
if (selection.focusIds?.length === 0)
|
|
231
|
+
throw new ServiceError('FOCUS_COMPACT_SELECTION_EMPTY', 'Compact focus selection requires at least one focus');
|
|
232
|
+
const known = new Set(plan.focuses.map(({ id }) => id));
|
|
233
|
+
for (const id of [...(selection.focusIds ?? []), ...(selection.pinnedFocusIds ?? [])])
|
|
234
|
+
if (!known.has(id))
|
|
235
|
+
throw new ServiceError('FOCUS_COMPACT_SELECTION_UNKNOWN', 'Compact focus selection names an unknown focus', { treeId: plan.id, focusId: id });
|
|
236
|
+
const used = new Set();
|
|
237
|
+
for (const group of selection.symmetryGroups ?? []) {
|
|
238
|
+
if (!known.has(group.centerFocusId))
|
|
239
|
+
throw new ServiceError('FOCUS_COMPACT_SYMMETRY_UNKNOWN', 'Symmetry center is unknown', {
|
|
240
|
+
treeId: plan.id,
|
|
241
|
+
focusId: group.centerFocusId,
|
|
242
|
+
});
|
|
243
|
+
for (const pair of group.pairs) {
|
|
244
|
+
for (const id of [pair.leftFocusId, pair.rightFocusId]) {
|
|
245
|
+
if (!known.has(id))
|
|
246
|
+
throw new ServiceError('FOCUS_COMPACT_SYMMETRY_UNKNOWN', 'Symmetry focus is unknown', {
|
|
247
|
+
treeId: plan.id,
|
|
248
|
+
focusId: id,
|
|
249
|
+
});
|
|
250
|
+
if (id === group.centerFocusId || used.has(id))
|
|
251
|
+
throw new ServiceError('FOCUS_COMPACT_SYMMETRY_DUPLICATE', 'A symmetry focus must belong to one pair and differ from its center', { treeId: plan.id, focusId: id });
|
|
252
|
+
used.add(id);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
...(selection.focusIds === undefined ? {} : { focusIds: new Set(selection.focusIds) }),
|
|
258
|
+
pinnedFocusIds: new Set(selection.pinnedFocusIds ?? []),
|
|
259
|
+
symmetryGroups: selection.symmetryGroups ?? [],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function symmetryViolations(layout, groups) {
|
|
263
|
+
const positions = new Map(layout.nodes.map(({ id, x, y }) => [id, { x, y }]));
|
|
264
|
+
return groups.flatMap(({ centerFocusId, pairs }) => {
|
|
265
|
+
const center = positions.get(centerFocusId);
|
|
266
|
+
if (center === undefined)
|
|
267
|
+
return [];
|
|
268
|
+
return pairs.flatMap(({ leftFocusId, rightFocusId }) => {
|
|
269
|
+
const left = positions.get(leftFocusId);
|
|
270
|
+
const right = positions.get(rightFocusId);
|
|
271
|
+
if (left === undefined || right === undefined)
|
|
272
|
+
return [];
|
|
273
|
+
const xOffset = left.x + right.x - 2 * center.x;
|
|
274
|
+
const yOffset = left.y - right.y;
|
|
275
|
+
return Math.abs(xOffset) <= 1 && yOffset === 0
|
|
276
|
+
? []
|
|
277
|
+
: [{ centerFocusId, leftFocusId, rightFocusId, xOffset, yOffset }];
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function applySymmetryGroups(selection, layout, preferredX, preferredY) {
|
|
282
|
+
const original = new Map(layout.nodes.map(({ id, x, y }) => [id, { x, y }]));
|
|
283
|
+
const fixed = (id) => selection.pinnedFocusIds.has(id) || selection.focusIds?.has(id) === false;
|
|
284
|
+
for (const { centerFocusId, pairs } of selection.symmetryGroups) {
|
|
285
|
+
const centerX = fixed(centerFocusId)
|
|
286
|
+
? original.get(centerFocusId)?.x
|
|
287
|
+
: preferredX.get(centerFocusId);
|
|
288
|
+
if (centerX === undefined)
|
|
289
|
+
continue;
|
|
290
|
+
for (const { leftFocusId, rightFocusId } of pairs) {
|
|
291
|
+
const leftFixed = fixed(leftFocusId);
|
|
292
|
+
const rightFixed = fixed(rightFocusId);
|
|
293
|
+
const leftX = leftFixed ? original.get(leftFocusId)?.x : preferredX.get(leftFocusId);
|
|
294
|
+
const rightX = rightFixed ? original.get(rightFocusId)?.x : preferredX.get(rightFocusId);
|
|
295
|
+
if (leftX === undefined || rightX === undefined)
|
|
296
|
+
continue;
|
|
297
|
+
if (!leftFixed && !rightFixed) {
|
|
298
|
+
const distance = Math.max(1, Math.round((rightX - leftX) / 2));
|
|
299
|
+
preferredX.set(leftFocusId, centerX - distance);
|
|
300
|
+
preferredX.set(rightFocusId, centerX + distance);
|
|
301
|
+
}
|
|
302
|
+
else if (!leftFixed)
|
|
303
|
+
preferredX.set(leftFocusId, 2 * centerX - rightX);
|
|
304
|
+
else if (!rightFixed)
|
|
305
|
+
preferredX.set(rightFocusId, 2 * centerX - leftX);
|
|
306
|
+
const leftY = leftFixed ? original.get(leftFocusId)?.y : preferredY.get(leftFocusId);
|
|
307
|
+
const rightY = rightFixed ? original.get(rightFocusId)?.y : preferredY.get(rightFocusId);
|
|
308
|
+
if (leftY === undefined || rightY === undefined)
|
|
309
|
+
continue;
|
|
310
|
+
const row = leftFixed ? leftY : rightFixed ? rightY : Math.round((leftY + rightY) / 2);
|
|
311
|
+
if (!leftFixed)
|
|
312
|
+
preferredY.set(leftFocusId, row);
|
|
313
|
+
if (!rightFixed)
|
|
314
|
+
preferredY.set(rightFocusId, row);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function scopedPrevious(current, focusIds) {
|
|
319
|
+
return { ...current, nodes: current.nodes.filter(({ id }) => !focusIds.has(id)) };
|
|
320
|
+
}
|
|
321
|
+
function movedOutsideSelection(current, proposed, focusIds, pinnedFocusIds) {
|
|
322
|
+
const fixed = new Set([
|
|
323
|
+
...current.nodes.filter(({ id }) => focusIds?.has(id) === false).map(({ id }) => id),
|
|
324
|
+
...pinnedFocusIds,
|
|
325
|
+
]);
|
|
326
|
+
const before = new Map(current.nodes.map(({ id, x, y }) => [id, { x, y }]));
|
|
327
|
+
return proposed.nodes
|
|
328
|
+
.filter(({ id, x, y }) => {
|
|
329
|
+
const original = before.get(id);
|
|
330
|
+
return fixed.has(id) && (original?.x !== x || original.y !== y);
|
|
331
|
+
})
|
|
332
|
+
.map(({ id }) => id)
|
|
333
|
+
.sort(compareCodeUnits);
|
|
334
|
+
}
|
|
335
|
+
function relativeCompactRegressions(current, proposed) {
|
|
336
|
+
const before = requiredMetrics(current);
|
|
337
|
+
const after = requiredMetrics(proposed);
|
|
338
|
+
const hard = (layout) => layout.diagnostics.filter(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code)).length;
|
|
339
|
+
return [
|
|
340
|
+
...(hard(proposed) > hard(current) ? ['hardLayoutDiagnostics'] : []),
|
|
341
|
+
...(after.connectors.crossingCount > before.connectors.crossingCount
|
|
342
|
+
? ['connectorCrossingCount']
|
|
343
|
+
: []),
|
|
344
|
+
...(after.connectors.nodeIntersectionCount > before.connectors.nodeIntersectionCount
|
|
345
|
+
? ['connectorNodeIntersections']
|
|
346
|
+
: []),
|
|
347
|
+
...(after.spacing.tooCloseSameRowPairCount > before.spacing.tooCloseSameRowPairCount
|
|
348
|
+
? ['sameRowSpacing']
|
|
349
|
+
: []),
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
function compactCandidate(plan, layout, strategy, selection = {
|
|
353
|
+
pinnedFocusIds: new Set(),
|
|
354
|
+
symmetryGroups: [],
|
|
355
|
+
}) {
|
|
230
356
|
const { scale, compressRows, structuralRows: useStructuralRows, mirrorSiblings, straightenChains, } = strategy;
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
357
|
+
const scopeNodes = selection.focusIds === undefined
|
|
358
|
+
? layout.nodes
|
|
359
|
+
: layout.nodes.filter(({ id }) => selection.focusIds.has(id));
|
|
360
|
+
const minimumX = Math.min(...scopeNodes.map(({ x }) => x));
|
|
361
|
+
const maximumX = Math.max(...scopeNodes.map(({ x }) => x));
|
|
362
|
+
const minimumY = Math.min(...scopeNodes.map(({ y }) => y));
|
|
234
363
|
const centerX = (minimumX + maximumX) / 2;
|
|
235
|
-
const preferredX = new Map(layout.nodes.map((node) => [
|
|
236
|
-
|
|
364
|
+
const preferredX = new Map(layout.nodes.map((node) => [
|
|
365
|
+
node.id,
|
|
366
|
+
Math.round((node.x - centerX) * scale + (selection.focusIds === undefined ? 0 : centerX)),
|
|
367
|
+
]));
|
|
368
|
+
const sourceRows = [...new Set(scopeNodes.map(({ y }) => y))].sort((left, right) => left - right);
|
|
237
369
|
const compactRows = new Map(sourceRows.map((row, index) => [row, index]));
|
|
238
370
|
const preferredY = new Map(layout.nodes.map((node) => [
|
|
239
371
|
node.id,
|
|
240
|
-
compressRows
|
|
372
|
+
compressRows
|
|
373
|
+
? (compactRows.get(node.y) ?? node.y - minimumY) +
|
|
374
|
+
(selection.focusIds === undefined ? 0 : minimumY)
|
|
375
|
+
: selection.focusIds === undefined
|
|
376
|
+
? node.y - minimumY
|
|
377
|
+
: node.y,
|
|
241
378
|
]));
|
|
242
379
|
if (useStructuralRows) {
|
|
243
380
|
const normalizedRows = structuralRows(plan);
|
|
@@ -270,13 +407,28 @@ function compactCandidate(plan, layout, strategy) {
|
|
|
270
407
|
mirrorSiblingCohorts(plan, preferredX, preferredY);
|
|
271
408
|
if (straightenChains)
|
|
272
409
|
straightenLinearChains(plan, preferredX, preferredY);
|
|
410
|
+
applySymmetryGroups(selection, layout, preferredX, preferredY);
|
|
273
411
|
const compacted = structuredClone(plan);
|
|
274
|
-
|
|
275
|
-
id,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
412
|
+
if (selection.focusIds === undefined)
|
|
413
|
+
compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
|
|
414
|
+
id,
|
|
415
|
+
label,
|
|
416
|
+
order,
|
|
417
|
+
}));
|
|
418
|
+
const currentCoordinates = new Map(layout.nodes.map(({ id, x, y }) => [id, { x, y }]));
|
|
279
419
|
for (const focus of compacted.focuses) {
|
|
420
|
+
if (selection.focusIds !== undefined && !selection.focusIds.has(focus.id))
|
|
421
|
+
continue;
|
|
422
|
+
if (selection.pinnedFocusIds.has(focus.id)) {
|
|
423
|
+
if (focus.position.mode === 'auto') {
|
|
424
|
+
const point = currentCoordinates.get(focus.id);
|
|
425
|
+
if (point !== undefined)
|
|
426
|
+
focus.position = { mode: 'fixed', ...point, pinned: true };
|
|
427
|
+
}
|
|
428
|
+
else
|
|
429
|
+
focus.position = { ...focus.position, pinned: true };
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
280
432
|
focus.position = {
|
|
281
433
|
mode: 'auto',
|
|
282
434
|
pinned: false,
|
|
@@ -390,7 +542,8 @@ function needsPresentationNormalization(plan) {
|
|
|
390
542
|
* repair and compression candidates are measured with the same layout engine.
|
|
391
543
|
* Invalid geometry cannot win; aesthetic tradeoffs are resolved by the score.
|
|
392
544
|
*/
|
|
393
|
-
export function compactFocusTreePlan(plan) {
|
|
545
|
+
export function compactFocusTreePlan(plan, requested = {}) {
|
|
546
|
+
const selection = validatedSelection(plan, requested);
|
|
394
547
|
const currentLayout = layoutFocusTree(plan, { aggressiveAestheticRepair: true });
|
|
395
548
|
const normalizedPlan = needsPresentationNormalization(plan)
|
|
396
549
|
? compactCandidate(plan, currentLayout, {
|
|
@@ -399,27 +552,51 @@ export function compactFocusTreePlan(plan) {
|
|
|
399
552
|
structuralRows: false,
|
|
400
553
|
mirrorSiblings: false,
|
|
401
554
|
straightenChains: false,
|
|
402
|
-
})
|
|
555
|
+
}, selection)
|
|
403
556
|
: plan;
|
|
404
557
|
const normalizedLayout = normalizedPlan === plan
|
|
405
558
|
? currentLayout
|
|
406
|
-
: layoutFocusTree(normalizedPlan, {
|
|
559
|
+
: layoutFocusTree(normalizedPlan, {
|
|
560
|
+
aggressiveAestheticRepair: true,
|
|
561
|
+
...(selection.focusIds === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: { previous: scopedPrevious(currentLayout, selection.focusIds) }),
|
|
564
|
+
});
|
|
407
565
|
let selected;
|
|
408
566
|
let fallback;
|
|
567
|
+
const blockedFocusIds = new Set();
|
|
568
|
+
let blockedSymmetry = [];
|
|
409
569
|
for (const strategy of compactStrategies(plan.focuses.length)) {
|
|
410
|
-
const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
|
|
570
|
+
const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy, selection);
|
|
411
571
|
let layout;
|
|
412
572
|
try {
|
|
413
|
-
layout = layoutFocusTree(candidate, {
|
|
573
|
+
layout = layoutFocusTree(candidate, {
|
|
574
|
+
aggressiveAestheticRepair: true,
|
|
575
|
+
...(selection.focusIds === undefined
|
|
576
|
+
? {}
|
|
577
|
+
: { previous: scopedPrevious(currentLayout, selection.focusIds) }),
|
|
578
|
+
});
|
|
414
579
|
}
|
|
415
580
|
catch (error) {
|
|
416
581
|
if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
|
|
417
582
|
continue;
|
|
418
583
|
throw error;
|
|
419
584
|
}
|
|
585
|
+
const moved = movedOutsideSelection(currentLayout, layout, selection.focusIds, selection.pinnedFocusIds);
|
|
586
|
+
for (const id of moved)
|
|
587
|
+
blockedFocusIds.add(id);
|
|
588
|
+
if (moved.length > 0)
|
|
589
|
+
continue;
|
|
590
|
+
const symmetry = symmetryViolations(layout, selection.symmetryGroups);
|
|
591
|
+
if (symmetry.length > 0) {
|
|
592
|
+
blockedSymmetry = symmetry;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
420
595
|
if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
|
|
421
596
|
fallback = { plan: candidate, layout };
|
|
422
|
-
if (
|
|
597
|
+
if ((selection.focusIds === undefined
|
|
598
|
+
? absoluteCompactRegressions(layout)
|
|
599
|
+
: relativeCompactRegressions(currentLayout, layout)).length > 0)
|
|
423
600
|
continue;
|
|
424
601
|
if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
|
|
425
602
|
selected = { plan: candidate, layout };
|
|
@@ -427,9 +604,13 @@ export function compactFocusTreePlan(plan) {
|
|
|
427
604
|
if (selected !== undefined)
|
|
428
605
|
return selected.plan;
|
|
429
606
|
if (fallback !== undefined) {
|
|
430
|
-
assertCompactLayoutQuality(currentLayout, fallback.layout);
|
|
607
|
+
assertCompactLayoutQuality(currentLayout, fallback.layout, selection.focusIds !== undefined);
|
|
431
608
|
return fallback.plan;
|
|
432
609
|
}
|
|
610
|
+
if (blockedFocusIds.size > 0)
|
|
611
|
+
throw new ServiceError('FOCUS_COMPACT_ANCHOR_CONFLICT', 'No compact candidate preserves the selected branch boundary and pinned anchors', { treeId: plan.id, movedFocusIds: [...blockedFocusIds].sort(compareCodeUnits) });
|
|
612
|
+
if (blockedSymmetry.length > 0)
|
|
613
|
+
throw new ServiceError('FOCUS_COMPACT_SYMMETRY_CONFLICT', 'No compact candidate satisfies the requested symmetry pairs', { treeId: plan.id, pairs: blockedSymmetry });
|
|
433
614
|
throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
|
|
434
615
|
}
|
|
435
616
|
/**
|
|
@@ -437,6 +618,7 @@ export function compactFocusTreePlan(plan) {
|
|
|
437
618
|
* one work budget, and the selected layout is returned for validation/rendering.
|
|
438
619
|
*/
|
|
439
620
|
export async function compactFocusTreePlanAsync(plan, options = {}) {
|
|
621
|
+
const selection = validatedSelection(plan, options);
|
|
440
622
|
const workBudget = new FocusLayoutWorkBudget(options.maximumWork ?? COMPACT_LAYOUT_WORK_MAX);
|
|
441
623
|
const layoutOptions = {
|
|
442
624
|
workBudget,
|
|
@@ -451,20 +633,32 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
|
|
|
451
633
|
structuralRows: false,
|
|
452
634
|
mirrorSiblings: false,
|
|
453
635
|
straightenChains: false,
|
|
454
|
-
})
|
|
636
|
+
}, selection)
|
|
455
637
|
: plan;
|
|
456
638
|
const normalizedLayout = normalizedPlan === plan
|
|
457
639
|
? currentLayout
|
|
458
|
-
: await layoutFocusTreeAsync(normalizedPlan,
|
|
640
|
+
: await layoutFocusTreeAsync(normalizedPlan, {
|
|
641
|
+
...layoutOptions,
|
|
642
|
+
...(selection.focusIds === undefined
|
|
643
|
+
? {}
|
|
644
|
+
: { previous: scopedPrevious(currentLayout, selection.focusIds) }),
|
|
645
|
+
});
|
|
459
646
|
let selected;
|
|
460
647
|
let fallback;
|
|
461
648
|
let exhausted;
|
|
649
|
+
const blockedFocusIds = new Set();
|
|
650
|
+
let blockedSymmetry = [];
|
|
462
651
|
candidateSearch: for (const strategy of compactStrategies(plan.focuses.length)) {
|
|
463
652
|
options.signal?.throwIfAborted();
|
|
464
|
-
const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
|
|
653
|
+
const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy, selection);
|
|
465
654
|
let layout;
|
|
466
655
|
try {
|
|
467
|
-
layout = await layoutFocusTreeAsync(candidate,
|
|
656
|
+
layout = await layoutFocusTreeAsync(candidate, {
|
|
657
|
+
...layoutOptions,
|
|
658
|
+
...(selection.focusIds === undefined
|
|
659
|
+
? {}
|
|
660
|
+
: { previous: scopedPrevious(currentLayout, selection.focusIds) }),
|
|
661
|
+
});
|
|
468
662
|
}
|
|
469
663
|
catch (error) {
|
|
470
664
|
if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED') {
|
|
@@ -473,9 +667,21 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
|
|
|
473
667
|
}
|
|
474
668
|
throw error;
|
|
475
669
|
}
|
|
670
|
+
const moved = movedOutsideSelection(currentLayout, layout, selection.focusIds, selection.pinnedFocusIds);
|
|
671
|
+
for (const id of moved)
|
|
672
|
+
blockedFocusIds.add(id);
|
|
673
|
+
if (moved.length > 0)
|
|
674
|
+
continue;
|
|
675
|
+
const symmetry = symmetryViolations(layout, selection.symmetryGroups);
|
|
676
|
+
if (symmetry.length > 0) {
|
|
677
|
+
blockedSymmetry = symmetry;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
476
680
|
if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
|
|
477
681
|
fallback = { plan: candidate, layout };
|
|
478
|
-
if (
|
|
682
|
+
if ((selection.focusIds === undefined
|
|
683
|
+
? absoluteCompactRegressions(layout)
|
|
684
|
+
: relativeCompactRegressions(currentLayout, layout)).length > 0)
|
|
479
685
|
continue;
|
|
480
686
|
if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
|
|
481
687
|
selected = { plan: candidate, layout };
|
|
@@ -486,14 +692,22 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
|
|
|
486
692
|
if (exhausted !== undefined)
|
|
487
693
|
throw exhausted;
|
|
488
694
|
if (fallback !== undefined) {
|
|
489
|
-
assertCompactLayoutQuality(currentLayout, fallback.layout);
|
|
695
|
+
assertCompactLayoutQuality(currentLayout, fallback.layout, selection.focusIds !== undefined);
|
|
490
696
|
return { plan: fallback.plan, currentLayout, proposedLayout: fallback.layout };
|
|
491
697
|
}
|
|
698
|
+
if (blockedFocusIds.size > 0)
|
|
699
|
+
throw new ServiceError('FOCUS_COMPACT_ANCHOR_CONFLICT', 'No compact candidate preserves the selected branch boundary and pinned anchors', { treeId: plan.id, movedFocusIds: [...blockedFocusIds].sort(compareCodeUnits) });
|
|
700
|
+
if (blockedSymmetry.length > 0)
|
|
701
|
+
throw new ServiceError('FOCUS_COMPACT_SYMMETRY_CONFLICT', 'No compact candidate satisfies the requested symmetry pairs', { treeId: plan.id, pairs: blockedSymmetry });
|
|
492
702
|
throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
|
|
493
703
|
}
|
|
494
704
|
/** Refuses compact rewrites that still contain invalid or unreadable geometry. */
|
|
495
|
-
export function assertCompactLayoutQuality(_current, proposed) {
|
|
496
|
-
const regressions = [
|
|
705
|
+
export function assertCompactLayoutQuality(_current, proposed, relativeToBaseline = false) {
|
|
706
|
+
const regressions = [
|
|
707
|
+
...(relativeToBaseline && _current !== undefined
|
|
708
|
+
? relativeCompactRegressions(_current, proposed)
|
|
709
|
+
: absoluteCompactRegressions(proposed)),
|
|
710
|
+
].filter((value, index, all) => all.indexOf(value) === index);
|
|
497
711
|
if (regressions.length === 0)
|
|
498
712
|
return;
|
|
499
713
|
throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the layout-correctness gate', {
|
|
@@ -3,7 +3,7 @@ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
|
|
|
3
3
|
import { DiagnosticCollector } from '../core/diagnostic-collector.js';
|
|
4
4
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
5
5
|
import { ServiceError } from '../core/result.js';
|
|
6
|
-
import { flattenFocusConnectorCurve, focusConnectorCurve, focusConnectorPolylinesIntersect, focusConnectorPolylineIntersectsRectangle, focusNodeRectangle, focusNodesVisiblyOverlap, } from './geometry.js';
|
|
6
|
+
import { flattenFocusConnectorCurve, focusConnectorCurve, focusConnectorPolylinesIntersect, focusConnectorPolylineIntersectsRectangle, focusNodeRectangle, focusNodesVisiblyOverlap, FOCUS_HORIZONTAL_GRID_PIXELS, FOCUS_NODE_HEIGHT_PIXELS, FOCUS_NODE_WIDTH_PIXELS, FOCUS_VERTICAL_GRID_PIXELS, } from './geometry.js';
|
|
7
7
|
import { FOCUS_GRAPH_MAX_DEPTH, FOCUS_DIAGNOSTIC_MAX, FOCUS_GRAPH_MAX_EDGES, FOCUS_GRAPH_MAX_NODES, FOCUS_LAYOUT_WORK_MAX, } from './limits.js';
|
|
8
8
|
import {} from './model.js';
|
|
9
9
|
export class FocusLayoutWorkBudget {
|
|
@@ -264,14 +264,61 @@ function* connectorCrossings(edges, context) {
|
|
|
264
264
|
edge,
|
|
265
265
|
points: flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child)),
|
|
266
266
|
}));
|
|
267
|
+
// Most focus connectors span one or two grid rows. Index those rows so
|
|
268
|
+
// unrelated branches do not consume the fixed work budget as an E² scan.
|
|
269
|
+
const maximumBucketSpan = 64;
|
|
270
|
+
const byRow = new Map();
|
|
271
|
+
const longEdges = [];
|
|
272
|
+
const intervals = edges.map(({ parent, child }) => ({
|
|
273
|
+
minimum: Math.min(parent.y, child.y),
|
|
274
|
+
maximum: Math.max(parent.y, child.y),
|
|
275
|
+
}));
|
|
276
|
+
for (const [index, interval] of intervals.entries()) {
|
|
277
|
+
const span = interval.maximum - interval.minimum;
|
|
278
|
+
if (span <= 0)
|
|
279
|
+
continue;
|
|
280
|
+
if (!Number.isSafeInteger(interval.minimum) ||
|
|
281
|
+
!Number.isSafeInteger(span) ||
|
|
282
|
+
span > maximumBucketSpan) {
|
|
283
|
+
longEdges.push(index);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
for (let row = interval.minimum; row < interval.maximum; row += 1) {
|
|
287
|
+
context.work.spend('connector row index');
|
|
288
|
+
const bucket = byRow.get(row) ?? [];
|
|
289
|
+
bucket.push(index);
|
|
290
|
+
byRow.set(row, bucket);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
267
293
|
let crossingCount = 0;
|
|
268
294
|
for (let leftIndex = 0; leftIndex < flattened.length; leftIndex += 1) {
|
|
269
295
|
yield* cancellationCheckpoint(context.signal, leftIndex, 16);
|
|
270
296
|
const left = flattened[leftIndex];
|
|
271
297
|
if (left === undefined)
|
|
272
298
|
continue;
|
|
273
|
-
|
|
274
|
-
|
|
299
|
+
const interval = intervals[leftIndex];
|
|
300
|
+
const span = interval.maximum - interval.minimum;
|
|
301
|
+
if (span <= 0)
|
|
302
|
+
continue;
|
|
303
|
+
const candidates = new Set();
|
|
304
|
+
if (span > maximumBucketSpan ||
|
|
305
|
+
!Number.isSafeInteger(interval.minimum) ||
|
|
306
|
+
!Number.isSafeInteger(span)) {
|
|
307
|
+
for (let index = leftIndex + 1; index < edges.length; index += 1)
|
|
308
|
+
candidates.add(index);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
for (let row = interval.minimum; row < interval.maximum; row += 1)
|
|
312
|
+
for (const index of byRow.get(row) ?? [])
|
|
313
|
+
if (index > leftIndex)
|
|
314
|
+
candidates.add(index);
|
|
315
|
+
for (const index of longEdges)
|
|
316
|
+
if (index > leftIndex)
|
|
317
|
+
candidates.add(index);
|
|
318
|
+
}
|
|
319
|
+
const orderedCandidates = [...candidates].sort((leftIndex, rightIndex) => leftIndex - rightIndex);
|
|
320
|
+
for (const [candidateIndex, rightIndex] of orderedCandidates.entries()) {
|
|
321
|
+
yield* cancellationCheckpoint(context.signal, candidateIndex, 64);
|
|
275
322
|
const right = edges[rightIndex];
|
|
276
323
|
const rightFlattened = flattened[rightIndex];
|
|
277
324
|
if (right === undefined || rightFlattened === undefined)
|
|
@@ -1062,15 +1109,33 @@ function* placeFocus(context, focusId, inheritedStack = new Set()) {
|
|
|
1062
1109
|
}
|
|
1063
1110
|
function* visibleOverlapDiagnostics(context) {
|
|
1064
1111
|
const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
1112
|
+
const cells = new Map();
|
|
1113
|
+
for (const node of nodes) {
|
|
1114
|
+
const key = `${Math.floor(node.x)},${Math.floor(node.y)}`;
|
|
1115
|
+
const cell = cells.get(key) ?? [];
|
|
1116
|
+
cell.push(node);
|
|
1117
|
+
cells.set(key, cell);
|
|
1118
|
+
}
|
|
1065
1119
|
for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
|
|
1066
1120
|
yield* cancellationCheckpoint(context.signal, leftIndex, 16);
|
|
1067
1121
|
const left = nodes[leftIndex];
|
|
1068
1122
|
if (left === undefined)
|
|
1069
1123
|
continue;
|
|
1070
|
-
|
|
1124
|
+
const candidates = [];
|
|
1125
|
+
const cellX = Math.floor(left.x);
|
|
1126
|
+
const cellY = Math.floor(left.y);
|
|
1127
|
+
for (let y = cellY - 1; y <= cellY + 1; y += 1) {
|
|
1128
|
+
for (let x = cellX - 1; x <= cellX + 1; x += 1) {
|
|
1129
|
+
for (const candidate of cells.get(`${x},${y}`) ?? []) {
|
|
1130
|
+
if (compareCodeUnits(candidate.id, left.id) > 0)
|
|
1131
|
+
candidates.push(candidate);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
candidates.sort((first, second) => compareCodeUnits(first.id, second.id));
|
|
1136
|
+
for (const right of candidates) {
|
|
1071
1137
|
context.work.spend('final visible-overlap comparison');
|
|
1072
|
-
|
|
1073
|
-
if (right === undefined || !focusNodesVisiblyOverlap(left, right))
|
|
1138
|
+
if (!focusNodesVisiblyOverlap(left, right))
|
|
1074
1139
|
continue;
|
|
1075
1140
|
const focus = context.focuses.get(right.id);
|
|
1076
1141
|
context.diagnostics.push({
|
|
@@ -1189,12 +1254,44 @@ function* mutualExclusionSpacingDiagnostics(context) {
|
|
|
1189
1254
|
}
|
|
1190
1255
|
}
|
|
1191
1256
|
function* connectorNodeIntersectionDiagnostics(context, edges) {
|
|
1192
|
-
const
|
|
1257
|
+
const nodesByY = [...context.placed.values()].sort((left, right) => left.y - right.y || compareCodeUnits(left.id, right.id));
|
|
1193
1258
|
let comparisonIndex = 0;
|
|
1194
1259
|
let intersectionCount = 0;
|
|
1195
1260
|
for (const edge of edges) {
|
|
1196
1261
|
const flattened = flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child));
|
|
1197
|
-
|
|
1262
|
+
let minimumX = Infinity;
|
|
1263
|
+
let maximumX = -Infinity;
|
|
1264
|
+
let minimumY = Infinity;
|
|
1265
|
+
let maximumY = -Infinity;
|
|
1266
|
+
for (const point of flattened) {
|
|
1267
|
+
minimumX = Math.min(minimumX, point.x);
|
|
1268
|
+
maximumX = Math.max(maximumX, point.x);
|
|
1269
|
+
minimumY = Math.min(minimumY, point.y);
|
|
1270
|
+
maximumY = Math.max(maximumY, point.y);
|
|
1271
|
+
}
|
|
1272
|
+
const minimumNodeY = (minimumY - FOCUS_NODE_HEIGHT_PIXELS) / FOCUS_VERTICAL_GRID_PIXELS;
|
|
1273
|
+
const maximumNodeY = maximumY / FOCUS_VERTICAL_GRID_PIXELS;
|
|
1274
|
+
const minimumNodeX = (minimumX - FOCUS_NODE_WIDTH_PIXELS) / FOCUS_HORIZONTAL_GRID_PIXELS;
|
|
1275
|
+
const maximumNodeX = maximumX / FOCUS_HORIZONTAL_GRID_PIXELS;
|
|
1276
|
+
let low = 0;
|
|
1277
|
+
let high = nodesByY.length;
|
|
1278
|
+
while (low < high) {
|
|
1279
|
+
const middle = Math.floor((low + high) / 2);
|
|
1280
|
+
if (nodesByY[middle].y < minimumNodeY)
|
|
1281
|
+
low = middle + 1;
|
|
1282
|
+
else
|
|
1283
|
+
high = middle;
|
|
1284
|
+
}
|
|
1285
|
+
const candidates = [];
|
|
1286
|
+
for (let index = low; index < nodesByY.length; index += 1) {
|
|
1287
|
+
const node = nodesByY[index];
|
|
1288
|
+
if (node.y > maximumNodeY)
|
|
1289
|
+
break;
|
|
1290
|
+
if (node.x >= minimumNodeX && node.x <= maximumNodeX)
|
|
1291
|
+
candidates.push(node);
|
|
1292
|
+
}
|
|
1293
|
+
candidates.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
1294
|
+
for (const node of candidates) {
|
|
1198
1295
|
yield* cancellationCheckpoint(context.signal, comparisonIndex, 32);
|
|
1199
1296
|
comparisonIndex += 1;
|
|
1200
1297
|
if (node.id === edge.parentId || node.id === edge.childId)
|
|
@@ -2196,6 +2293,20 @@ function* layoutFocusTreeSteps(plan, options = {}) {
|
|
|
2196
2293
|
decisions,
|
|
2197
2294
|
diagnostics: sortDiagnostics(context.diagnostics.values()),
|
|
2198
2295
|
metrics: layoutMetrics(context, edges, crossingSummary.count, nodeIntersectionCount, symmetry, spacing),
|
|
2296
|
+
connectorMeasurements: edges
|
|
2297
|
+
.map((edge) => {
|
|
2298
|
+
const spans = connectorSpans(edge);
|
|
2299
|
+
return {
|
|
2300
|
+
parentId: edge.parentId,
|
|
2301
|
+
childId: edge.childId,
|
|
2302
|
+
horizontalSpan: spans.horizontal,
|
|
2303
|
+
verticalSpan: spans.vertical,
|
|
2304
|
+
manhattanSpan: spans.manhattan,
|
|
2305
|
+
long: longConnectorSpans(spans),
|
|
2306
|
+
};
|
|
2307
|
+
})
|
|
2308
|
+
.sort((left, right) => compareCodeUnits(left.parentId, right.parentId) ||
|
|
2309
|
+
compareCodeUnits(left.childId, right.childId)),
|
|
2199
2310
|
layoutHash: hashCanonical(nodes.map(({ id, x, y, laneId }) => ({ id, laneId, x, y }))),
|
|
2200
2311
|
};
|
|
2201
2312
|
}
|
|
@@ -334,6 +334,7 @@ function svgDocument(plan, layout, diagnostics, options) {
|
|
|
334
334
|
nodes,
|
|
335
335
|
decisions: layout.decisions,
|
|
336
336
|
metrics: layout.metrics ?? null,
|
|
337
|
+
connectorMeasurements: layout.connectorMeasurements ?? null,
|
|
337
338
|
},
|
|
338
339
|
focuses: [...plan.focuses]
|
|
339
340
|
.sort((left, right) => compareCodeUnits(left.id, right.id))
|
|
@@ -452,6 +453,47 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
|
|
|
452
453
|
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
453
454
|
.toBuffer();
|
|
454
455
|
}
|
|
456
|
+
const problemCrops = [];
|
|
457
|
+
if ((options.cropFocusIds?.length ?? 0) > 0) {
|
|
458
|
+
if (options.cropFocusIds.length > 16)
|
|
459
|
+
throw new ServiceError('FOCUS_CROP_LIMIT', 'Focus render accepts at most 16 problem crops');
|
|
460
|
+
if (options.rasterize === false)
|
|
461
|
+
throw new ServiceError('FOCUS_CROP_REQUIRES_RASTER', 'Focus problem crops require raster output');
|
|
462
|
+
const nodes = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
463
|
+
const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
|
|
464
|
+
const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
|
|
465
|
+
const horizontal = options.horizontalSpacing ?? FOCUS_HORIZONTAL_GRID_PIXELS;
|
|
466
|
+
const vertical = options.verticalSpacing ?? FOCUS_VERTICAL_GRID_PIXELS;
|
|
467
|
+
const padding = options.padding ?? 80;
|
|
468
|
+
const scale = options.outputScale ?? 1;
|
|
469
|
+
for (const focusId of [...new Set(options.cropFocusIds)]) {
|
|
470
|
+
const node = nodes.get(focusId);
|
|
471
|
+
if (node === undefined)
|
|
472
|
+
throw new ServiceError('FOCUS_CROP_FOCUS_MISSING', 'Problem crop names an unknown focus', {
|
|
473
|
+
focusId,
|
|
474
|
+
});
|
|
475
|
+
const origin = focusNodeOrigin(node, minimumX, minimumY, padding, horizontal, vertical);
|
|
476
|
+
const centerX = Math.round((origin.x + FOCUS_NODE_WIDTH_PIXELS / 2) * scale);
|
|
477
|
+
const centerY = Math.round((origin.y + FOCUS_NODE_HEIGHT_PIXELS / 2) * scale);
|
|
478
|
+
const width = Math.min(rendered.width, 640);
|
|
479
|
+
const height = Math.min(rendered.height, 440);
|
|
480
|
+
budget.reserve(width, height, 'focus problem crop PNG');
|
|
481
|
+
const x = Math.max(0, Math.min(rendered.width - width, centerX - Math.floor(width / 2)));
|
|
482
|
+
const y = Math.max(0, Math.min(rendered.height - height, centerY - Math.floor(height / 2)));
|
|
483
|
+
const crop = await sharp(png)
|
|
484
|
+
.extract({ left: x, top: y, width, height })
|
|
485
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
486
|
+
.toBuffer();
|
|
487
|
+
problemCrops.push({
|
|
488
|
+
focusId,
|
|
489
|
+
bounds: { x, y, width, height },
|
|
490
|
+
png: crop,
|
|
491
|
+
diagnostics: diagnostics
|
|
492
|
+
.filter((diagnostic) => focusIdForDiagnostic(diagnostic) === focusId)
|
|
493
|
+
.map(({ code }) => code),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
455
497
|
options.signal?.throwIfAborted();
|
|
456
498
|
return {
|
|
457
499
|
html,
|
|
@@ -467,6 +509,7 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
|
|
|
467
509
|
sourceMap: compiled.sourceMap,
|
|
468
510
|
width: rendered.width,
|
|
469
511
|
height: rendered.height,
|
|
512
|
+
...(problemCrops.length === 0 ? {} : { problemCrops }),
|
|
470
513
|
};
|
|
471
514
|
}
|
|
472
515
|
export async function storeFocusRenderArtifacts(workspace, artifacts, plan, bundle, options = {}) {
|
|
@@ -534,5 +577,24 @@ export async function storeFocusRenderArtifacts(workspace, artifacts, plan, bund
|
|
|
534
577
|
description: 'Source-hash-bound non-Clausewitz planning metadata',
|
|
535
578
|
},
|
|
536
579
|
];
|
|
580
|
+
if (bundle.problemCrops !== undefined) {
|
|
581
|
+
for (const crop of bundle.problemCrops)
|
|
582
|
+
writes.push({
|
|
583
|
+
name: `${stem}.focus-problem-${safeArtifactStem(crop.focusId)}-${sha256Bytes(crop.focusId).slice(0, 8)}.png`,
|
|
584
|
+
mimeType: 'image/png',
|
|
585
|
+
content: crop.png,
|
|
586
|
+
provenance: {
|
|
587
|
+
...provenance,
|
|
588
|
+
kind: 'focus-problem-crop',
|
|
589
|
+
metadata: { ...provenance.metadata, focusId: crop.focusId, bounds: crop.bounds },
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
writes.push({
|
|
593
|
+
name: `${stem}.focus-problem-crops.json`,
|
|
594
|
+
mimeType: 'application/json',
|
|
595
|
+
content: `${canonicalJson(bundle.problemCrops.map(({ focusId, bounds, png, diagnostics }) => ({ focusId, bounds, sha256: sha256Bytes(png), diagnostics })))}\n`,
|
|
596
|
+
provenance: { ...provenance, kind: 'focus-problem-crop-manifest' },
|
|
597
|
+
});
|
|
598
|
+
}
|
|
537
599
|
return artifacts.withAtomicChunkedWrites(workspace, writes, (stored) => Promise.resolve([...stored]), options.signal);
|
|
538
600
|
}
|