editmamei 0.19.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/NOTICES.md +17 -1
  2. package/README.md +9 -8
  3. package/dist/api/extendscript/_helpers.js +1 -1
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/config.js +3 -3
  8. package/dist/cli/help.js +4 -0
  9. package/dist/cli/install.js +2 -2
  10. package/dist/cli/report.js +11 -0
  11. package/dist/cli/router.js +20 -0
  12. package/dist/core/server.js +8 -8
  13. package/dist/core/settings.js +2 -2
  14. package/dist/core/tool-groups.js +92 -82
  15. package/dist/core/tool-tiers.js +91 -81
  16. package/dist/detection/landmark-detection-client.js +8 -0
  17. package/dist/detection/mesh-eye-correction.js +126 -0
  18. package/dist/detection/sam-segmenter.js +83 -0
  19. package/dist/diagnostics/collect.js +169 -0
  20. package/dist/index.js +2 -0
  21. package/dist/license/entitlement.js +52 -0
  22. package/dist/modules/ce/index.js +6 -4
  23. package/dist/perception/grounding-anchors.js +37 -0
  24. package/dist/perception/grounding-corners.js +93 -0
  25. package/dist/perception/grounding-edge-trace.js +90 -0
  26. package/dist/perception/grounding-extrema.js +41 -0
  27. package/dist/perception/grounding-gate.js +154 -0
  28. package/dist/perception/grounding-geometry.js +132 -0
  29. package/dist/perception/grounding-grid.js +34 -0
  30. package/dist/perception/grounding-landmarks.js +40 -0
  31. package/dist/perception/grounding-locate.js +341 -0
  32. package/dist/perception/grounding-resolver.js +202 -0
  33. package/dist/perception/grounding-review-crop.js +85 -0
  34. package/dist/perception/grounding-warp.js +182 -0
  35. package/dist/perception/sky-mask-transfer.js +50 -18
  36. package/dist/skills/editmamei-skill.zip +0 -0
  37. package/dist/tools/adjustment-tools.js +3 -3
  38. package/dist/tools/brush-tools.js +111 -23
  39. package/dist/tools/channel-compose-tools.js +241 -0
  40. package/dist/tools/detection-tools.js +1 -1
  41. package/dist/tools/diagnostics-tools.js +66 -0
  42. package/dist/tools/document-tools.js +6 -6
  43. package/dist/tools/filter-tools.js +476 -19
  44. package/dist/tools/group-tools.js +10 -10
  45. package/dist/tools/guide-tools.js +4 -4
  46. package/dist/tools/history-tools.js +4 -4
  47. package/dist/tools/image-placement-tools.js +1 -1
  48. package/dist/tools/image-tools.js +62 -19
  49. package/dist/tools/inspect-tools.js +2 -2
  50. package/dist/tools/layer-ordering-tools.js +1 -1
  51. package/dist/tools/layer-properties-tools.js +42 -23
  52. package/dist/tools/layer-tools.js +10 -10
  53. package/dist/tools/layer-transform-tools.js +80 -41
  54. package/dist/tools/overview-tools.js +42 -42
  55. package/dist/tools/path-tools.js +53 -10
  56. package/dist/tools/portrait-tools.js +3 -3
  57. package/dist/tools/preview-tools.js +7 -82
  58. package/dist/tools/retouch-tools.js +93 -25
  59. package/dist/tools/scene-tools.js +13 -13
  60. package/dist/tools/selection-tools.js +319 -40
  61. package/dist/tools/shape-tools.js +216 -0
  62. package/dist/tools/text-tools.js +1 -1
  63. package/dist/tools/transform-canvas-tools.js +2 -2
  64. package/dist/tools/vector-mask-tools.js +23 -8
  65. package/dist/utils/log-buffer.js +41 -0
  66. package/dist/utils/logger.js +12 -1
  67. package/dist/utils/session-log-reader.js +27 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
  70. package/dist/preview/coordinate-grid.js +0 -250
  71. package/dist/tools/object-tools.js +0 -193
  72. package/dist/tools/text-on-object-tools.js +0 -225
@@ -0,0 +1,341 @@
1
+ import { decode } from 'jpeg-js';
2
+ import { ValidationError } from '../utils/validate.js';
3
+ import { detectActiveDoc } from '../detection/detect-active-doc.js';
4
+ import { localizeAnchor } from './grounding-anchors.js';
5
+ import { landmarkAnchor, isLandmarkFeature, LANDMARK_FEATURE_NAMES, } from './grounding-landmarks.js';
6
+ import { gridAnchor, frameBox } from './grounding-grid.js';
7
+ import { findExtremum } from './grounding-extrema.js';
8
+ import { cornerAnchor } from './grounding-corners.js';
9
+ import { traceEdge } from './grounding-edge-trace.js';
10
+ import { resolve, centerOf, } from './grounding-resolver.js';
11
+ import { runGate } from './grounding-gate.js';
12
+ import { boundsOf } from './grounding-geometry.js';
13
+ const POINT_RELATIONS = ['midpoint', 'centroid', 'offset'];
14
+ const REGION_RELATIONS = ['inside', 'gap'];
15
+ const PATH_RELATIONS = ['along', 'offset-curve', 'segment'];
16
+ export const SUPPORTED_RELATIONS = [...POINT_RELATIONS, ...REGION_RELATIONS, ...PATH_RELATIONS];
17
+ const DEFAULT_MIN_EDGE_CONFIDENCE = 40;
18
+ export class LocateError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'LocateError';
22
+ }
23
+ }
24
+ const scaleBox = (b, s) => ({
25
+ left: b.left * s,
26
+ top: b.top * s,
27
+ right: b.right * s,
28
+ bottom: b.bottom * s,
29
+ });
30
+ const liftPt = (p, s) => ({ x: p.x / s, y: p.y / s });
31
+ export function parseBox(v, name) {
32
+ if (!Array.isArray(v) || v.length !== 4 || v.some((n) => !Number.isFinite(n))) {
33
+ throw new ValidationError(`${name} must be [x1,y1,x2,y2] finite numbers in document pixels`);
34
+ }
35
+ const [x1, y1, x2, y2] = v;
36
+ return {
37
+ left: Math.min(x1, x2),
38
+ top: Math.min(y1, y2),
39
+ right: Math.max(x1, x2),
40
+ bottom: Math.max(y1, y2),
41
+ };
42
+ }
43
+ function localizeOne(a, det, exportImg, docW, docH, s, confSink) {
44
+ const needsImage = () => {
45
+ if (!exportImg)
46
+ throw new ValidationError(`anchor '${a.id}' (${a.kind}) needs the document image, which failed to export`);
47
+ return exportImg;
48
+ };
49
+ switch (a.kind) {
50
+ case 'face':
51
+ return localizeAnchor(det, {
52
+ kind: 'face',
53
+ instance: a.instance,
54
+ pick: a.pick,
55
+ });
56
+ case 'object': {
57
+ if (typeof a.label !== 'string')
58
+ throw new ValidationError(`object anchor '${a.id}' needs a 'label'`);
59
+ return localizeAnchor(det, {
60
+ kind: 'object',
61
+ label: a.label,
62
+ instance: a.instance,
63
+ pick: a.pick,
64
+ });
65
+ }
66
+ case 'grid': {
67
+ if (typeof a.at !== 'string')
68
+ throw new ValidationError(`grid anchor '${a.id}' needs 'at'`);
69
+ return gridAnchor(frameBox(docW, docH), a.at);
70
+ }
71
+ case 'extremum': {
72
+ const measure = a.measure;
73
+ if (!['brightest', 'darkest', 'most-saturated'].includes(measure)) {
74
+ throw new ValidationError(`extremum anchor '${a.id}' needs measure brightest|darkest|most-saturated`);
75
+ }
76
+ const region = a.region
77
+ ? scaleBox(parseBox(a.region, `${a.id}.region`), s)
78
+ : frameBox(needsImage().width, needsImage().height);
79
+ const e = findExtremum(needsImage(), region, measure);
80
+ return { kind: 'point', point: liftPt(e.point, s) };
81
+ }
82
+ case 'corner': {
83
+ if (!a.region)
84
+ throw new ValidationError(`corner anchor '${a.id}' needs a 'region' [x1,y1,x2,y2]`);
85
+ const region = scaleBox(parseBox(a.region, `${a.id}.region`), s);
86
+ let near;
87
+ if (a.near) {
88
+ if (!Array.isArray(a.near) ||
89
+ a.near.length !== 2 ||
90
+ !a.near.every((n) => Number.isFinite(n)))
91
+ throw new ValidationError(`${a.id}.near must be [x,y] finite numbers`);
92
+ near = { x: a.near[0] * s, y: a.near[1] * s };
93
+ }
94
+ const c = cornerAnchor(needsImage(), region, { near });
95
+ return c && c.kind === 'point' ? { kind: 'point', point: liftPt(c.point, s) } : null;
96
+ }
97
+ case 'edge': {
98
+ const orientation = a.orientation;
99
+ if (orientation !== 'horizontal-edge' && orientation !== 'vertical-edge') {
100
+ throw new ValidationError(`edge anchor '${a.id}' needs orientation horizontal-edge|vertical-edge`);
101
+ }
102
+ if (!a.region)
103
+ throw new ValidationError(`edge anchor '${a.id}' needs a 'region' [x1,y1,x2,y2]`);
104
+ const region = scaleBox(parseBox(a.region, `${a.id}.region`), s);
105
+ const t = traceEdge(needsImage(), {
106
+ region,
107
+ orientation: orientation,
108
+ samples: typeof a.samples === 'number' ? a.samples : undefined,
109
+ });
110
+ if (t.polyline.length < 2)
111
+ return null;
112
+ confSink[a.id] = t.confidences;
113
+ return { kind: 'polyline', polyline: t.polyline.map((p) => liftPt(p, s)) };
114
+ }
115
+ case 'landmark': {
116
+ if (typeof a.feature !== 'string')
117
+ throw new ValidationError(`landmark anchor '${a.id}' needs a 'feature' (one of: ${LANDMARK_FEATURE_NAMES.join(', ')})`);
118
+ if (!isLandmarkFeature(a.feature))
119
+ throw new ValidationError(`landmark anchor '${a.id}' feature '${a.feature}' unknown — use one of: ${LANDMARK_FEATURE_NAMES.join(', ')}`);
120
+ return landmarkAnchor(det, {
121
+ feature: a.feature,
122
+ face: typeof a.face === 'number' ? a.face : undefined,
123
+ });
124
+ }
125
+ default:
126
+ throw new ValidationError(`unknown anchor kind '${a.kind}' (want face|object|grid|extremum|corner|edge|landmark)`);
127
+ }
128
+ }
129
+ function parseExclusion(relation) {
130
+ if (!relation.exclusion)
131
+ return undefined;
132
+ return { kind: 'box', box: parseBox(relation.exclusion, 'exclusion') };
133
+ }
134
+ function gateResolved(geom, relation, anchors, confSink, anchorKinds, docW, docH) {
135
+ const canvas = { width: docW, height: docH };
136
+ if (geom.target === 'region')
137
+ return runGate({ target: 'region', polygon: geom.polygon, canvas });
138
+ if (geom.target === 'point') {
139
+ const spec = { target: 'point', point: geom.point, canvas };
140
+ if (relation.type === 'midpoint') {
141
+ spec.anchors = [
142
+ centerOf(anchors[relation.anchors[0]]),
143
+ centerOf(anchors[relation.anchors[1]]),
144
+ ];
145
+ }
146
+ return runGate(spec);
147
+ }
148
+ const srcId = relation.curve ?? '';
149
+ const srcPrim = anchors[srcId];
150
+ const srcCurve = srcPrim && srcPrim.kind === 'polyline' ? srcPrim.polyline : [];
151
+ let edgeMeasured = {};
152
+ if (anchorKinds[srcId] === 'edge') {
153
+ const minConf = typeof relation.min_confidence === 'number'
154
+ ? relation.min_confidence
155
+ : DEFAULT_MIN_EDGE_CONFIDENCE;
156
+ const edgeGate = runGate({
157
+ target: 'path',
158
+ kind: 'edge',
159
+ curve: srcCurve,
160
+ confidences: confSink[srcId] ?? [],
161
+ minConfidence: minConf,
162
+ canvas,
163
+ });
164
+ if (!edgeGate.pass)
165
+ return edgeGate;
166
+ edgeMeasured = edgeGate.measured;
167
+ }
168
+ if (geom.kind === 'along') {
169
+ const off = geom.curve.some((p) => p.x < 0 || p.y < 0 || p.x > docW || p.y > docH);
170
+ const measured = {
171
+ ...edgeMeasured,
172
+ source: anchorKinds[srcId] ?? 'unknown',
173
+ points: geom.curve.length,
174
+ };
175
+ return off
176
+ ? { pass: false, reason: 'path runs off canvas', measured }
177
+ : { pass: true, measured };
178
+ }
179
+ const ocGate = runGate({
180
+ target: 'path',
181
+ kind: 'offset-curve',
182
+ curve: geom.curve,
183
+ source: geom.source,
184
+ side: geom.side,
185
+ requiredMargin: 1,
186
+ exclusion: parseExclusion(relation),
187
+ canvas,
188
+ });
189
+ return {
190
+ pass: ocGate.pass,
191
+ ...(ocGate.pass ? {} : { reason: ocGate.reason }),
192
+ measured: { ...edgeMeasured, ...ocGate.measured },
193
+ };
194
+ }
195
+ export async function resolveToGeometry(connection, client, input) {
196
+ const anchorSpecs = input.anchors;
197
+ const relation = input.relation;
198
+ if (!Array.isArray(anchorSpecs) || anchorSpecs.length === 0)
199
+ throw new ValidationError('anchors must be a non-empty array');
200
+ if (!relation || typeof relation !== 'object' || typeof relation.type !== 'string')
201
+ throw new ValidationError('relation must be an object with a "type"');
202
+ if (!SUPPORTED_RELATIONS.includes(relation.type)) {
203
+ throw new ValidationError(`relation "${relation.type}" not supported yet — use ${SUPPORTED_RELATIONS.join(' | ')}`);
204
+ }
205
+ for (const a of anchorSpecs)
206
+ if (typeof a?.id !== 'string' || typeof a?.kind !== 'string')
207
+ throw new ValidationError('each anchor needs a string "id" and "kind"');
208
+ const needFaces = anchorSpecs.some((a) => a.kind === 'face' || a.kind === 'landmark');
209
+ const needObjects = anchorSpecs.some((a) => a.kind === 'object');
210
+ const det = await detectActiveDoc(connection, client, {
211
+ faces: needFaces,
212
+ objects: needObjects,
213
+ maxDimension: input.maxDimension,
214
+ });
215
+ const docW = det.docWidth;
216
+ const docH = det.docHeight;
217
+ let exportImg = null;
218
+ try {
219
+ if (det.exportBytes.length > 0)
220
+ exportImg = decode(det.exportBytes, { useTArray: true, formatAsRGBA: true });
221
+ }
222
+ catch {
223
+ exportImg = null;
224
+ }
225
+ const s = exportImg ? exportImg.width / docW : 1;
226
+ const anchors = {};
227
+ const anchorMeta = {};
228
+ const confSink = {};
229
+ const missing = [];
230
+ for (const a of anchorSpecs) {
231
+ const prim = localizeOne(a, det.result, exportImg, docW, docH, s, confSink);
232
+ if (prim) {
233
+ anchors[a.id] = prim;
234
+ anchorMeta[a.id] = { kind: a.kind, center: centerOf(prim) };
235
+ }
236
+ else
237
+ missing.push(`${a.id} (${a.kind})`);
238
+ }
239
+ if (missing.length > 0) {
240
+ throw new LocateError(`Could not localize anchor(s): ${missing.join(', ')}. ` +
241
+ `Detection found ${det.result.faces?.length ?? 0} face(s), ${det.result.objects?.length ?? 0} object(s). ` +
242
+ `Try ps_detect to see what's present, or loosen the selector.`);
243
+ }
244
+ let geom;
245
+ try {
246
+ geom = resolve(relation, anchors, { frame: { width: docW, height: docH } });
247
+ }
248
+ catch (e) {
249
+ throw new LocateError(`Could not resolve the relation: ${e.message}`);
250
+ }
251
+ if (input.pathTransform && geom.target === 'path') {
252
+ const transformed = input.pathTransform(geom.curve);
253
+ if (Array.isArray(transformed) && transformed.length >= 2) {
254
+ geom = { ...geom, curve: transformed };
255
+ }
256
+ }
257
+ const anchorKinds = Object.fromEntries(Object.entries(anchorMeta).map(([id, m]) => [id, m.kind]));
258
+ const gate = gateResolved(geom, relation, anchors, confSink, anchorKinds, docW, docH);
259
+ return { geom, gate, anchorMeta, context: det.context, exportImg, docW, docH, s };
260
+ }
261
+ export const PLACEMENT_SCHEMA = {
262
+ type: 'object',
263
+ description: 'ANCHOR-RELATIONAL placement (preferred over guessing pixels): name anchors + a relation and the ' +
264
+ 'spatial-grounding resolver computes the geometry, verified by an objective gate; the action runs ONLY if the ' +
265
+ 'gate PASSES. For a HARD or ambiguous placement, first concur on ps_resolve_placement (review_crop: true) — a ' +
266
+ 'read-only zoomed crop with a marker at the resolved spot — then pass the SAME anchors + relation here. See ' +
267
+ 'ps_resolve_placement for the full anchors + relation vocabulary.',
268
+ properties: {
269
+ anchors: {
270
+ type: 'array',
271
+ description: 'Named anchors, same vocabulary as ps_resolve_placement (face/object/grid/extremum/corner/edge/landmark).',
272
+ },
273
+ relation: {
274
+ type: 'object',
275
+ description: 'The relation, same vocabulary as ps_resolve_placement.',
276
+ },
277
+ max_dimension: {
278
+ type: 'number',
279
+ minimum: 256,
280
+ maximum: 4096,
281
+ description: 'Long-edge px of the export the CV runs on (default 1024).',
282
+ },
283
+ },
284
+ };
285
+ const EXPECT_HINT = {
286
+ point: 'Use a point relation (centroid / midpoint / offset).',
287
+ region: 'Use a region relation (inside / gap).',
288
+ path: 'Use a path relation (along / offset-curve).',
289
+ };
290
+ export async function resolveGatedPlacement(connection, client, placement, opts) {
291
+ const p = (placement ?? {});
292
+ const loc = await resolveToGeometry(connection, client, {
293
+ anchors: p.anchors,
294
+ relation: p.relation,
295
+ maxDimension: p.max_dimension,
296
+ pathTransform: opts.pathTransform,
297
+ });
298
+ if (!loc.gate.pass) {
299
+ throw new LocateError(`Placement gate REJECT — ${loc.gate.reason ?? 'failed verification'}. No ${opts.label} performed; re-spec the anchors/relation.`);
300
+ }
301
+ const expects = (Array.isArray(opts.expect) ? opts.expect : [opts.expect]);
302
+ if (!expects.includes(loc.geom.target)) {
303
+ const want = expects.join(' or ');
304
+ const hint = expects.map((e) => EXPECT_HINT[e]).join(' ');
305
+ throw new LocateError(`${opts.label} placement needs a ${want} relation — resolved a '${loc.geom.target}'. ${hint}`);
306
+ }
307
+ return loc;
308
+ }
309
+ export async function resolveExpectedPlacement(connection, client, placement, expect, label) {
310
+ const gated = await resolveGatedPlacement(connection, client, placement, { expect, label });
311
+ const g = gated.geom;
312
+ const anchors = gated.anchorMeta;
313
+ let out;
314
+ if (g.target === 'point') {
315
+ const point = { x: Math.round(g.point.x), y: Math.round(g.point.y) };
316
+ out = { target: 'point', point, anchors, summary: `(${point.x},${point.y})` };
317
+ }
318
+ else if (g.target === 'region') {
319
+ const b = boundsOf(g.polygon);
320
+ const bbox = {
321
+ left: Math.round(b.left),
322
+ top: Math.round(b.top),
323
+ right: Math.round(b.right),
324
+ bottom: Math.round(b.bottom),
325
+ };
326
+ out = {
327
+ target: 'region',
328
+ bbox,
329
+ anchors,
330
+ summary: `[${bbox.left},${bbox.top},${bbox.right},${bbox.bottom}]`,
331
+ };
332
+ }
333
+ else {
334
+ if (g.curve.length < 2) {
335
+ throw new LocateError(`${label} placement resolved to a degenerate (<2 point) curve.`);
336
+ }
337
+ const curve = g.curve.map((pt) => ({ x: Math.round(pt.x), y: Math.round(pt.y) }));
338
+ out = { target: 'path', curve, anchors, summary: `${curve.length}-point path` };
339
+ }
340
+ return out;
341
+ }
@@ -0,0 +1,202 @@
1
+ import { boundsOf, dist, } from './grounding-geometry.js';
2
+ function need(anchors, id) {
3
+ const a = anchors[id];
4
+ if (!a)
5
+ throw new Error(`resolve: unknown anchor '${id}'`);
6
+ return a;
7
+ }
8
+ export function centerOf(prim) {
9
+ if (prim.kind === 'point')
10
+ return prim.point;
11
+ const b = prim.kind === 'box' ? prim.box : boundsOf(prim.polyline);
12
+ return { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2 };
13
+ }
14
+ export function bboxOf(prim) {
15
+ if (prim.kind === 'box')
16
+ return prim.box;
17
+ if (prim.kind === 'polyline')
18
+ return boundsOf(prim.polyline);
19
+ return { left: prim.point.x, top: prim.point.y, right: prim.point.x, bottom: prim.point.y };
20
+ }
21
+ function asCurve(prim, id) {
22
+ if (prim.kind !== 'polyline')
23
+ throw new Error(`resolve: anchor '${id}' must be a polyline for this relation`);
24
+ return prim.polyline;
25
+ }
26
+ function dimOf(prim, dim) {
27
+ const b = bboxOf(prim);
28
+ const w = b.right - b.left, h = b.bottom - b.top;
29
+ if (dim === 'w')
30
+ return w;
31
+ if (dim === 'h')
32
+ return h;
33
+ if (dim === 'diag')
34
+ return Math.hypot(w, h);
35
+ throw new Error(`resolve: bad frac-of dimension '${dim}' (want w|h|diag)`);
36
+ }
37
+ export function resolveLength(len, anchors, frameDiag) {
38
+ if (len.unit === 'px')
39
+ return len.value;
40
+ if (len.unit === 'frac-diag')
41
+ return len.value * frameDiag;
42
+ if (len.unit.startsWith('frac-of:')) {
43
+ const [, ref, dim] = len.unit.split(':');
44
+ if (!ref || !dim)
45
+ throw new Error(`resolve: malformed unit '${len.unit}' (want frac-of:<ref>:<w|h|diag>)`);
46
+ return len.value * dimOf(need(anchors, ref), dim);
47
+ }
48
+ throw new Error(`resolve: unknown length unit '${len.unit}'`);
49
+ }
50
+ const AXIS = {
51
+ up: { x: 0, y: -1 },
52
+ down: { x: 0, y: 1 },
53
+ left: { x: -1, y: 0 },
54
+ right: { x: 1, y: 0 },
55
+ };
56
+ function unitVector(direction, from, anchors) {
57
+ if (direction in AXIS)
58
+ return AXIS[direction];
59
+ const [kind, arg] = direction.split(':');
60
+ if (kind === 'angle') {
61
+ const r = (Number(arg) * Math.PI) / 180;
62
+ return { x: Math.cos(r), y: Math.sin(r) };
63
+ }
64
+ if (kind === 'toward' || kind === 'away') {
65
+ const target = centerOf(need(anchors, arg));
66
+ const v = { x: target.x - from.x, y: target.y - from.y };
67
+ const m = Math.hypot(v.x, v.y) || 1;
68
+ const u = { x: v.x / m, y: v.y / m };
69
+ return kind === 'toward' ? u : { x: -u.x, y: -u.y };
70
+ }
71
+ throw new Error(`resolve: unknown direction '${direction}'`);
72
+ }
73
+ export function trimByArcLength(curve, from, to) {
74
+ if (curve.length < 2 || (from <= 0 && to >= 1))
75
+ return curve;
76
+ const segLen = [];
77
+ let total = 0;
78
+ for (let i = 0; i < curve.length - 1; i++) {
79
+ const d = dist(curve[i], curve[i + 1]);
80
+ segLen.push(d);
81
+ total += d;
82
+ }
83
+ const startD = Math.max(0, from) * total, endD = Math.min(1, to) * total;
84
+ const at = (target) => {
85
+ let acc = 0;
86
+ for (let i = 0; i < segLen.length; i++) {
87
+ if (acc + segLen[i] >= target) {
88
+ const t = segLen[i] === 0 ? 0 : (target - acc) / segLen[i];
89
+ return {
90
+ x: curve[i].x + t * (curve[i + 1].x - curve[i].x),
91
+ y: curve[i].y + t * (curve[i + 1].y - curve[i].y),
92
+ };
93
+ }
94
+ acc += segLen[i];
95
+ }
96
+ return curve[curve.length - 1];
97
+ };
98
+ const out = [at(startD)];
99
+ let acc = 0;
100
+ for (let i = 0; i < curve.length; i++) {
101
+ if (i > 0)
102
+ acc += segLen[i - 1];
103
+ if (acc > startD && acc < endD)
104
+ out.push(curve[i]);
105
+ }
106
+ out.push(at(endD));
107
+ return out;
108
+ }
109
+ const boxToPolygon = (b) => [
110
+ { x: b.left, y: b.top },
111
+ { x: b.right, y: b.top },
112
+ { x: b.right, y: b.bottom },
113
+ { x: b.left, y: b.bottom },
114
+ ];
115
+ export function resolve(relation, anchors, ctx) {
116
+ const frameDiag = Math.hypot(ctx.frame.width, ctx.frame.height);
117
+ switch (relation.type) {
118
+ case 'midpoint': {
119
+ const a = centerOf(need(anchors, relation.anchors[0]));
120
+ const b = centerOf(need(anchors, relation.anchors[1]));
121
+ const t = relation.t ?? 0.5;
122
+ return { target: 'point', point: { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t } };
123
+ }
124
+ case 'centroid':
125
+ return { target: 'point', point: centerOf(need(anchors, relation.anchor)) };
126
+ case 'offset': {
127
+ const from = centerOf(need(anchors, relation.from));
128
+ const d = resolveLength(relation.distance, anchors, frameDiag);
129
+ const u = unitVector(relation.direction, from, anchors);
130
+ return { target: 'point', point: { x: from.x + u.x * d, y: from.y + u.y * d } };
131
+ }
132
+ case 'along': {
133
+ const curve = asCurve(need(anchors, relation.curve), relation.curve);
134
+ return {
135
+ target: 'path',
136
+ kind: 'along',
137
+ curve: trimByArcLength(curve, relation.from ?? 0, relation.to ?? 1),
138
+ };
139
+ }
140
+ case 'offset-curve': {
141
+ const source = asCurve(need(anchors, relation.curve), relation.curve);
142
+ const d = resolveLength(relation.distance, anchors, frameDiag);
143
+ const u = AXIS[relation.side];
144
+ const curve = source.map((p) => ({ x: p.x + u.x * d, y: p.y + u.y * d }));
145
+ return {
146
+ target: 'path',
147
+ kind: 'offset-curve',
148
+ curve,
149
+ source,
150
+ side: relation.side,
151
+ margin: d,
152
+ };
153
+ }
154
+ case 'segment': {
155
+ const a = centerOf(need(anchors, relation.anchors[0]));
156
+ const b = centerOf(need(anchors, relation.anchors[1]));
157
+ return { target: 'path', kind: 'along', curve: [a, b] };
158
+ }
159
+ case 'inside': {
160
+ const bb = bboxOf(need(anchors, relation.anchor));
161
+ const inset = relation.inset ? resolveLength(relation.inset, anchors, frameDiag) : 0;
162
+ return {
163
+ target: 'region',
164
+ polygon: boxToPolygon({
165
+ left: bb.left + inset,
166
+ top: bb.top + inset,
167
+ right: bb.right - inset,
168
+ bottom: bb.bottom - inset,
169
+ }),
170
+ };
171
+ }
172
+ case 'gap': {
173
+ const a = bboxOf(need(anchors, relation.anchors[0]));
174
+ const b = bboxOf(need(anchors, relation.anchors[1]));
175
+ const [l, r] = a.right <= b.left ? [a, b] : [b, a];
176
+ if (l.right < r.left) {
177
+ return {
178
+ target: 'region',
179
+ polygon: boxToPolygon({
180
+ left: l.right,
181
+ right: r.left,
182
+ top: Math.max(a.top, b.top),
183
+ bottom: Math.min(a.bottom, b.bottom),
184
+ }),
185
+ };
186
+ }
187
+ const [t, bot] = a.bottom <= b.top ? [a, b] : [b, a];
188
+ if (t.bottom < bot.top) {
189
+ return {
190
+ target: 'region',
191
+ polygon: boxToPolygon({
192
+ top: t.bottom,
193
+ bottom: bot.top,
194
+ left: Math.max(a.left, b.left),
195
+ right: Math.min(a.right, b.right),
196
+ }),
197
+ };
198
+ }
199
+ return { target: 'region', polygon: [] };
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,85 @@
1
+ import { boundsOf } from './grounding-geometry.js';
2
+ function setPx(img, x, y, [r, g, b]) {
3
+ if (x < 0 || y < 0 || x >= img.width || y >= img.height)
4
+ return;
5
+ const i = (y * img.width + x) * 4;
6
+ img.data[i] = r;
7
+ img.data[i + 1] = g;
8
+ img.data[i + 2] = b;
9
+ img.data[i + 3] = 255;
10
+ }
11
+ function drawRing(img, c, radius, thick, color) {
12
+ const cx = Math.round(c.x), cy = Math.round(c.y);
13
+ const outer = radius + thick;
14
+ for (let dy = -outer; dy <= outer; dy++) {
15
+ for (let dx = -outer; dx <= outer; dx++) {
16
+ const d = Math.hypot(dx, dy);
17
+ if (d >= radius - thick && d <= radius + thick)
18
+ setPx(img, cx + dx, cy + dy, color);
19
+ }
20
+ }
21
+ }
22
+ function disc(img, c, radius, color) {
23
+ const cx = Math.round(c.x), cy = Math.round(c.y);
24
+ for (let dy = -radius; dy <= radius; dy++)
25
+ for (let dx = -radius; dx <= radius; dx++)
26
+ if (dx * dx + dy * dy <= radius * radius)
27
+ setPx(img, cx + dx, cy + dy, color);
28
+ }
29
+ function drawPolyline(img, pts, thick, color, closed = false) {
30
+ const r = Math.max(0, thick - 1);
31
+ const seq = closed && pts.length > 2 ? [...pts, pts[0]] : pts;
32
+ for (let i = 0; i < seq.length - 1; i++) {
33
+ const a = seq[i], b = seq[i + 1];
34
+ const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)));
35
+ for (let s = 0; s <= steps; s++) {
36
+ const t = s / steps;
37
+ disc(img, { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) }, r, color);
38
+ }
39
+ }
40
+ }
41
+ function placementBBox(p) {
42
+ const b = p.kind === 'point'
43
+ ? { left: p.point.x, top: p.point.y, right: p.point.x, bottom: p.point.y }
44
+ : boundsOf(p.kind === 'curve' ? p.curve : p.polygon);
45
+ return {
46
+ center: { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2 },
47
+ extent: Math.max((b.right - b.left) / 2, (b.bottom - b.top) / 2),
48
+ };
49
+ }
50
+ export function renderReviewCrop(img, placement, opts = {}) {
51
+ const padding = opts.padding ?? 120;
52
+ const minHalf = opts.minHalfWindow ?? 140;
53
+ const maxHalf = opts.maxHalfWindow ?? 600;
54
+ const color = opts.markerColor ?? [40, 240, 90];
55
+ const { center, extent } = placementBBox(placement);
56
+ const half = Math.min(maxHalf, Math.max(minHalf, Math.round(extent + padding)));
57
+ const size = 2 * half + 1;
58
+ const ox = Math.round(center.x) - half;
59
+ const oy = Math.round(center.y) - half;
60
+ const out = { width: size, height: size, data: new Uint8Array(size * size * 4) };
61
+ for (let y = 0; y < size; y++) {
62
+ for (let x = 0; x < size; x++) {
63
+ const sx = ox + x, sy = oy + y;
64
+ const di = (y * size + x) * 4;
65
+ out.data[di + 3] = 255;
66
+ if (sx < 0 || sy < 0 || sx >= img.width || sy >= img.height)
67
+ continue;
68
+ const si = (sy * img.width + sx) * 4;
69
+ out.data[di] = img.data[si];
70
+ out.data[di + 1] = img.data[si + 1];
71
+ out.data[di + 2] = img.data[si + 2];
72
+ }
73
+ }
74
+ const toCrop = (p) => ({ x: p.x - ox, y: p.y - oy });
75
+ if (placement.kind === 'point') {
76
+ drawRing(out, toCrop(placement.point), opts.ringRadius ?? 13, opts.ringThickness ?? 2, color);
77
+ }
78
+ else if (placement.kind === 'curve') {
79
+ drawPolyline(out, placement.curve.map(toCrop), opts.strokeThickness ?? 2, color);
80
+ }
81
+ else {
82
+ drawPolyline(out, placement.polygon.map(toCrop), opts.strokeThickness ?? 2, color, true);
83
+ }
84
+ return { image: out, origin: { x: ox, y: oy }, size };
85
+ }