slidemind 0.3.20 → 0.4.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// `--concept` / `--style` as adapters onto the image-intent contract.
|
|
2
|
+
//
|
|
3
|
+
// Both flags predate the contract: they read a file inline and shipped it to
|
|
4
|
+
// the concept model as a transient `{role, image}` reference that nothing
|
|
5
|
+
// recorded. The image was gone the moment the request came back, so a resumed
|
|
6
|
+
// session, a re-run or the .pptx itself had no idea it ever existed.
|
|
7
|
+
//
|
|
8
|
+
// They keep working, and they now mean something durable (migration plan step
|
|
9
|
+
// 5):
|
|
10
|
+
//
|
|
11
|
+
// --concept file@2,3 import the file, then a reconstruct/slide use on 2 and 3
|
|
12
|
+
// --style file@2,3 import the file, then a reference use on 2 and 3
|
|
13
|
+
// (no @) the CURRENT planned set — never an evergreen
|
|
14
|
+
// "all future slides" record
|
|
15
|
+
//
|
|
16
|
+
// Four rules the implementation exists to keep:
|
|
17
|
+
//
|
|
18
|
+
// * **Import first, plan second.** The bytes are registered before any use
|
|
19
|
+
// names them, so a use can never point at something the deck does not have.
|
|
20
|
+
// * **Idempotent.** The registry deduplicates by digest and this deduplicates
|
|
21
|
+
// equivalent uses, so the same flag with the same bytes and the same scope
|
|
22
|
+
// twice is a no-op — which is what leaves an approved plan approved.
|
|
23
|
+
// * **A conflict is refused, not guessed.** If the slide already carries a
|
|
24
|
+
// canonical use of those bytes with a different obligation, the flag and
|
|
25
|
+
// the plan disagree about what the picture is FOR. Picking one silently is
|
|
26
|
+
// how an `original` becomes a mood board. `image_intent_conflict` names
|
|
27
|
+
// both choices and the agent resolves it in the plan.
|
|
28
|
+
// * **No approval is bypassed.** This writes a PENDING plan revision. The
|
|
29
|
+
// existing plan hash gate then re-arms by itself (the storyboard record is
|
|
30
|
+
// part of the story hash), so the user re-signs the plan that now carries
|
|
31
|
+
// the obligation. Nothing here touches plan-review.json.
|
|
32
|
+
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
import { importImageAsset } from "../lib/image-assets.mjs";
|
|
37
|
+
import { describeUse } from "../lib/image-intent.mjs";
|
|
38
|
+
import { replaceStoryboardSegment, splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
|
|
39
|
+
|
|
40
|
+
/** `--concept` → a whole-slide blueprint; `--style` → guidance only. */
|
|
41
|
+
const ROLE_USES = {
|
|
42
|
+
concept: {
|
|
43
|
+
fidelity: "reconstruct",
|
|
44
|
+
reconstructionScope: "slide",
|
|
45
|
+
suffix: "rc",
|
|
46
|
+
instruction: (name) => `Rebuild this slide from ${name} (supplied with --concept).`,
|
|
47
|
+
},
|
|
48
|
+
style: {
|
|
49
|
+
fidelity: "reference",
|
|
50
|
+
suffix: "rf",
|
|
51
|
+
instruction: (name) => `Use ${name} as a visual reference for this slide (supplied with --style).`,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function conflict(message, extra) {
|
|
56
|
+
return {
|
|
57
|
+
code: "image_intent_conflict",
|
|
58
|
+
message,
|
|
59
|
+
action: "Resolve the competing record in the plan before generating mockups.",
|
|
60
|
+
...extra,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The slide numbers the plan currently names — the scope of a flag with no `@`. */
|
|
65
|
+
export function plannedSlidesInPlan(planMarkdown) {
|
|
66
|
+
return splitPlanMarkdownIntoSegments(planMarkdown)
|
|
67
|
+
.filter((segment) => segment.type === "storyboard" && segment.data?.slide)
|
|
68
|
+
.map((segment) => segment.data.slide);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Turn legacy reference flags into imported assets plus plan uses.
|
|
73
|
+
*
|
|
74
|
+
* Pure with respect to the plan: it returns the next markdown, it does not
|
|
75
|
+
* write it. The caller decides what to do with a changed plan (`concepts` lets
|
|
76
|
+
* the existing sign-off gate refuse the run).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} deckDir
|
|
79
|
+
* @param {Array<{role: "concept"|"style", file: string, slides?: number[]}>} flags
|
|
80
|
+
* @param {{planMarkdown: string}} ctx
|
|
81
|
+
* @returns {Promise<{planMarkdown: string, changed: boolean,
|
|
82
|
+
* imported: Array, added: Array, issues: Array}>}
|
|
83
|
+
*/
|
|
84
|
+
export async function applyLegacyReferenceFlags(deckDir, flags, { planMarkdown }) {
|
|
85
|
+
const out = { planMarkdown, changed: false, imported: [], added: [], issues: [] };
|
|
86
|
+
if (!flags?.length) return out;
|
|
87
|
+
|
|
88
|
+
const planned = plannedSlidesInPlan(planMarkdown);
|
|
89
|
+
for (const flag of flags) {
|
|
90
|
+
const spec = ROLE_USES[flag.role];
|
|
91
|
+
if (!spec) continue;
|
|
92
|
+
|
|
93
|
+
// Import BEFORE planning. Same bytes as an earlier import reuse the entry
|
|
94
|
+
// they already have, which is what makes the whole adapter idempotent.
|
|
95
|
+
const { asset, reused } = await importImageAsset(deckDir, flag.file, {
|
|
96
|
+
displayName: path.basename(flag.file),
|
|
97
|
+
provenance: { kind: "local" },
|
|
98
|
+
});
|
|
99
|
+
out.imported.push({ file: flag.file, assetId: asset.assetId, reused });
|
|
100
|
+
|
|
101
|
+
const targets = flag.slides?.length ? flag.slides : planned;
|
|
102
|
+
for (const slide of targets) {
|
|
103
|
+
const board = splitPlanMarkdownIntoSegments(out.planMarkdown).find(
|
|
104
|
+
(segment) => segment.type === "storyboard" && segment.data?.slide === slide,
|
|
105
|
+
);
|
|
106
|
+
if (!board) {
|
|
107
|
+
out.issues.push(
|
|
108
|
+
conflict(
|
|
109
|
+
`--${flag.role} names slide ${slide}, which the plan has no storyboard for.`,
|
|
110
|
+
{ slide, assetId: asset.assetId },
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const existing = board.data.assetUses ?? [];
|
|
116
|
+
|
|
117
|
+
// Equivalent to something already recorded → nothing to do.
|
|
118
|
+
const same = existing.find(
|
|
119
|
+
(use) =>
|
|
120
|
+
use.assetId === asset.assetId &&
|
|
121
|
+
use.fidelity === spec.fidelity &&
|
|
122
|
+
use.reconstructionScope === spec.reconstructionScope,
|
|
123
|
+
);
|
|
124
|
+
if (same) continue;
|
|
125
|
+
|
|
126
|
+
// Same bytes, different obligation: the flag and the plan disagree about
|
|
127
|
+
// what this picture is for.
|
|
128
|
+
const other = existing.find((use) => use.assetId === asset.assetId);
|
|
129
|
+
if (other) {
|
|
130
|
+
out.issues.push(
|
|
131
|
+
conflict(
|
|
132
|
+
`slide ${slide} already uses ${asset.displayName} as ${describeUse(other)}, ` +
|
|
133
|
+
`but --${flag.role} asks for ${describeUse(spec)}.`,
|
|
134
|
+
{ slide, assetId: asset.assetId, useId: other.useId },
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A slide takes one whole-slide blueprint. The contract's own parser
|
|
141
|
+
// would refuse a second one; catching it here names the other source.
|
|
142
|
+
if (spec.reconstructionScope === "slide") {
|
|
143
|
+
const blueprint = existing.find(
|
|
144
|
+
(use) => use.fidelity === "reconstruct" && use.reconstructionScope === "slide",
|
|
145
|
+
);
|
|
146
|
+
if (blueprint) {
|
|
147
|
+
out.issues.push(
|
|
148
|
+
conflict(
|
|
149
|
+
`slide ${slide} already rebuilds from another source (${blueprint.useId}); ` +
|
|
150
|
+
`--concept ${path.basename(flag.file)} would be a second whole-slide blueprint.`,
|
|
151
|
+
{ slide, assetId: asset.assetId, useId: blueprint.useId },
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const use = {
|
|
159
|
+
useId: `${asset.assetId}_s${slide}_${spec.suffix}`,
|
|
160
|
+
assetId: asset.assetId,
|
|
161
|
+
fidelity: spec.fidelity,
|
|
162
|
+
instruction: spec.instruction(asset.displayName),
|
|
163
|
+
...(spec.reconstructionScope ? { reconstructionScope: spec.reconstructionScope } : {}),
|
|
164
|
+
};
|
|
165
|
+
out.planMarkdown = replaceStoryboardSegment(out.planMarkdown, board, {
|
|
166
|
+
...board.data,
|
|
167
|
+
assetUses: [...existing, use],
|
|
168
|
+
});
|
|
169
|
+
out.changed = true;
|
|
170
|
+
out.added.push({ slide, useId: use.useId, fidelity: use.fidelity, assetId: asset.assetId });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Persist a pending plan revision. The sign-off gate re-arms by itself. */
|
|
177
|
+
export function writePlanMarkdown(deckDir, markdown) {
|
|
178
|
+
fs.writeFileSync(path.join(deckDir, "plan.md"), markdown);
|
|
179
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Trace utilities for the line fitter (line-native.mjs) and the line intake
|
|
2
|
+
// (concept-lines.mjs): the strokes an element draws, cleaned of what runs
|
|
3
|
+
// inside its own nodes, and the ordered distance between two paths. WHICH
|
|
4
|
+
// native object draws a stroke is line-native's business.
|
|
5
|
+
//
|
|
6
|
+
// Shared by both clients; mirrored by scripts/sync-transcription-runtime.mjs —
|
|
7
|
+
// no Node or CLI imports here.
|
|
8
|
+
|
|
9
|
+
import { repairCommands } from "./concept-geometry.mjs";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
13
|
+
const rectCenter = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which side of its node a line end sits on: the nearest edge of a box, or
|
|
17
|
+
* one of eight 45° sectors of a round node. Two ends on the same side are the
|
|
18
|
+
* same attachment for the line's shape; a different side is a different line.
|
|
19
|
+
*/
|
|
20
|
+
export function sideOf(p, rect, round = false) {
|
|
21
|
+
const c = rectCenter(rect);
|
|
22
|
+
const dx = (p.x - c.x) / Math.max(1, rect.w / 2), dy = (p.y - c.y) / Math.max(1, rect.h / 2);
|
|
23
|
+
if (round) {
|
|
24
|
+
const k = Math.round(Math.atan2(dy, dx) / (Math.PI / 4));
|
|
25
|
+
return ["right", "bottom-right", "bottom", "bottom-left", "left", "top-left", "top", "top-right"][((k % 8) + 8) % 8];
|
|
26
|
+
}
|
|
27
|
+
return Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? "right" : "left") : dy >= 0 ? "bottom" : "top";
|
|
28
|
+
}
|
|
29
|
+
const inside = (p, r, e = 0.5) => p.x > r.x + e && p.x < r.x + r.w - e && p.y > r.y + e && p.y < r.y + r.h - e;
|
|
30
|
+
const finite = (p) => Number.isFinite(p?.x) && Number.isFinite(p?.y);
|
|
31
|
+
const r1 = (v) => Math.round(v * 10) / 10;
|
|
32
|
+
|
|
33
|
+
/** A point end as written in JSX (`data-from="x,y"`) and read back. */
|
|
34
|
+
export const pointRef = (p) => `${r1(p.x)},${r1(p.y)}`;
|
|
35
|
+
export function parsePointRef(ref) {
|
|
36
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(String(ref ?? ""));
|
|
37
|
+
return m ? { x: Number(m[1]), y: Number(m[2]) } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function traceLength(points) {
|
|
41
|
+
let len = 0;
|
|
42
|
+
for (let i = 1; i < points.length; i++) len += dist(points[i], points[i - 1]);
|
|
43
|
+
return len;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Ordered distance between two paths (px): both resampled to the same number
|
|
47
|
+
// of points by arc length, then compared point for point, start to end. An
|
|
48
|
+
// unordered nearest-point distance let a square around the corners pass for
|
|
49
|
+
// a circle through them (14px against a 24px tolerance, x58 GTM 3); in order,
|
|
50
|
+
// the corner cuts far from the arc and the shape difference counts.
|
|
51
|
+
export function pathDistance(a, b, n = 32) {
|
|
52
|
+
return pathErrors(a, b, n).max;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Both measures of the ordered comparison: the worst point and the RMS. */
|
|
56
|
+
export function pathErrors(a, b, n = 32) {
|
|
57
|
+
// Linear interpolation by arc length along the polyline itself.
|
|
58
|
+
const resample = (pts) => {
|
|
59
|
+
const d = pts;
|
|
60
|
+
const cum = [0];
|
|
61
|
+
for (let i = 1; i < d.length; i++) cum.push(cum[i - 1] + dist(d[i], d[i - 1]));
|
|
62
|
+
const total = cum.at(-1) || 1;
|
|
63
|
+
const out = [];
|
|
64
|
+
let j = 0;
|
|
65
|
+
for (let k = 0; k < n; k++) {
|
|
66
|
+
const t = (total * k) / (n - 1);
|
|
67
|
+
while (j < cum.length - 2 && cum[j + 1] < t) j++;
|
|
68
|
+
const seg = cum[j + 1] - cum[j] || 1;
|
|
69
|
+
const f = Math.min(1, Math.max(0, (t - cum[j]) / seg));
|
|
70
|
+
out.push({ x: d[j].x + (d[j + 1]?.x - d[j].x || 0) * f, y: d[j].y + (d[j + 1]?.y - d[j].y || 0) * f });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
};
|
|
74
|
+
if (a.length < 2 || b.length < 2) return { max: Infinity, rms: Infinity };
|
|
75
|
+
const A = resample(a), B = resample(b);
|
|
76
|
+
// The worst point decides: a corner cut short of a curve is ONE place, and
|
|
77
|
+
// an average hides it. The RMS is the smooth measure a search descends on.
|
|
78
|
+
const d = A.map((p, i) => dist(p, B[i]));
|
|
79
|
+
return { max: Math.max(...d), rms: Math.sqrt(d.reduce((s, v) => s + v * v, 0) / d.length) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const segmentHitsRect = (p, q, r) => {
|
|
83
|
+
// Axis-aligned or diagonal segment vs rect: sample along the segment.
|
|
84
|
+
const n = Math.max(2, Math.ceil(dist(p, q) / 4));
|
|
85
|
+
for (let i = 0; i <= n; i++) {
|
|
86
|
+
const t = i / n;
|
|
87
|
+
if (inside({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }, r)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
export const contains = (outer, inner) => inner.x >= outer.x - 0.5 && inner.y >= outer.y - 0.5 && inner.x + inner.w <= outer.x + outer.w + 0.5 && inner.y + inner.h <= outer.y + outer.h + 0.5;
|
|
92
|
+
|
|
93
|
+
// ---- Traces.
|
|
94
|
+
|
|
95
|
+
// Inside the node's own outline: its ellipse when it is round (a disc's
|
|
96
|
+
// bounding-box corners are empty slide).
|
|
97
|
+
const insideNode = (p, r, round) => {
|
|
98
|
+
if (!round) return inside(p, r);
|
|
99
|
+
const rx = r.w / 2 - 0.5, ry = r.h / 2 - 0.5;
|
|
100
|
+
if (rx <= 0 || ry <= 0) return false;
|
|
101
|
+
const dx = (p.x - r.x - r.w / 2) / rx, dy = (p.y - r.y - r.h / 2) / ry;
|
|
102
|
+
return dx * dx + dy * dy < 1;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Where a walk from `a` (inside) toward `b` (outside) leaves the node.
|
|
106
|
+
function exitPoint(a, b, r, round) {
|
|
107
|
+
let lo = 0, hi = 1;
|
|
108
|
+
for (let i = 0; i < 24; i++) {
|
|
109
|
+
const mid = (lo + hi) / 2;
|
|
110
|
+
if (insideNode({ x: a.x + (b.x - a.x) * mid, y: a.y + (b.y - a.y) * mid }, r, round)) lo = mid;
|
|
111
|
+
else hi = mid;
|
|
112
|
+
}
|
|
113
|
+
return { x: a.x + (b.x - a.x) * hi, y: a.y + (b.y - a.y) * hi };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clipStart(pts, r, round) {
|
|
117
|
+
let k = -1;
|
|
118
|
+
for (let i = 0; i < pts.length - 1; i++) if (insideNode(pts[i], r, round)) k = i;
|
|
119
|
+
if (k < 0) return pts;
|
|
120
|
+
return [exitPoint(pts[k], pts[k + 1], r, round), ...pts.slice(k + 1)];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The trace with everything that runs INSIDE its own source or target cut
|
|
125
|
+
* away: a line is drawn between the nodes, not through them. A reader that put
|
|
126
|
+
* a fan's bus inside the source disc (2d9503ca slide 2) otherwise pulled two
|
|
127
|
+
* edges up through the disc and its label.
|
|
128
|
+
*/
|
|
129
|
+
export function sanitizeTrace(points, fromRect, toRect, { fromRound = false, toRound = false } = {}) {
|
|
130
|
+
const raw = (points ?? []).filter(finite).map((p) => ({ x: p.x, y: p.y }));
|
|
131
|
+
if (raw.length < 2) return raw;
|
|
132
|
+
let pts = raw;
|
|
133
|
+
if (fromRect?.w > 0 && fromRect?.h > 0) pts = clipStart(pts, fromRect, fromRound);
|
|
134
|
+
if (toRect?.w > 0 && toRect?.h > 0) pts = clipStart([...pts].reverse(), toRect, toRound).reverse();
|
|
135
|
+
// Nothing left between the nodes (they overlap, or the reader traced only
|
|
136
|
+
// inside them): the reader's own trace is the better evidence.
|
|
137
|
+
return traceLength(pts) >= 8 ? pts : raw;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const bezier = (p0, c1, c2, p1, t) => {
|
|
141
|
+
const u = 1 - t;
|
|
142
|
+
return { x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p1.x, y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p1.y };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The open strokes a `kind:path` element draws, each as an ordered point list
|
|
147
|
+
* (curves sampled), or [] when it is a closed or filled shape — which stays a
|
|
148
|
+
* drawn shape. Several subpaths are several lines.
|
|
149
|
+
*/
|
|
150
|
+
export function strokeTraces(e) {
|
|
151
|
+
if (e?.kind !== "path" || (e.text != null && String(e.text).trim() !== "")) return [];
|
|
152
|
+
const g = e.geometry;
|
|
153
|
+
const filled = e.fill && e.fill !== "none";
|
|
154
|
+
// Only a closed outline holds paint (the transcriber's own rule): an open
|
|
155
|
+
// spline, arc or path is a stroke whatever fill the reader named.
|
|
156
|
+
if (g?.type === "spline") return g.closed || !(g.points?.length >= 2) ? [] : [g.points.filter(finite)];
|
|
157
|
+
if (g?.type === "arc") {
|
|
158
|
+
// A full ring is a closed outline; a filled arc with a hole is a band.
|
|
159
|
+
if (Math.abs(g.sweep) >= 360 || (filled && (g.innerRatio ?? 0) > 0 && g.innerRatio < 1)) return [];
|
|
160
|
+
const at = (a) => ({ x: g.cx + g.rx * Math.cos((a * Math.PI) / 180), y: g.cy + g.ry * Math.sin((a * Math.PI) / 180) });
|
|
161
|
+
const n = Math.max(4, Math.ceil(Math.abs(g.sweep) / 10));
|
|
162
|
+
return [Array.from({ length: n + 1 }, (_, i) => at(g.start + (g.sweep * i) / n))];
|
|
163
|
+
}
|
|
164
|
+
if (g?.type === "path") {
|
|
165
|
+
// The commands as the transcriber draws them (the reader writes `M` as a
|
|
166
|
+
// whole segment, start in x1/y1).
|
|
167
|
+
const cmds = repairCommands(g.commands ?? [], e) ?? [];
|
|
168
|
+
if (cmds.some((c) => c?.op === "Z")) return [];
|
|
169
|
+
const out = [];
|
|
170
|
+
let cur = null, pen = null;
|
|
171
|
+
for (const c of cmds) {
|
|
172
|
+
const p = { x: c.x, y: c.y };
|
|
173
|
+
if (!finite(p)) continue;
|
|
174
|
+
if (c.op === "M") { if (cur?.length >= 2) out.push(cur); cur = [p]; }
|
|
175
|
+
else if (!cur) cur = [p];
|
|
176
|
+
else if (c.op === "C" || c.op === "Q") {
|
|
177
|
+
const c1 = { x: c.x1, y: c.y1 }, c2 = c.op === "C" ? { x: c.x2, y: c.y2 } : c1;
|
|
178
|
+
if (finite(c1) && finite(c2)) for (let k = 1; k <= 8; k++) cur.push(bezier(pen, c1, c2, p, k / 8));
|
|
179
|
+
else cur.push(p);
|
|
180
|
+
} else cur.push(p);
|
|
181
|
+
pen = p;
|
|
182
|
+
}
|
|
183
|
+
if (cur?.length >= 2) out.push(cur);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
if (g || (filled && e.points?.length >= 3) || !(e.points?.length >= 2)) return [];
|
|
187
|
+
return [e.points.filter(finite)];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A few plain strokes the size of an icon with no arrowhead — a tick, a cross,
|
|
192
|
+
* a chevron — are a glyph mark (the intake's role `mark`), not a line between
|
|
193
|
+
* things: never glued, written as a chain of native pieces with round caps.
|
|
194
|
+
*/
|
|
195
|
+
export function isGlyphStroke(e, traces = strokeTraces(e)) {
|
|
196
|
+
if (!traces.length) return false;
|
|
197
|
+
// A declared cap, or an end the reader says touches an element, makes it a
|
|
198
|
+
// line whatever its size.
|
|
199
|
+
const heads = e.ends && ["start", "end"].some((k) => e.ends[k] && e.ends[k] !== "none");
|
|
200
|
+
const declared = e.strokeEnds && ["start", "end"].some((k) => {
|
|
201
|
+
const s = e.strokeEnds[k];
|
|
202
|
+
return s && ((s.cap && s.cap !== "none") || (s.element && s.contact === "touches"));
|
|
203
|
+
});
|
|
204
|
+
if (heads || declared) return false;
|
|
205
|
+
const pts = traces.flat();
|
|
206
|
+
const w = Math.max(...pts.map((p) => p.x)) - Math.min(...pts.map((p) => p.x));
|
|
207
|
+
const h = Math.max(...pts.map((p) => p.y)) - Math.min(...pts.map((p) => p.y));
|
|
208
|
+
return Math.max(w, h) <= 32;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The point halfway along a trace, by length. */
|
|
212
|
+
export function traceMidpoint(pts) {
|
|
213
|
+
const total = traceLength(pts);
|
|
214
|
+
let acc = 0;
|
|
215
|
+
for (let i = 1; i < pts.length; i++) {
|
|
216
|
+
const seg = dist(pts[i], pts[i - 1]);
|
|
217
|
+
if (acc + seg >= total / 2) {
|
|
218
|
+
const f = seg ? (total / 2 - acc) / seg : 0;
|
|
219
|
+
return { x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * f, y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * f };
|
|
220
|
+
}
|
|
221
|
+
acc += seg;
|
|
222
|
+
}
|
|
223
|
+
return pts[0];
|
|
224
|
+
}
|