slidemind 0.3.19 → 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/engine-slides.mjs +1 -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,895 @@
|
|
|
1
|
+
// Every stroke a slide draws is ONE native PowerPoint object from a closed
|
|
2
|
+
// alphabet (the nativeLinesV1 directive contract): a connector (`cxnSp`,
|
|
3
|
+
// straight / bent / curved, glued at a real connection site of its node's own
|
|
4
|
+
// preset or free at a point), an open preset shape (`arc`, brackets, braces),
|
|
5
|
+
// or — when no single preset draws a free stroke within tolerance — a group
|
|
6
|
+
// chain of straight and arc pieces. Never a custGeom, never an svg.
|
|
7
|
+
//
|
|
8
|
+
// `fitNative` chooses the preset and its adjust values by MEASUREMENT: every
|
|
9
|
+
// candidate is evaluated with the one ECMA evaluator (preset-eval.mjs, the same
|
|
10
|
+
// code the exporter and the preview use) and scored by its ordered distance
|
|
11
|
+
// from the path the concept draws. Closed forms seed it (a circle for an arc,
|
|
12
|
+
// the trace's box for a bracket, a grid over the adjusts for a connector) and
|
|
13
|
+
// coordinate descent refines at most three adjusts. `lineDirective` writes the
|
|
14
|
+
// result as the contract's JSX. Width and colour are NOT decided here: the
|
|
15
|
+
// caller passes design tokens (the reader's weight class, never a pixel
|
|
16
|
+
// measurement), and the agent sets final weights by proportion.
|
|
17
|
+
//
|
|
18
|
+
// Shared by both clients; mirrored by scripts/sync-transcription-runtime.mjs —
|
|
19
|
+
// no Node or CLI imports here.
|
|
20
|
+
|
|
21
|
+
import { evalPreset, presetAdjusts, presetSites } from "./preset-eval.mjs";
|
|
22
|
+
import { pathDistance, pathErrors, pointRef, sanitizeTrace, segmentHitsRect, contains, traceLength } from "./line-fit.mjs";
|
|
23
|
+
import { headRoutePx } from "./placement.mjs";
|
|
24
|
+
|
|
25
|
+
/** OOXML angle unit: 60000ths of a degree. */
|
|
26
|
+
const DEG = 60000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every tolerance and search bound the fitter uses, in one table. Calibrated
|
|
30
|
+
* on the dev set, never per slide or per design.
|
|
31
|
+
*/
|
|
32
|
+
export const NATIVE_TOL = Object.freeze({
|
|
33
|
+
// A free stroke is ONE preset when its worst point is within
|
|
34
|
+
// max(freePx, freeFrac · length) of the concept; else it is a chain.
|
|
35
|
+
freePx: 2,
|
|
36
|
+
freeFrac: 0.04,
|
|
37
|
+
// A glyph mark's pieces are held to this (it is a few px across).
|
|
38
|
+
markPx: 1,
|
|
39
|
+
// A connector's handle may overshoot its box by at most this (a U-turn out
|
|
40
|
+
// of two same-side sites), beyond which it draws a loop the concept lacks.
|
|
41
|
+
adjOvershoot: 100000,
|
|
42
|
+
// A glued connector further than max(gluedPx, gluedFrac · length) from the
|
|
43
|
+
// concept is still the one glued connector, and the distance is warned.
|
|
44
|
+
gluedPx: 18,
|
|
45
|
+
gluedFrac: 0.15,
|
|
46
|
+
// An arc piece whose radius exceeds this × its length is a straight line
|
|
47
|
+
// (no off-slide circles for a slightly bent stroke).
|
|
48
|
+
straightRadiusSpan: 4,
|
|
49
|
+
// Curves are one loop when one circle holds all their points within this
|
|
50
|
+
// share of its radius.
|
|
51
|
+
loopFrac: 0.12,
|
|
52
|
+
maxChainPieces: 12,
|
|
53
|
+
// Connection sites tried per glued end: the nearest ones to where the
|
|
54
|
+
// concept attaches.
|
|
55
|
+
siteCandidates: 2,
|
|
56
|
+
// Seed configurations given a coarse descent, per preset; the leaders
|
|
57
|
+
// overall given the fine one.
|
|
58
|
+
seedsPerConfig: 2,
|
|
59
|
+
refinePerPreset: 3,
|
|
60
|
+
refineFinal: 4,
|
|
61
|
+
// A crossing of a text or picture the concept does not cross costs this
|
|
62
|
+
// while fitting the concept; the settle router, which only clears
|
|
63
|
+
// crossings, weighs one as never worth taking.
|
|
64
|
+
crossPenaltyPx: 30,
|
|
65
|
+
routeCrossPenaltyPx: 1000,
|
|
66
|
+
// Moving a settled connector's end to another site costs this.
|
|
67
|
+
siteChangePx: 40,
|
|
68
|
+
// Each adjust handle a preset needs costs this much distance: a simpler
|
|
69
|
+
// preset wins a tie.
|
|
70
|
+
adjustBiasPx: 0.75,
|
|
71
|
+
// Elbows off one site whose knees lie within this share one rail.
|
|
72
|
+
railPx: 12,
|
|
73
|
+
// Two ends closer than this are the same point.
|
|
74
|
+
samePointPx: 0.5,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The canonical design weight rungs (`--bw-*`), px. A reader's declared stroke
|
|
79
|
+
* width snaps to the nearest rung; the BRAND's own values never enter here, so
|
|
80
|
+
* a design changing its `--bw-*` changes no emitted JSX. No width = `default`.
|
|
81
|
+
*/
|
|
82
|
+
export const LINE_WEIGHT_RUNGS_PX = Object.freeze({ hairline: 1, medium: 2, bold: 4 });
|
|
83
|
+
|
|
84
|
+
/** The weight class of a reader's declared width (px), or "default" for none. */
|
|
85
|
+
export function weightClass(px) {
|
|
86
|
+
if (!(Number.isFinite(px) && px > 0)) return "default";
|
|
87
|
+
let best = null;
|
|
88
|
+
for (const [name, rung] of Object.entries(LINE_WEIGHT_RUNGS_PX)) {
|
|
89
|
+
if (!best || Math.abs(px - rung) < Math.abs(px - best[1])) best = [name, rung];
|
|
90
|
+
}
|
|
91
|
+
return best[0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const CONNECTOR_PRESETS = Object.freeze([
|
|
95
|
+
"straightConnector1",
|
|
96
|
+
"bentConnector2", "bentConnector3", "bentConnector4", "bentConnector5",
|
|
97
|
+
"curvedConnector2", "curvedConnector3", "curvedConnector4", "curvedConnector5",
|
|
98
|
+
]);
|
|
99
|
+
/** Open presets a free stroke may be, mirrored forms included through flips. */
|
|
100
|
+
const OPEN_SHAPES = Object.freeze(["leftBracket", "leftBrace"]);
|
|
101
|
+
const MIRRORED = Object.freeze({ leftBracket: "rightBracket", leftBrace: "rightBrace" });
|
|
102
|
+
const FAMILY_BY_KIND = Object.freeze({
|
|
103
|
+
curve: CONNECTOR_PRESETS.filter((p) => !p.startsWith("bent")),
|
|
104
|
+
arc: CONNECTOR_PRESETS.filter((p) => !p.startsWith("bent")),
|
|
105
|
+
orthogonal: CONNECTOR_PRESETS.filter((p) => !p.startsWith("curved")),
|
|
106
|
+
});
|
|
107
|
+
const FLIPS = [[false, false], [true, false], [false, true], [true, true]];
|
|
108
|
+
|
|
109
|
+
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
110
|
+
const finite = (p) => Number.isFinite(p?.x) && Number.isFinite(p?.y);
|
|
111
|
+
const r1 = (v) => Math.round(v * 10) / 10;
|
|
112
|
+
const norm360 = (deg) => ((deg % 360) + 360) % 360;
|
|
113
|
+
const adjustNames = (prst) => Object.keys(presetAdjusts(prst));
|
|
114
|
+
|
|
115
|
+
// ---- Geometry of one candidate, evaluated by the shared evaluator.
|
|
116
|
+
|
|
117
|
+
/** The stroked outline of an evaluated preset: its first stroke-only path. */
|
|
118
|
+
function strokeOf(evaluated) {
|
|
119
|
+
const paths = evaluated.paths ?? [];
|
|
120
|
+
const path = paths.find((p) => p.stroke && !p.fill) ?? paths.find((p) => p.stroke) ?? paths[0];
|
|
121
|
+
return path?.polylines?.[0]?.points ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A connector preset placed between two points exactly as the exporter places
|
|
126
|
+
* it (contract §1): the box whose corners are S and E (turned 90° about their
|
|
127
|
+
* midpoint for rot 90/270), flips chosen so the path starts on S and ends on E.
|
|
128
|
+
* Returns `{ points, flipH, flipV }` or null.
|
|
129
|
+
*/
|
|
130
|
+
export function connectorGeometry(prst, S, E, rot = 0, av = {}) {
|
|
131
|
+
const turned = rot === 90 || rot === 270;
|
|
132
|
+
const dx = Math.abs(E.x - S.x), dy = Math.abs(E.y - S.y);
|
|
133
|
+
const w = turned ? dy : dx, h = turned ? dx : dy;
|
|
134
|
+
const x = (S.x + E.x) / 2 - w / 2, y = (S.y + E.y) / 2 - h / 2;
|
|
135
|
+
const eps = 1e-6 * Math.max(1, w, h);
|
|
136
|
+
// Every connector preset runs corner to corner, so the flips depend only on
|
|
137
|
+
// the turn and the direction from S to E: the one that worked is tried first.
|
|
138
|
+
const key = `${rot}|${E.x >= S.x}|${E.y >= S.y}`;
|
|
139
|
+
const known = FLIP_BY_DIRECTION.get(key);
|
|
140
|
+
for (const [flipH, flipV] of known ? [known, ...FLIPS] : FLIPS) {
|
|
141
|
+
const pts = strokeOf(evalPreset(prst, w, h, av, { x, y, rot: rot * DEG, flipH, flipV }));
|
|
142
|
+
if (pts?.length >= 2 && dist(pts[0], S) <= eps + 1e-3 && dist(pts.at(-1), E) <= eps + 1e-3) {
|
|
143
|
+
if (w > eps && h > eps) FLIP_BY_DIRECTION.set(key, [flipH, flipV]);
|
|
144
|
+
return { points: pts, flipH, flipV };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const FLIP_BY_DIRECTION = new Map();
|
|
150
|
+
|
|
151
|
+
/** An open preset shape in its (unrotated) box, `rot` in degrees. */
|
|
152
|
+
export function shapeGeometry(prst, box, av = {}, rot = 0, flipH = false, flipV = false) {
|
|
153
|
+
return strokeOf(evalPreset(prst, box.w, box.h, av, { x: box.x, y: box.y, rot: rot * DEG, flipH, flipV }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A NativeLine's outline in slot px (a chain concatenates its pieces). */
|
|
157
|
+
export function nativePoints(n) {
|
|
158
|
+
if (n.object === "grp") return n.children.flatMap(nativePoints);
|
|
159
|
+
if (n.object === "cxn") return connectorGeometry(n.prst, n.start, n.end, n.rot, n.av)?.points ?? [];
|
|
160
|
+
return shapeGeometry(n.prst, n.box, n.av, n.rot, n.flipH, n.flipV) ?? [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Nodes: the preset a glue target exports as, and its real sites.
|
|
164
|
+
|
|
165
|
+
const CLIP_ADJ = { chevron: 0.25, trapezoid: 0.12, parallelogram: 0.18 };
|
|
166
|
+
// Presets whose outline their box (or its inscribed ellipse) describes.
|
|
167
|
+
const OUTLINE_BOX = new Set(["rect", "roundRect", "ellipse"]);
|
|
168
|
+
// Painted boxes (B), and the other placed elements (text, icons, charts),
|
|
169
|
+
// which export as plain rectangles whatever their spec shape says.
|
|
170
|
+
const BOX_KINDS = new Set(["card", "panel", "disc", "badge", "shape", "bar"]);
|
|
171
|
+
const NODE_KINDS = new Set([...BOX_KINDS, "text", "number", "icon", "image", "chart"]);
|
|
172
|
+
// A text on a painted shape is written as that shape (the transcriber's B).
|
|
173
|
+
const drawnAsBox = (e) => BOX_KINDS.has(e.kind) || ((e.kind === "text" || e.kind === "number") && e.shape && e.fill && e.fill !== "none");
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The preset a node exports as — the same rule the helpers emit it with — or
|
|
177
|
+
* null when it has no preset (a traced outline, a turned triangle): such a node
|
|
178
|
+
* cannot be glued to, and an end on it stays a free point.
|
|
179
|
+
* `rect` overrides the element's own rect (a measured render).
|
|
180
|
+
*/
|
|
181
|
+
export function nodePreset(e, rect = null) {
|
|
182
|
+
if (!e) return null;
|
|
183
|
+
const g = e.geometry;
|
|
184
|
+
if (e.kind === "path" && g?.type === "arc" && Math.abs(g.sweep) >= 360 && g.rx > 0 && g.ry > 0) {
|
|
185
|
+
return { prst: "ellipse", av: {}, box: rect ?? { x: g.cx - g.rx, y: g.cy - g.ry, w: 2 * g.rx, h: 2 * g.ry } };
|
|
186
|
+
}
|
|
187
|
+
// A band segment is written as its blockArc (or a full band as its donut)
|
|
188
|
+
// and glues at that preset's own sites.
|
|
189
|
+
if (e.kind === "path" && ["arc", "sector"].includes(g?.type) && e.fill && e.fill !== "none" && g.innerRatio > 0 && g.innerRatio < 1) {
|
|
190
|
+
const band = bandNative(e.id, g);
|
|
191
|
+
return band ? { prst: band.prst, av: band.av, box: rect ?? band.box } : null;
|
|
192
|
+
}
|
|
193
|
+
const box = rect ?? { x: e.x, y: e.y, w: e.w, h: e.h };
|
|
194
|
+
// Only an element the helpers draw as a box: a drawn figure or a stroke is
|
|
195
|
+
// no shape PowerPoint can glue to.
|
|
196
|
+
if (!(box.w > 0 && box.h > 0) || !NODE_KINDS.has(e.kind)) return null;
|
|
197
|
+
const s = drawnAsBox(e) ? e.shape : null;
|
|
198
|
+
if (s === "circle" || s === "ring" || s === "ellipse") return { prst: "ellipse", av: {}, box };
|
|
199
|
+
if (s === "rounded") return { prst: "roundRect", av: {}, box };
|
|
200
|
+
if (s === "pill") return { prst: "roundRect", av: { adj: 50000 }, box };
|
|
201
|
+
if (s === "diamond") return { prst: "diamond", av: {}, box };
|
|
202
|
+
if (s in CLIP_ADJ) {
|
|
203
|
+
// The helpers' clip polygons as the preset draws them: the notch/slant is
|
|
204
|
+
// a share of the WIDTH, the preset's adjust a share of the short side.
|
|
205
|
+
if (s === "chevron" && box.w < box.h) return null;
|
|
206
|
+
return { prst: s, av: { adj: Math.round((CLIP_ADJ[s] * box.w / Math.min(box.w, box.h)) * 100000) }, box };
|
|
207
|
+
}
|
|
208
|
+
if (!s || s === "rect" || !drawnAsBox(e)) return { prst: "rect", av: {}, box };
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A node's connection sites in slot px, in cxnLst order (index = site). */
|
|
213
|
+
export function nodeSites(node) {
|
|
214
|
+
return presetSites(node.prst, node.box.w, node.box.h, node.av ?? {}, { x: node.box.x, y: node.box.y });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- Circles and ellipses through a trace.
|
|
218
|
+
|
|
219
|
+
function solve3(m, v) {
|
|
220
|
+
const det = (a) => a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
|
221
|
+
const d = det(m);
|
|
222
|
+
if (Math.abs(d) < 1e-12) return null;
|
|
223
|
+
return [0, 1, 2].map((k) => det(m.map((row, i) => row.map((c, j) => (j === k ? v[i] : c)))) / d);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Least-squares (Kåsa) circle through points: `{ cx, cy, rx, ry }` or null. */
|
|
227
|
+
export function circleFit(pts) {
|
|
228
|
+
if (pts.length < 3) return null;
|
|
229
|
+
const mx = pts.reduce((s, p) => s + p.x, 0) / pts.length, my = pts.reduce((s, p) => s + p.y, 0) / pts.length;
|
|
230
|
+
let sxx = 0, sxy = 0, syy = 0, sx = 0, sy = 0, sxz = 0, syz = 0, sz = 0;
|
|
231
|
+
for (const p of pts) {
|
|
232
|
+
const x = p.x - mx, y = p.y - my, z = x * x + y * y;
|
|
233
|
+
sxx += x * x; sxy += x * y; syy += y * y; sx += x; sy += y; sxz += x * z; syz += y * z; sz += z;
|
|
234
|
+
}
|
|
235
|
+
const s = solve3([[sxx, sxy, sx], [sxy, syy, sy], [sx, sy, pts.length]], [-sxz, -syz, -sz]);
|
|
236
|
+
if (!s) return null;
|
|
237
|
+
const cx = -s[0] / 2, cy = -s[1] / 2, r2 = cx * cx + cy * cy - s[2];
|
|
238
|
+
if (!(r2 > 0)) return null;
|
|
239
|
+
const r = Math.sqrt(r2);
|
|
240
|
+
return { cx: cx + mx, cy: cy + my, rx: r, ry: r };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Least-squares axis-aligned ellipse A·x² + C·y² + D·x + E·y = 1 (centred on the mean). */
|
|
244
|
+
function ellipseFit(pts) {
|
|
245
|
+
if (pts.length < 5) return null;
|
|
246
|
+
const mx = pts.reduce((s, p) => s + p.x, 0) / pts.length, my = pts.reduce((s, p) => s + p.y, 0) / pts.length;
|
|
247
|
+
const rows = pts.map((p) => { const x = p.x - mx, y = p.y - my; return [x * x, y * y, x, y]; });
|
|
248
|
+
const n = 4, ata = Array.from({ length: n }, () => Array(n).fill(0)), atb = Array(n).fill(0);
|
|
249
|
+
for (const r of rows) for (let i = 0; i < n; i++) { atb[i] += r[i]; for (let j = 0; j < n; j++) ata[i][j] += r[i] * r[j]; }
|
|
250
|
+
// Gaussian elimination.
|
|
251
|
+
for (let c = 0; c < n; c++) {
|
|
252
|
+
let piv = c;
|
|
253
|
+
for (let r = c + 1; r < n; r++) if (Math.abs(ata[r][c]) > Math.abs(ata[piv][c])) piv = r;
|
|
254
|
+
if (Math.abs(ata[piv][c]) < 1e-12) return null;
|
|
255
|
+
[ata[c], ata[piv]] = [ata[piv], ata[c]];
|
|
256
|
+
[atb[c], atb[piv]] = [atb[piv], atb[c]];
|
|
257
|
+
for (let r = 0; r < n; r++) {
|
|
258
|
+
if (r === c) continue;
|
|
259
|
+
const f = ata[r][c] / ata[c][c];
|
|
260
|
+
for (let k = c; k < n; k++) ata[r][k] -= f * ata[c][k];
|
|
261
|
+
atb[r] -= f * atb[c];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const [A, C, D, E] = atb.map((v, i) => v / ata[i][i]);
|
|
265
|
+
if (!(A > 0 && C > 0)) return null;
|
|
266
|
+
const cx = -D / (2 * A), cy = -E / (2 * C);
|
|
267
|
+
const k = 1 + A * cx * cx + C * cy * cy;
|
|
268
|
+
if (!(k > 0)) return null;
|
|
269
|
+
return { cx: cx + mx, cy: cy + my, rx: Math.sqrt(k / A), ry: Math.sqrt(k / C) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- Scoring and search.
|
|
273
|
+
|
|
274
|
+
// What a search descends on: the ordered RMS, with the worst point weighed in
|
|
275
|
+
// (the max alone is flat almost everywhere). Selection reports the max.
|
|
276
|
+
const searchError = (a, b) => { const e = pathErrors(a, b); return e.rms + 0.25 * e.max; };
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* How far a native line may run from the concept's trace: a glued connector is
|
|
280
|
+
* held to its nodes' real sites, a free stroke to the ink. The draft check
|
|
281
|
+
* (draft-lines.mjs) uses the same rule, so the fitter's own output passes it.
|
|
282
|
+
*/
|
|
283
|
+
export const toleranceFor = (trace, glued) => glued
|
|
284
|
+
? Math.max(NATIVE_TOL.gluedPx, NATIVE_TOL.gluedFrac * traceLength(trace))
|
|
285
|
+
: Math.max(NATIVE_TOL.freePx, NATIVE_TOL.freeFrac * traceLength(trace));
|
|
286
|
+
|
|
287
|
+
function crossings(points, obstacles = [], skip = []) {
|
|
288
|
+
let hits = 0;
|
|
289
|
+
for (const t of obstacles) {
|
|
290
|
+
if (skip.some((r) => r && (contains(r, t) || (!t.text && !t.content && contains(t, r))))) continue;
|
|
291
|
+
const box = { x: t.x - 2, y: t.y - 2, w: t.w + 4, h: t.h + 4 };
|
|
292
|
+
for (let i = 0; i + 1 < points.length; i++) if (segmentHitsRect(points[i], points[i + 1], box)) { hits++; break; }
|
|
293
|
+
}
|
|
294
|
+
return hits;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Coordinate descent on a preset's adjusts; `score(av)` → number. */
|
|
298
|
+
function descend(av0, names, score, first = 32000, last = 250) {
|
|
299
|
+
let best = { av: { ...av0 }, score: score(av0) };
|
|
300
|
+
for (let step = first; step >= last; step /= 2) {
|
|
301
|
+
for (let round = 0; round < 24; round++) {
|
|
302
|
+
let moved = false;
|
|
303
|
+
for (const n of names) for (const s of [step, -step]) {
|
|
304
|
+
const av = { ...best.av, [n]: Math.round(best.av[n] + s) };
|
|
305
|
+
const sc = score(av);
|
|
306
|
+
if (sc < best.score - 1e-9) { best = { av, score: sc }; moved = true; }
|
|
307
|
+
}
|
|
308
|
+
if (!moved) break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return best;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Seed values for a connector's adjusts: overshoot included for a one-handle
|
|
315
|
+
// preset (a U-turn out of two same-side sites).
|
|
316
|
+
const SEEDS_1 = [-30000, 15000, 50000, 85000, 130000];
|
|
317
|
+
const SEEDS_2 = [15000, 40000, 65000, 90000];
|
|
318
|
+
const SEEDS_3 = [20000, 50000, 80000];
|
|
319
|
+
function seedGrid(names) {
|
|
320
|
+
if (!names.length) return [{}];
|
|
321
|
+
const values = [SEEDS_1, SEEDS_2, SEEDS_3][names.length - 1] ?? SEEDS_3;
|
|
322
|
+
let out = [{}];
|
|
323
|
+
for (const n of names) out = out.flatMap((av) => values.map((v) => ({ ...av, [n]: v })));
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const inRange = (av) => Object.values(av).every((v) => v >= -NATIVE_TOL.adjOvershoot && v <= 100000 + NATIVE_TOL.adjOvershoot);
|
|
328
|
+
|
|
329
|
+
// A bent connector whose handle folds a leg to nothing IS the simpler preset:
|
|
330
|
+
// that candidate is left to the preset that draws it without the extra leg.
|
|
331
|
+
const collapsed = (prst, pts) => prst.startsWith("bent") && pts.some((p, i) => i > 0 && dist(p, pts[i - 1]) < 1);
|
|
332
|
+
|
|
333
|
+
function sitesNear(node, target, k) {
|
|
334
|
+
const sites = nodeSites(node);
|
|
335
|
+
if (!target) return sites.slice(0, k);
|
|
336
|
+
return [...sites].sort((a, b) => dist(a, target) - dist(b, target)).slice(0, k);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The best connector between two ends. `ends[i]` is `{ node, preset }` (glued;
|
|
341
|
+
* the site is searched) or `{ point }` (free). `trace` is the concept's path
|
|
342
|
+
* (null: reader declared a relation with no drawn path). `keep` fixes the
|
|
343
|
+
* preset (settle keeps a line's family). Returns a NativeLine (`object: "cxn"`).
|
|
344
|
+
*/
|
|
345
|
+
function fitConnector(ends, trace, { prsts = CONNECTOR_PRESETS, obstacles = [], keep = [null, null], crossPenalty = NATIVE_TOL.crossPenaltyPx, tol = null } = {}) {
|
|
346
|
+
const [a, b] = ends;
|
|
347
|
+
const centre = (n) => ({ x: n.box.x + n.box.w / 2, y: n.box.y + n.box.h / 2 });
|
|
348
|
+
const hasTrace = trace?.length >= 2;
|
|
349
|
+
// Where a glued end looks for its sites: the concept's attach point, else
|
|
350
|
+
// toward the other end.
|
|
351
|
+
const targetOf = (other, at) => (hasTrace ? at : other.point ?? centre(other.preset));
|
|
352
|
+
const options = (e, other, at, kept) => {
|
|
353
|
+
if (!e.node) return [{ point: e.point, x: e.point.x, y: e.point.y }];
|
|
354
|
+
const all = nodeSites(e.preset);
|
|
355
|
+
const near = sitesNear(e.preset, targetOf(other, at), NATIVE_TOL.siteCandidates);
|
|
356
|
+
if (kept != null && all[kept] && !near.includes(all[kept])) near.push(all[kept]);
|
|
357
|
+
return near.map((s) => ({ node: e.node, site: s.idx, x: s.x, y: s.y, angle: s.angle, changed: kept != null && s.idx !== kept }));
|
|
358
|
+
};
|
|
359
|
+
const A = options(a, b, hasTrace ? trace[0] : null, keep[0]), B = options(b, a, hasTrace ? trace.at(-1) : null, keep[1]);
|
|
360
|
+
const skip = [a.preset?.box, b.preset?.box];
|
|
361
|
+
const seen = hasTrace ? crossings(trace, obstacles, skip) : 0;
|
|
362
|
+
// A leg that leaves a glued site against its outward direction, where the
|
|
363
|
+
// concept gives no path to say so.
|
|
364
|
+
const against = (pts, end, atStart) => {
|
|
365
|
+
if (end.angle == null || pts.length < 2) return 0;
|
|
366
|
+
const [p, q] = atStart ? [pts[0], pts.find((r) => dist(r, pts[0]) > 1) ?? pts[1]] : [pts.at(-1), [...pts].reverse().find((r) => dist(r, pts.at(-1)) > 1) ?? pts.at(-2)];
|
|
367
|
+
const dir = Math.atan2(q.y - p.y, q.x - p.x) * 180 / Math.PI;
|
|
368
|
+
const d = Math.abs(((dir - end.angle + 540) % 360) - 180);
|
|
369
|
+
return d > 60 ? 1 : 0;
|
|
370
|
+
};
|
|
371
|
+
const scoreOf = (pts, s, e) => {
|
|
372
|
+
let v = hasTrace ? searchError(pts, trace) : traceLength(pts) + 50 * (against(pts, s, true) + against(pts, e, false));
|
|
373
|
+
v += NATIVE_TOL.siteChangePx * ((s.changed ? 1 : 0) + (e.changed ? 1 : 0));
|
|
374
|
+
if (obstacles.length) v += crossPenalty * Math.max(0, crossings(pts, obstacles, skip) - seen);
|
|
375
|
+
return v;
|
|
376
|
+
};
|
|
377
|
+
const seeds = [];
|
|
378
|
+
for (const prst of prsts) {
|
|
379
|
+
const names = adjustNames(prst);
|
|
380
|
+
for (const rot of prst === "straightConnector1" ? [0] : [0, 90]) for (const s of A) for (const e of B) {
|
|
381
|
+
if (dist(s, e) < NATIVE_TOL.samePointPx) continue;
|
|
382
|
+
for (const av of seedGrid(names)) {
|
|
383
|
+
const g = connectorGeometry(prst, s, e, rot, av);
|
|
384
|
+
if (!g || collapsed(prst, g.points)) continue;
|
|
385
|
+
seeds.push({ prst, rot, s, e, av, names, score: scoreOf(g.points, s, e) });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (!seeds.length) return null;
|
|
390
|
+
// The best seeds of each prst/rot/site configuration (two: a three-handle
|
|
391
|
+
// preset has more than one basin).
|
|
392
|
+
const byConfig = new Map();
|
|
393
|
+
for (const c of seeds) {
|
|
394
|
+
const key = `${c.prst}|${c.rot}|${c.s.site ?? "p"}|${c.e.site ?? "p"}`;
|
|
395
|
+
byConfig.set(key, [...(byConfig.get(key) ?? []), c]);
|
|
396
|
+
}
|
|
397
|
+
const leaders = [...byConfig.values()].flatMap((list) => list.sort((x, y) => x.score - y.score).slice(0, NATIVE_TOL.seedsPerConfig));
|
|
398
|
+
// Every preset's best configurations get a coarse descent (so a preset whose
|
|
399
|
+
// seeds landed badly still competes); the leaders then a fine one.
|
|
400
|
+
const refine = (c, first, last) => {
|
|
401
|
+
if (!c.names.length) return c;
|
|
402
|
+
const r = descend(c.av, c.names, (av) => { const g = connectorGeometry(c.prst, c.s, c.e, c.rot, av); return g && inRange(av) && !collapsed(c.prst, g.points) ? scoreOf(g.points, c.s, c.e) : Infinity; }, first, last);
|
|
403
|
+
return { ...c, av: r.av, score: r.score };
|
|
404
|
+
};
|
|
405
|
+
const total = (c) => c.score + NATIVE_TOL.adjustBiasPx * c.names.length;
|
|
406
|
+
const coarse = [];
|
|
407
|
+
for (const prst of prsts) {
|
|
408
|
+
const mine = leaders.filter((c) => c.prst === prst).sort((x, y) => x.score - y.score).slice(0, NATIVE_TOL.refinePerPreset);
|
|
409
|
+
coarse.push(...mine.map((c) => refine(c, 16000, 4000)));
|
|
410
|
+
}
|
|
411
|
+
coarse.sort((x, y) => total(x) - total(y));
|
|
412
|
+
const end = (x) => (x.node ? { node: x.node, site: x.site } : { point: { x: x.x, y: x.y } });
|
|
413
|
+
const nativeOf = (c) => {
|
|
414
|
+
const n = { object: "cxn", prst: c.prst, av: c.av, rot: c.rot, from: end(c.s), to: end(c.e), start: { x: c.s.x, y: c.s.y }, end: { x: c.e.x, y: c.e.y } };
|
|
415
|
+
n.devPx = hasTrace ? pathDistance(nativePoints(n), trace) : null;
|
|
416
|
+
return n;
|
|
417
|
+
};
|
|
418
|
+
const native = nativeOf(coarse.slice(0, NATIVE_TOL.refineFinal).map((c) => refine(c, 2000, 250)).sort((x, y) => total(x) - total(y))[0]);
|
|
419
|
+
if (!(hasTrace && tol != null && native.devPx > tol)) return native;
|
|
420
|
+
// No preset follows the concept at these sites: every candidate misses by
|
|
421
|
+
// about as much, so the fewest legs win (within one tolerance of the best) —
|
|
422
|
+
// never a detour pinned at an adjust's limit that draws a loop the concept
|
|
423
|
+
// lacks.
|
|
424
|
+
const legs = (n) => { const pts = nativePoints(n); let k = 0; for (let i = 1; i < pts.length; i++) if (dist(pts[i], pts[i - 1]) > 1) k++; return k; };
|
|
425
|
+
const plain = coarse.map(nativeOf).filter((n) => n.devPx <= native.devPx + tol);
|
|
426
|
+
return plain.sort((x, y) => legs(x) - legs(y) || x.devPx - y.devPx)[0] ?? native;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The arc on a circle (or ellipse) that draws `pts`, or null when it would be a straight line. */
|
|
430
|
+
function fitArc(pts, circle = null, slot = null) {
|
|
431
|
+
if (pts.length < 3) return null;
|
|
432
|
+
const tryOne = (c) => {
|
|
433
|
+
if (!c || !(c.rx > 0 && c.ry > 0)) return null;
|
|
434
|
+
// A short stroke on a huge circle is a straight line read as bowed — but
|
|
435
|
+
// a loop's own circle is given, not guessed from this one stroke.
|
|
436
|
+
if (!circle && Math.max(c.rx, c.ry) > NATIVE_TOL.straightRadiusSpan * Math.max(1, traceLength(pts))) return null;
|
|
437
|
+
// A circle guessed from one stroke stays inside the content slot: a gentle
|
|
438
|
+
// bend is a curved connector, not a sliver of a circle far off the slide.
|
|
439
|
+
if (!circle && slot && (c.cx - c.rx < -1 || c.cy - c.ry < -1 || c.cx + c.rx > slot.w + 1 || c.cy + c.ry > slot.h + 1)) return null;
|
|
440
|
+
const ang = (p) => Math.atan2(p.y - c.cy, p.x - c.cx) * 180 / Math.PI;
|
|
441
|
+
let sweep = 0;
|
|
442
|
+
for (let i = 1; i < pts.length; i++) sweep += ((ang(pts[i]) - ang(pts[i - 1]) + 540) % 360) - 180;
|
|
443
|
+
if (Math.abs(sweep) < 1) return null;
|
|
444
|
+
// The preset runs clockwise from adj1 to adj2 (y down): a counter-clockwise
|
|
445
|
+
// stroke is the same arc drawn from its other end.
|
|
446
|
+
const reversed = sweep < 0;
|
|
447
|
+
const [a0, a1] = reversed ? [ang(pts.at(-1)), ang(pts[0])] : [ang(pts[0]), ang(pts.at(-1))];
|
|
448
|
+
const box = { x: c.cx - c.rx, y: c.cy - c.ry, w: 2 * c.rx, h: 2 * c.ry };
|
|
449
|
+
const av = { adj1: Math.round(norm360(a0) * DEG), adj2: Math.round(norm360(a1) * DEG) };
|
|
450
|
+
const n = { object: "sp", prst: "arc", av, rot: 0, flipH: false, flipV: false, box, reversed };
|
|
451
|
+
const drawn = nativePoints(n);
|
|
452
|
+
// A loop's own circle may reach past the slot's edge (the arc shape's box
|
|
453
|
+
// is the whole circle, and no gate measures a line by its box: slot-fit
|
|
454
|
+
// skips line directives) — only its stroke stays inside. Refusing the box
|
|
455
|
+
// broke every loop drawn near the slot's edge into curved connectors and
|
|
456
|
+
// straight chains (66452cbd).
|
|
457
|
+
if (circle && slot && drawn.some((p) => p.x < -1 || p.y < -1 || p.x > slot.w + 1 || p.y > slot.h + 1)) return null;
|
|
458
|
+
n.score = pathDistance(reversed ? [...drawn].reverse() : drawn, pts);
|
|
459
|
+
return n;
|
|
460
|
+
};
|
|
461
|
+
if (circle) return tryOne(circle);
|
|
462
|
+
const round = tryOne(circleFit(pts));
|
|
463
|
+
const oval = tryOne(ellipseFit(pts));
|
|
464
|
+
return [round, oval].filter(Boolean).sort((x, y) => x.score - y.score)[0] ?? null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* A loop member's path on its loop's ONE circle, in the trace's direction: a
|
|
469
|
+
* reader samples each arc at a few scattered points, and its end points stop
|
|
470
|
+
* short of a node or run into it. A glued end whose node the circle passes
|
|
471
|
+
* through starts at that node's centre angle, and the part inside the node's
|
|
472
|
+
* outline is cut away, so the arc meets the node where the circle does.
|
|
473
|
+
*/
|
|
474
|
+
function loopTrace(trace, c, ends, box, round, n = 48) {
|
|
475
|
+
const ang = (p) => Math.atan2(p.y - c.cy, p.x - c.cx);
|
|
476
|
+
const at = (a) => ({ x: c.cx + c.rx * Math.cos(a), y: c.cy + c.ry * Math.sin(a) });
|
|
477
|
+
let sweep = 0;
|
|
478
|
+
for (let i = 1; i < trace.length; i++) sweep += ((ang(trace[i]) - ang(trace[i - 1]) + 3 * Math.PI) % (2 * Math.PI)) - Math.PI;
|
|
479
|
+
if (Math.abs(sweep) < 1e-3) return trace;
|
|
480
|
+
const endAngle = (e, p) => {
|
|
481
|
+
const b = box(e);
|
|
482
|
+
if (!b) return ang(p);
|
|
483
|
+
const centre = { x: b.x + b.w / 2, y: b.y + b.h / 2 };
|
|
484
|
+
const a = ang(centre);
|
|
485
|
+
return dist(at(a), centre) < Math.min(b.w, b.h) / 2 ? a : ang(p);
|
|
486
|
+
};
|
|
487
|
+
const a0 = endAngle(ends[0], trace[0]);
|
|
488
|
+
const a1 = endAngle(ends[1], trace.at(-1));
|
|
489
|
+
const dir = Math.sign(sweep), turn = 2 * Math.PI;
|
|
490
|
+
const span = dir * ((((a1 - a0) * dir) % turn + turn) % turn);
|
|
491
|
+
if (Math.abs(span) < 1e-3) return trace;
|
|
492
|
+
const pts = Array.from({ length: n + 1 }, (_, i) => at(a0 + (span * i) / n));
|
|
493
|
+
return sanitizeTrace(pts, box(ends[0]), box(ends[1]), { fromRound: round(ends[0]), toRound: round(ends[1]) });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The bracket or brace (any turn/mirror) that draws `pts`, or null. */
|
|
497
|
+
function fitOpenShape(pts) {
|
|
498
|
+
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
|
499
|
+
const bb = { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
500
|
+
if (bb.w < 1 || bb.h < 1) return null;
|
|
501
|
+
const c = { x: bb.x + bb.w / 2, y: bb.y + bb.h / 2 };
|
|
502
|
+
const back = [...pts].reverse();
|
|
503
|
+
const seeds = [];
|
|
504
|
+
for (const prst of OPEN_SHAPES) for (const rot of [0, 90]) {
|
|
505
|
+
const box = rot ? { x: c.x - bb.h / 2, y: c.y - bb.w / 2, w: bb.h, h: bb.w } : bb;
|
|
506
|
+
for (const [flipH, flipV] of FLIPS) for (const reversed of [false, true]) {
|
|
507
|
+
const av = presetAdjusts(prst);
|
|
508
|
+
const score = (v) => { const g = shapeGeometry(prst, box, v, rot, flipH, flipV); return g ? searchError(g, reversed ? back : pts) : Infinity; };
|
|
509
|
+
seeds.push({ prst, rot, box, flipH, flipV, reversed, av, score, s: score(av) });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
seeds.sort((x, y) => x.s - y.s);
|
|
513
|
+
let best = null;
|
|
514
|
+
for (const c2 of seeds.slice(0, NATIVE_TOL.refinePerPreset * OPEN_SHAPES.length)) {
|
|
515
|
+
const names = adjustNames(c2.prst);
|
|
516
|
+
// A curl radius or apex position lives inside the box.
|
|
517
|
+
const r = descend(c2.av, names, (v) => (Object.values(v).every((x) => x >= 0 && x <= 100000) ? c2.score(v) : Infinity), 8000, 125);
|
|
518
|
+
const total = r.score + NATIVE_TOL.adjustBiasPx * names.length;
|
|
519
|
+
if (!best || total < best.total) best = { ...c2, av: r.av, score: r.score, total };
|
|
520
|
+
}
|
|
521
|
+
if (!best) return null;
|
|
522
|
+
const n = { object: "sp", prst: best.prst, av: best.av, rot: best.rot, flipH: best.flipH, flipV: best.flipV, box: best.box, reversed: best.reversed };
|
|
523
|
+
n.score = pathDistance(nativePoints(n), best.reversed ? back : pts);
|
|
524
|
+
return n;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** One straight or arc piece for a chain (`straight`: never an arc). */
|
|
528
|
+
function fitPiece(pts, slot = null, straightOnly = false) {
|
|
529
|
+
const straight = { object: "cxn", prst: "straightConnector1", av: {}, rot: 0, from: { point: pts[0] }, to: { point: pts.at(-1) }, start: pts[0], end: pts.at(-1) };
|
|
530
|
+
straight.score = pathDistance([pts[0], pts.at(-1)], pts);
|
|
531
|
+
const arc = pts.length >= 3 && !straightOnly ? fitArc(pts, null, slot) : null;
|
|
532
|
+
return arc && arc.score < straight.score ? arc : straight;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// The index of the point farthest from the chord of pts (Ramer–Douglas–Peucker).
|
|
536
|
+
function farthest(pts) {
|
|
537
|
+
const a = pts[0], b = pts.at(-1);
|
|
538
|
+
const len = dist(a, b) || 1;
|
|
539
|
+
let k = Math.floor(pts.length / 2), best = -1;
|
|
540
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
541
|
+
const d = Math.abs((b.x - a.x) * (a.y - pts[i].y) - (a.x - pts[i].x) * (b.y - a.y)) / len;
|
|
542
|
+
if (d > best) { best = d; k = i; }
|
|
543
|
+
}
|
|
544
|
+
return k;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** The fewest straight/arc pieces that draw a free stroke within `tol`. */
|
|
548
|
+
function fitChain(trace, tol, slot = null, straightOnly = false) {
|
|
549
|
+
const pieces = [];
|
|
550
|
+
const split = (pts, depth) => {
|
|
551
|
+
const piece = fitPiece(pts, slot, straightOnly);
|
|
552
|
+
if (piece.score <= tol || pts.length <= 2 || depth >= 6) { pieces.push(piece); return; }
|
|
553
|
+
const k = farthest(pts);
|
|
554
|
+
split(pts.slice(0, k + 1), depth + 1);
|
|
555
|
+
split(pts.slice(k), depth + 1);
|
|
556
|
+
};
|
|
557
|
+
split(trace, 0);
|
|
558
|
+
return pieces.slice(0, NATIVE_TOL.maxChainPieces);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function unionBox(children) {
|
|
562
|
+
const pts = children.flatMap((c) => (c.object === "sp" ? [{ x: c.box.x, y: c.box.y }, { x: c.box.x + c.box.w, y: c.box.y + c.box.h }] : [c.start, c.end]));
|
|
563
|
+
const x = Math.min(...pts.map((p) => p.x)), y = Math.min(...pts.map((p) => p.y));
|
|
564
|
+
return { x, y, w: Math.max(1, Math.max(...pts.map((p) => p.x)) - x), h: Math.max(1, Math.max(...pts.map((p) => p.y)) - y) };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// One piece is the line itself; several are a group.
|
|
568
|
+
const chainOrPiece = (id, pieces) => (pieces.length === 1 ? { ...pieces[0], devPx: pieces[0].score } : { ...chainNative(id, pieces), devPx: null });
|
|
569
|
+
|
|
570
|
+
/** A chain of free pieces as one group NativeLine. */
|
|
571
|
+
export function chainNative(id, pieces) {
|
|
572
|
+
const children = pieces.map((p, i) => ({ ...p, id: `${id}-${i + 1}` }));
|
|
573
|
+
return { object: "grp", id, children, box: unionBox(children) };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The native object for one Line (concept-lines.mjs). `nodes`: Map id →
|
|
578
|
+
* `nodePreset` result (a glued end whose node has none is a free point).
|
|
579
|
+
* `loop`: a shared circle `{ cx, cy, rx, ry }` for a loop member.
|
|
580
|
+
* Returns a NativeLine `{ id, object, prst, av, rot, flipH, flipV, from, to,
|
|
581
|
+
* start, end, box, children, reversed, devPx, tolPx, notes }`, or null when the
|
|
582
|
+
* Line has no drawable ends.
|
|
583
|
+
*/
|
|
584
|
+
export function fitNative(line, nodes = new Map(), loop = null, opts = {}) {
|
|
585
|
+
const notes = [];
|
|
586
|
+
const raw = (line.path?.points ?? []).filter(finite);
|
|
587
|
+
const ends = line.ends.map((end, i) => {
|
|
588
|
+
const preset = end.node ? nodes.get(end.node) : null;
|
|
589
|
+
if (end.node && preset) return { node: end.node, preset };
|
|
590
|
+
if (end.node) notes.push(`${line.id}: ${end.node} has no native preset to glue to; that end is a free point`);
|
|
591
|
+
const at = finite(end.at) ? end.at : raw[i === 0 ? 0 : raw.length - 1];
|
|
592
|
+
return finite(at) ? { point: { x: at.x, y: at.y } } : null;
|
|
593
|
+
});
|
|
594
|
+
if (ends.some((e) => !e)) return null;
|
|
595
|
+
const glued = ends.some((e) => e.node);
|
|
596
|
+
// The trace is cut where it runs inside a node's own outline — for the
|
|
597
|
+
// outlines its box describes (a band's box is its whole circle).
|
|
598
|
+
const box = (e) => (OUTLINE_BOX.has(e.preset?.prst) ? e.preset.box : null);
|
|
599
|
+
const round = (e) => e.preset?.prst === "ellipse";
|
|
600
|
+
let trace = raw.length >= 2 ? sanitizeTrace(raw, box(ends[0]), box(ends[1]), { fromRound: round(ends[0]), toRound: round(ends[1]) }) : null;
|
|
601
|
+
if (!glued && trace) {
|
|
602
|
+
// A free stroke's ends ARE the ink's ends.
|
|
603
|
+
trace = [ends[0].point, ...trace.slice(1, -1), ends[1].point];
|
|
604
|
+
}
|
|
605
|
+
if (loop && trace?.length >= 3) trace = loopTrace(trace, loop, ends, box, round);
|
|
606
|
+
const tol = trace ? toleranceFor(trace, glued) : null;
|
|
607
|
+
// The reader's own word on the path's character: a curve is never given
|
|
608
|
+
// sharp elbows, an orthogonal path never a curve.
|
|
609
|
+
const family = FAMILY_BY_KIND[line.path?.kind] ?? CONNECTOR_PRESETS;
|
|
610
|
+
opts = { ...opts, prsts: opts.prsts ?? family };
|
|
611
|
+
// A glued fit that misses the concept because a node has no connection
|
|
612
|
+
// site near where the concept attaches (a band ring's three sites): that
|
|
613
|
+
// end is freed onto the ink when that brings the line within tolerance —
|
|
614
|
+
// glue that draws a different line is worse than a free end on the right one.
|
|
615
|
+
const freedWhereNoSite = (fit) => {
|
|
616
|
+
// The farthest sites first; then any end whose site sits more than half a
|
|
617
|
+
// tolerance off the ink (its glue alone may carry the line out).
|
|
618
|
+
for (const reach of [tol, tol / 2]) {
|
|
619
|
+
const r = freeBeyond(fit, reach);
|
|
620
|
+
if (r) return r;
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
};
|
|
624
|
+
const freeBeyond = (fit, reach) => {
|
|
625
|
+
const opt = ends.map((e, i) => {
|
|
626
|
+
if (!e.node) return e;
|
|
627
|
+
const at = i === 0 ? trace[0] : trace.at(-1);
|
|
628
|
+
return Math.min(...nodeSites(e.preset).map((q) => dist(q, at))) > reach ? { point: { x: at.x, y: at.y }, freed: e.node } : e;
|
|
629
|
+
});
|
|
630
|
+
if (!opt.some((e) => e.freed)) return null;
|
|
631
|
+
const refit = fitConnector(opt, trace, { ...opts, tol });
|
|
632
|
+
if (!refit || !(refit.devPx < fit.devPx) || refit.devPx > tol) return null;
|
|
633
|
+
for (const e of opt) if (e.freed) notes.push(`[line:note] ${line.id}: ${e.freed} has no connection site near where the concept attaches — that end is free on the ink`);
|
|
634
|
+
return refit;
|
|
635
|
+
};
|
|
636
|
+
let n;
|
|
637
|
+
// A loop member is the loop's own arc: the circle is what the concept draws,
|
|
638
|
+
// and a connector glued at side sites leaves along its box's axis where the
|
|
639
|
+
// circle crosses at an angle — it can only bend back onto the circle, an
|
|
640
|
+
// S-wobble per edge (36da373d: four curvedConnector4/5 within tolerance
|
|
641
|
+
// made a round ring wavy). A glued connector draws a loop member only when
|
|
642
|
+
// no arc on the circle fits the slot.
|
|
643
|
+
if (glued && !(loop && trace && fitArc(trace, loop, opts.slot))) {
|
|
644
|
+
// A relation is ONE glued connector, never a chain.
|
|
645
|
+
n = fitConnector(ends, trace, { ...opts, tol });
|
|
646
|
+
const off = n && tol != null && n.devPx > tol;
|
|
647
|
+
// A loop member no glued connector follows is the loop's own arc: one
|
|
648
|
+
// circle for the whole loop, drawn free (its ends sit on the ink).
|
|
649
|
+
const arc = off && loop ? fitArc(trace, loop, opts.slot) : null;
|
|
650
|
+
if (arc && arc.score < n.devPx) {
|
|
651
|
+
n = { ...arc, devPx: arc.score };
|
|
652
|
+
notes.push(`[line:warn] ${line.id}: drawn as an arc on its loop's circle — no connector glued at the nodes' sites follows it within ${Math.round(tol)}px`);
|
|
653
|
+
} else if (off && (n = freedWhereNoSite(n) ?? n) && n.devPx > tol) {
|
|
654
|
+
// Glue is kept: the end sits at the node's nearest real site and the
|
|
655
|
+
// distance is named (the plan's I3), never silently re-shaped.
|
|
656
|
+
notes.push(`[line:warn] ${line.id}: the glued connector runs ${Math.round(n.devPx)}px from the concept's line (tolerance ${Math.round(tol)}px) — the closest a ${n.prst} glued at the nodes' own sites can draw it`);
|
|
657
|
+
}
|
|
658
|
+
} else if (loop && trace && fitArc(trace, loop, opts.slot)) {
|
|
659
|
+
// A free loop member is an arc on the loop's ONE circle.
|
|
660
|
+
const arc = fitArc(trace, loop, opts.slot);
|
|
661
|
+
n = { ...arc, devPx: arc.score };
|
|
662
|
+
if (arc.score > tol) notes.push(`[line:warn] ${line.id}: an arc on its loop's circle, ${Math.round(arc.score)}px from the concept's own stroke`);
|
|
663
|
+
} else if (line.role === "mark" && trace) {
|
|
664
|
+
// A glyph mark (tick, cross, chevron, plus) is its straight strokes with
|
|
665
|
+
// round caps: three vertices of a chevron always fit an arc exactly, and a
|
|
666
|
+
// bowed chevron is a malformed one.
|
|
667
|
+
const pieces = fitChain(trace, Math.max(NATIVE_TOL.markPx, NATIVE_TOL.freeFrac * traceLength(trace)), opts.slot, true);
|
|
668
|
+
n = chainOrPiece(line.id, pieces);
|
|
669
|
+
if (n?.object === "grp") n.mark = true;
|
|
670
|
+
} else if (!trace || trace.length === 2 || line.path?.kind === "straight") {
|
|
671
|
+
const [s, e] = ends.map((x) => x.point);
|
|
672
|
+
n = dist(s, e) < NATIVE_TOL.samePointPx ? null : { object: "cxn", prst: "straightConnector1", av: {}, rot: 0, from: { point: s }, to: { point: e }, start: s, end: e, devPx: trace ? pathDistance([s, e], trace) : 0 };
|
|
673
|
+
} else {
|
|
674
|
+
// Candidates compared on the smooth error (a tie on the worst point is
|
|
675
|
+
// noise); the tolerance is held on the worst point.
|
|
676
|
+
const judged = (cand, bias) => {
|
|
677
|
+
const drawn = nativePoints(cand);
|
|
678
|
+
const e = pathErrors(cand.reversed ? [...drawn].reverse() : drawn, trace);
|
|
679
|
+
return { n: cand, dev: e.max, score: e.rms + 0.25 * e.max + bias };
|
|
680
|
+
};
|
|
681
|
+
const cands = [];
|
|
682
|
+
const cxn = fitConnector(ends, trace, { ...opts, tol });
|
|
683
|
+
if (cxn) cands.push(judged(cxn, NATIVE_TOL.adjustBiasPx * adjustNames(cxn.prst).length));
|
|
684
|
+
// An arc's angles are closed form, not searched: no adjust bias.
|
|
685
|
+
const arc = fitArc(trace, loop, opts.slot);
|
|
686
|
+
if (arc) cands.push(judged(arc, 0));
|
|
687
|
+
// A connector already on the ink leaves nothing for a bracket to win.
|
|
688
|
+
if (!(cxn?.devPx <= NATIVE_TOL.freePx / 2)) {
|
|
689
|
+
const shape = fitOpenShape(trace);
|
|
690
|
+
if (shape) cands.push(judged(shape, NATIVE_TOL.adjustBiasPx * adjustNames(shape.prst).length));
|
|
691
|
+
}
|
|
692
|
+
const best = cands.sort((x, y) => x.score - y.score)[0];
|
|
693
|
+
if (best && best.dev <= tol) n = { ...best.n, devPx: best.dev };
|
|
694
|
+
else n = chainOrPiece(line.id, fitChain(trace, tol, opts.slot));
|
|
695
|
+
}
|
|
696
|
+
if (!n) return null;
|
|
697
|
+
return { ...n, id: line.id, tolPx: tol, notes };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* A bus: elbows leaving (or entering) one site of one node on the same turn
|
|
702
|
+
* whose knees nearly coincide share ONE rail — each member's adjust is set so
|
|
703
|
+
* its knee sits on the members' common coordinate. Mutates `natives`
|
|
704
|
+
* (NativeLines); returns the rails it aligned, as notes.
|
|
705
|
+
*/
|
|
706
|
+
export function alignRails(natives) {
|
|
707
|
+
const notes = [];
|
|
708
|
+
const groups = new Map();
|
|
709
|
+
for (const n of natives) {
|
|
710
|
+
if (n?.object !== "cxn" || n.prst !== "bentConnector3") continue;
|
|
711
|
+
for (const [end, key] of [[n.from, "from"], [n.to, "to"]]) {
|
|
712
|
+
if (!end?.node) continue;
|
|
713
|
+
const k = `${key}|${end.node}|${end.site}|${n.rot}`;
|
|
714
|
+
groups.set(k, [...(groups.get(k) ?? []), n]);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const axis = (n) => (n.rot === 90 ? "y" : "x");
|
|
718
|
+
const knee = (n) => n.start[axis(n)] + (n.end[axis(n)] - n.start[axis(n)]) * (n.av.adj1 ?? 50000) / 100000;
|
|
719
|
+
for (const [k, members] of groups) {
|
|
720
|
+
const usable = members.filter((n) => Math.abs(n.end[axis(n)] - n.start[axis(n)]) > 1);
|
|
721
|
+
if (usable.length < 2) continue;
|
|
722
|
+
const knees = usable.map(knee);
|
|
723
|
+
if (Math.max(...knees) - Math.min(...knees) > NATIVE_TOL.railPx) continue;
|
|
724
|
+
const rail = knees.reduce((s, v) => s + v, 0) / knees.length;
|
|
725
|
+
for (const n of usable) n.av = { ...n.av, adj1: Math.round(((rail - n.start[axis(n)]) / (n.end[axis(n)] - n.start[axis(n)])) * 100000) };
|
|
726
|
+
notes.push(`${usable.map((n) => n.id).join(", ")}: one rail at ${Math.round(rail)}px (${k.split("|")[1]})`);
|
|
727
|
+
}
|
|
728
|
+
return notes;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Loop members share ONE circle (a reader's per-arc circles scatter; the loop
|
|
733
|
+
* it saw is one). Curved Lines the reader gave one `loop` id are members
|
|
734
|
+
* however far one was drawn off the ring — the id is the reader's word on the
|
|
735
|
+
* structure, the drawing is a generated image's. Lines without an id join a
|
|
736
|
+
* group when the circle fitted over all their points holds every member within
|
|
737
|
+
* `loopFrac` of its radius. A round node the circle encloses, its centre
|
|
738
|
+
* within `loopFrac` of the circle's, is the loop's hub: the circle is centred
|
|
739
|
+
* on it. `nodes`: Map id → `nodePreset` result. Returns Map lineId → circle.
|
|
740
|
+
*/
|
|
741
|
+
export function loopCircles(lines, nodes = new Map()) {
|
|
742
|
+
const cand = [];
|
|
743
|
+
for (const l of lines) {
|
|
744
|
+
if (!["arc", "curve"].includes(l.path?.kind)) continue;
|
|
745
|
+
const pts = (l.path.points ?? []).filter(finite);
|
|
746
|
+
if (pts.length >= 3 && circleFit(pts)) cand.push({ l, pts });
|
|
747
|
+
}
|
|
748
|
+
const fits = (members) => {
|
|
749
|
+
const c = circleFit(members.flatMap((x) => x.pts));
|
|
750
|
+
if (!c) return null;
|
|
751
|
+
const worst = Math.max(...members.flatMap((x) => x.pts.map((p) => Math.abs(Math.hypot(p.x - c.cx, p.y - c.cy) - c.rx))));
|
|
752
|
+
return worst <= NATIVE_TOL.loopFrac * c.rx ? c : null;
|
|
753
|
+
};
|
|
754
|
+
const declared = new Map();
|
|
755
|
+
const groups = [];
|
|
756
|
+
for (const x of cand) {
|
|
757
|
+
const id = loopId(x.l.loop);
|
|
758
|
+
if (id) {
|
|
759
|
+
if (!declared.has(id)) declared.set(id, []);
|
|
760
|
+
declared.get(id).push(x);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const g = groups.find((gr) => fits([...gr, x]));
|
|
764
|
+
if (g) g.push(x);
|
|
765
|
+
else groups.push([x]);
|
|
766
|
+
}
|
|
767
|
+
const out = new Map();
|
|
768
|
+
const assign = (g, c) => {
|
|
769
|
+
if (!c) return;
|
|
770
|
+
const hubbed = onHub(c, g.flatMap((x) => x.pts), nodes);
|
|
771
|
+
for (const x of g) out.set(x.l.id, hubbed);
|
|
772
|
+
};
|
|
773
|
+
for (const g of declared.values()) if (g.length >= 2) assign(g, circleFit(g.flatMap((x) => x.pts)));
|
|
774
|
+
for (const g of groups) if (g.length >= 2) assign(g, fits(g));
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// The reader numbers a loop's strokes (1, 2, …); anything else is no loop.
|
|
779
|
+
const loopId = (v) => (Number.isInteger(v) ? String(v) : null);
|
|
780
|
+
|
|
781
|
+
/** `c` centred on the round node it encloses near its centre (radius refitted), else `c`. */
|
|
782
|
+
function onHub(c, pts, nodes) {
|
|
783
|
+
let hub = null;
|
|
784
|
+
for (const n of nodes.values()) {
|
|
785
|
+
if (n?.prst !== "ellipse") continue;
|
|
786
|
+
const h = { x: n.box.x + n.box.w / 2, y: n.box.y + n.box.h / 2 };
|
|
787
|
+
const off = Math.hypot(h.x - c.cx, h.y - c.cy);
|
|
788
|
+
if (Math.max(n.box.w, n.box.h) / 2 >= c.rx || off > NATIVE_TOL.loopFrac * c.rx) continue;
|
|
789
|
+
if (!hub || off < hub.off) hub = { ...h, off };
|
|
790
|
+
}
|
|
791
|
+
if (!hub) return c;
|
|
792
|
+
const r = pts.reduce((s, p) => s + Math.hypot(p.x - hub.x, p.y - hub.y), 0) / pts.length;
|
|
793
|
+
return { cx: hub.x, cy: hub.y, rx: r, ry: r };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* A filled band read as an arc with a hole: `donut` for a full ring,
|
|
798
|
+
* `blockArc` for a segment (clockwise from adj1 to adj2, thickness adj3).
|
|
799
|
+
* `g`: `{ cx, cy, rx, ry, start, sweep, innerRatio }` (parametric degrees).
|
|
800
|
+
*/
|
|
801
|
+
export function bandNative(id, g) {
|
|
802
|
+
if (!(g?.rx > 0 && g?.ry > 0 && g.innerRatio > 0 && g.innerRatio < 1)) return null;
|
|
803
|
+
const box = { x: g.cx - g.rx, y: g.cy - g.ry, w: 2 * g.rx, h: 2 * g.ry };
|
|
804
|
+
const thick = Math.min(50000, Math.round(((1 - g.innerRatio) / 2) * 100000));
|
|
805
|
+
if (Math.abs(g.sweep) >= 360) return { id, object: "sp", prst: "donut", av: { adj: thick }, rot: 0, flipH: false, flipV: false, box };
|
|
806
|
+
const visual = (t) => norm360(Math.atan2(g.ry * Math.sin(t * Math.PI / 180), g.rx * Math.cos(t * Math.PI / 180)) * 180 / Math.PI);
|
|
807
|
+
const [a, b] = g.sweep >= 0 ? [g.start, g.start + g.sweep] : [g.start + g.sweep, g.start];
|
|
808
|
+
return { id, object: "sp", prst: "blockArc", av: { adj1: Math.round(visual(a) * DEG), adj2: Math.round(visual(b) * DEG), adj3: thick }, rot: 0, flipH: false, flipV: false, box };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** A stroked full ring nothing glues to: an unfilled `ellipse` shape. */
|
|
812
|
+
export function ringNative(id, g) {
|
|
813
|
+
if (!(g?.rx > 0 && g?.ry > 0)) return null;
|
|
814
|
+
return { id, object: "sp", prst: "ellipse", av: {}, rot: 0, flipH: false, flipV: false, box: { x: g.cx - g.rx, y: g.cy - g.ry, w: 2 * g.rx, h: 2 * g.ry } };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Re-solve a glued connector after its nodes moved (settle): the preset and
|
|
819
|
+
* the glue stay; rot, adjusts and — only where it pays for itself — a site are
|
|
820
|
+
* searched again. `n`: the directive as parsed (`{ prst, from, to }`, a glued
|
|
821
|
+
* end `{ node, site }`, a free one `{ point }`); `nodes`: Map id → nodePreset
|
|
822
|
+
* over the measured rects; `trace`: the concept's path, or null.
|
|
823
|
+
*/
|
|
824
|
+
export function resolveGlued({ prst, from, to }, nodes, { trace = null, obstacles = [], crossPenalty } = {}) {
|
|
825
|
+
const end = (ref) => (ref.node ? (nodes.get(ref.node) ? { node: ref.node, preset: nodes.get(ref.node) } : null) : { point: ref.point });
|
|
826
|
+
const ends = [end(from), end(to)];
|
|
827
|
+
if (ends.some((e) => !e)) return null;
|
|
828
|
+
const n = fitConnector(ends, trace, { prsts: [prst], obstacles, keep: [from.site ?? null, to.site ?? null], ...(crossPenalty != null ? { crossPenalty } : {}) });
|
|
829
|
+
if (n && trace?.length >= 2) n.devPx = pathDistance(nativePoints(n), trace);
|
|
830
|
+
return n;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ---- The directive the exporter reads (contract §§1–3).
|
|
834
|
+
|
|
835
|
+
const adjAttr = (av) => {
|
|
836
|
+
const entries = Object.entries(av ?? {});
|
|
837
|
+
return entries.length ? ` data-adj="${entries.map(([k, v]) => `${k}:${Math.round(v)}`).join(",")}"` : "";
|
|
838
|
+
};
|
|
839
|
+
const CAP_TYPES = new Set(["triangle", "stealth", "arrow", "oval", "diamond"]);
|
|
840
|
+
const CAP_SIZE = { sm: "sm", md: "med", med: "med", lg: "lg" };
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* A Line end's cap as `type:w:len`, or null: the concept's size, or the
|
|
844
|
+
* largest smaller one its route of `routePx` carries — sm when none does (the
|
|
845
|
+
* render gate then names the connector).
|
|
846
|
+
*/
|
|
847
|
+
export function capAttr(cap, routePx = Infinity, strokePx = 0) {
|
|
848
|
+
if (!cap || !CAP_TYPES.has(cap.type)) return null;
|
|
849
|
+
const sizes = ["lg", "med", "sm"];
|
|
850
|
+
const fits = sizes.slice(sizes.indexOf(CAP_SIZE[cap.size] ?? "med")).find((k) => headRoutePx(k, strokePx) <= routePx - 0.5);
|
|
851
|
+
const s = fits ?? "sm";
|
|
852
|
+
return `${cap.type}:${s}:${s}`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const q = (s) => JSON.stringify(String(s));
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* One NativeLine as JSX. `paint`: `{ className, style: ["borderWidth: …", …],
|
|
859
|
+
* caps: [start, end] (`type:w:len` or null), dash, linecap, plotOwner }`. A
|
|
860
|
+
* closed preset's fill is a `fill: …` style entry (CSS `fill`, contract §2).
|
|
861
|
+
* Coordinates are slot px; `origin` shifts them into a group's frame.
|
|
862
|
+
*/
|
|
863
|
+
export function lineDirective(n, paint = {}, origin = { x: 0, y: 0 }) {
|
|
864
|
+
const cls = paint.className ?? "connector-muted";
|
|
865
|
+
const owner = `${paint.plotOwner ? ` data-sm-plot-owner=${q(paint.plotOwner)}` : ""}${paint.stack ? ` data-sm-stack=${q(paint.stack)}` : ""}${paint.divides ? ` data-sm-divides=${q(paint.divides)}` : ""}`;
|
|
866
|
+
// A mirrored bracket or brace is its own preset — and runs the other way
|
|
867
|
+
// (a flipped leftBracket goes bottom→top, a rightBracket top→bottom).
|
|
868
|
+
const renamed = n.object === "sp" && MIRRORED[n.prst] && n.flipH;
|
|
869
|
+
const [capS, capE] = Boolean(n.reversed) !== Boolean(renamed) ? [paint.caps?.[1], paint.caps?.[0]] : [paint.caps?.[0], paint.caps?.[1]];
|
|
870
|
+
const caps = `${capS ? ` data-cap-start="${capS}"` : ""}${capE ? ` data-cap-end="${capE}"` : ""}`;
|
|
871
|
+
const dash = paint.dash && paint.dash !== "solid" ? ` data-dash="${paint.dash}"` : "";
|
|
872
|
+
const linecap = paint.linecap && paint.linecap !== "flat" ? ` data-linecap="${paint.linecap}"` : "";
|
|
873
|
+
const style = [...(paint.style ?? [])];
|
|
874
|
+
if (n.object === "grp") {
|
|
875
|
+
const b = n.box;
|
|
876
|
+
const kids = n.children.map((c, i) => lineDirective(c, {
|
|
877
|
+
...paint, plotOwner: null,
|
|
878
|
+
caps: [i === 0 ? paint.caps?.[0] : null, i === n.children.length - 1 ? paint.caps?.[1] : null],
|
|
879
|
+
// Round caps join a glyph's strokes; a plain chain keeps its own.
|
|
880
|
+
linecap: n.mark ? "rnd" : paint.linecap,
|
|
881
|
+
}, { x: b.x, y: b.y }));
|
|
882
|
+
return `<div data-sm-line-group id=${q(n.id)}${owner} style={{ position: "absolute", left: ${r1(b.x - origin.x)}, top: ${r1(b.y - origin.y)}, width: ${r1(b.w)}, height: ${r1(b.h)} }}>${kids.join("")}</div>`;
|
|
883
|
+
}
|
|
884
|
+
if (n.object === "cxn") {
|
|
885
|
+
const ref = (e, p) => (e.node ? `#${e.node}` : pointRef({ x: p.x - origin.x, y: p.y - origin.y }));
|
|
886
|
+
const site = (e, k) => (e.node ? ` data-${k}-site="${e.site}"` : "");
|
|
887
|
+
const rot = n.rot ? ` data-rot="${n.rot}"` : "";
|
|
888
|
+
return `<div data-connector id=${q(n.id)}${owner} data-from="${ref(n.from, n.start)}"${site(n.from, "from")} data-to="${ref(n.to, n.end)}"${site(n.to, "to")} data-prst="${n.prst}"${adjAttr(n.av)}${rot}${caps}${dash}${linecap} className="${cls}"${style.length ? ` style={{ ${style.join(", ")} }}` : ""} />`;
|
|
889
|
+
}
|
|
890
|
+
let prst = n.prst, flipH = n.flipH;
|
|
891
|
+
if (renamed) { prst = MIRRORED[prst]; flipH = false; }
|
|
892
|
+
const flip = flipH && n.flipV ? "hv" : flipH ? "h" : n.flipV ? "v" : "";
|
|
893
|
+
const b = n.box;
|
|
894
|
+
return `<div data-sm-line id=${q(n.id)}${owner} data-prst="${prst}"${adjAttr(n.av)}${n.rot ? ` data-rot="${n.rot}"` : ""}${flip ? ` data-flip="${flip}"` : ""}${caps}${dash}${linecap} className="${cls}" style={{ position: "absolute", left: ${r1(b.x - origin.x)}, top: ${r1(b.y - origin.y)}, width: ${r1(b.w)}, height: ${r1(b.h)}${style.map((s) => `, ${s}`).join("")} }} />`;
|
|
895
|
+
}
|