@retikz/core 0.1.0-alpha.2 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/es/compile/compile.js +3 -3
  2. package/dist/es/compile/path.d.ts +3 -2
  3. package/dist/es/compile/path.d.ts.map +1 -1
  4. package/dist/es/compile/path.js +333 -14
  5. package/dist/es/geometry/arc.d.ts +34 -0
  6. package/dist/es/geometry/arc.d.ts.map +1 -0
  7. package/dist/es/geometry/arc.js +53 -0
  8. package/dist/es/geometry/bend.d.ts +18 -0
  9. package/dist/es/geometry/bend.d.ts.map +1 -0
  10. package/dist/es/geometry/bend.js +29 -0
  11. package/dist/es/geometry/index.d.ts +3 -0
  12. package/dist/es/geometry/index.d.ts.map +1 -1
  13. package/dist/es/geometry/segment.d.ts +38 -0
  14. package/dist/es/geometry/segment.d.ts.map +1 -0
  15. package/dist/es/geometry/segment.js +82 -0
  16. package/dist/es/index.d.ts +4 -4
  17. package/dist/es/index.d.ts.map +1 -1
  18. package/dist/es/index.js +4 -3
  19. package/dist/es/ir/node.d.ts +12 -12
  20. package/dist/es/ir/path/path.d.ts +625 -15
  21. package/dist/es/ir/path/path.d.ts.map +1 -1
  22. package/dist/es/ir/path/path.js +22 -0
  23. package/dist/es/ir/path/step.d.ts +872 -20
  24. package/dist/es/ir/path/step.d.ts.map +1 -1
  25. package/dist/es/ir/path/step.js +86 -4
  26. package/dist/es/ir/path/target.d.ts +32 -2
  27. package/dist/es/ir/path/target.d.ts.map +1 -1
  28. package/dist/es/ir/path/target.js +7 -3
  29. package/dist/es/ir/scene.d.ts +1496 -72
  30. package/dist/es/ir/scene.d.ts.map +1 -1
  31. package/dist/es/parsers/index.d.ts +1 -0
  32. package/dist/es/parsers/index.d.ts.map +1 -1
  33. package/dist/es/parsers/parseTargetSugar.d.ts +3 -0
  34. package/dist/es/parsers/parseTargetSugar.d.ts.map +1 -0
  35. package/dist/es/parsers/parseTargetSugar.js +31 -0
  36. package/dist/es/parsers/parseWay.d.ts +131 -22
  37. package/dist/es/parsers/parseWay.d.ts.map +1 -1
  38. package/dist/es/parsers/parseWay.js +149 -17
  39. package/dist/lib/compile/compile.cjs +3 -3
  40. package/dist/lib/compile/path.cjs +333 -14
  41. package/dist/lib/compile/path.d.ts +3 -2
  42. package/dist/lib/compile/path.d.ts.map +1 -1
  43. package/dist/lib/geometry/arc.cjs +55 -0
  44. package/dist/lib/geometry/arc.d.ts +34 -0
  45. package/dist/lib/geometry/arc.d.ts.map +1 -0
  46. package/dist/lib/geometry/bend.cjs +29 -0
  47. package/dist/lib/geometry/bend.d.ts +18 -0
  48. package/dist/lib/geometry/bend.d.ts.map +1 -0
  49. package/dist/lib/geometry/index.d.ts +3 -0
  50. package/dist/lib/geometry/index.d.ts.map +1 -1
  51. package/dist/lib/geometry/segment.cjs +88 -0
  52. package/dist/lib/geometry/segment.d.ts +38 -0
  53. package/dist/lib/geometry/segment.d.ts.map +1 -0
  54. package/dist/lib/index.cjs +12 -0
  55. package/dist/lib/index.d.ts +4 -4
  56. package/dist/lib/index.d.ts.map +1 -1
  57. package/dist/lib/ir/node.d.ts +12 -12
  58. package/dist/lib/ir/path/path.cjs +22 -0
  59. package/dist/lib/ir/path/path.d.ts +625 -15
  60. package/dist/lib/ir/path/path.d.ts.map +1 -1
  61. package/dist/lib/ir/path/step.cjs +93 -3
  62. package/dist/lib/ir/path/step.d.ts +872 -20
  63. package/dist/lib/ir/path/step.d.ts.map +1 -1
  64. package/dist/lib/ir/path/target.cjs +8 -2
  65. package/dist/lib/ir/path/target.d.ts +32 -2
  66. package/dist/lib/ir/path/target.d.ts.map +1 -1
  67. package/dist/lib/ir/scene.d.ts +1496 -72
  68. package/dist/lib/ir/scene.d.ts.map +1 -1
  69. package/dist/lib/parsers/index.d.ts +1 -0
  70. package/dist/lib/parsers/index.d.ts.map +1 -1
  71. package/dist/lib/parsers/parseTargetSugar.cjs +31 -0
  72. package/dist/lib/parsers/parseTargetSugar.d.ts +3 -0
  73. package/dist/lib/parsers/parseTargetSugar.d.ts.map +1 -0
  74. package/dist/lib/parsers/parseWay.cjs +149 -17
  75. package/dist/lib/parsers/parseWay.d.ts +131 -22
  76. package/dist/lib/parsers/parseWay.d.ts.map +1 -1
  77. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  import { rect } from "../geometry/rect.js";
2
2
  import { emitNodePrimitives, layoutNode } from "./node.js";
3
+ import { fallbackMeasurer } from "./text-metrics.js";
3
4
  import { emitPathPrimitive } from "./path.js";
4
5
  import { makeRound } from "./precision.js";
5
- import { fallbackMeasurer } from "./text-metrics.js";
6
6
  import { computeViewBox } from "./view-box.js";
7
7
  //#region src/compile/compile.ts
8
8
  /**
@@ -28,9 +28,9 @@ var compileToScene = (ir, options = {}) => {
28
28
  allPoints.push(rect.anchor(layout.rect, "north-west"), rect.anchor(layout.rect, "north-east"), rect.anchor(layout.rect, "south-west"), rect.anchor(layout.rect, "south-east"));
29
29
  }
30
30
  for (const child of ir.children) if (child.type === "path") {
31
- const result = emitPathPrimitive(child, nodeIndex, round);
31
+ const result = emitPathPrimitive(child, nodeIndex, round, measureText);
32
32
  if (result) {
33
- primitives.push(result.primitive);
33
+ for (const prim of result.primitives) primitives.push(prim);
34
34
  for (const p of result.points) allPoints.push(p);
35
35
  }
36
36
  }
@@ -1,6 +1,7 @@
1
1
  import { IRPath, IRPosition } from '../ir';
2
2
  import { ScenePrimitive } from '../primitive';
3
3
  import { NodeLayout } from './node';
4
+ import { TextMeasurer } from './text-metrics';
4
5
  /**
5
6
  * 把 IR Path 翻译为单个 PathPrim。
6
7
  *
@@ -22,8 +23,8 @@ import { NodeLayout } from './node';
22
23
  *
23
24
  * 引用未定义节点 / 解析失败时返回 null(path 整体跳过)。
24
25
  */
25
- export declare const emitPathPrimitive: (path: IRPath, nodeIndex: Map<string, NodeLayout>, round: (n: number) => number) => {
26
- primitive: ScenePrimitive;
26
+ export declare const emitPathPrimitive: (path: IRPath, nodeIndex: Map<string, NodeLayout>, round: (n: number) => number, measureText?: TextMeasurer) => {
27
+ primitives: Array<ScenePrimitive>;
27
28
  points: Array<IRPosition>;
28
29
  } | null;
29
30
  //# sourceMappingURL=path.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../../../src/compile/path.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,MAAM,EAAE,UAAU,EAAoB,MAAM,OAAO,CAAC;AAC9E,OAAO,KAAK,EAAY,cAAc,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,KAAK,UAAU,EAA8C,MAAM,QAAQ,CAAC;AAuIrF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iBAAiB,GAC5B,MAAM,MAAM,EACZ,WAAW,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,EAClC,OAAO,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,KAC3B;IAAE,SAAS,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAA;CAAE,GAAG,IAsN7D,CAAC"}
1
+ {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../../../src/compile/path.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAEV,MAAM,EACN,UAAU,EAIX,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAY,cAAc,EAAY,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,KAAK,UAAU,EAA8C,MAAM,QAAQ,CAAC;AAGrF,OAAO,EAAE,KAAK,YAAY,EAAoB,MAAM,gBAAgB,CAAC;AAoWrE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iBAAiB,GAC5B,MAAM,MAAM,EACZ,WAAW,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,EAClC,OAAO,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,EAC5B,cAAa,YAA+B,KAC3C;IAAE,UAAU,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAA;CAAE,GAAG,IA8arE,CAAC"}
@@ -1,6 +1,10 @@
1
1
  import { resolvePosition } from "./position.js";
2
2
  import { anchorOf, angleBoundaryOf, boundaryPointOf } from "./node.js";
3
+ import { arcBoundingPoints, arcEndPoint, arcSvgFlags } from "../geometry/arc.js";
4
+ import { bendControlPoints } from "../geometry/bend.js";
5
+ import { arcSegmentSample, circleSegmentSample, cubicSegmentSample, ellipseSegmentSample, foldSegmentSample, lineSegmentSample, quadSegmentSample } from "../geometry/segment.js";
3
6
  import { parseNodeRef } from "./parseTarget.js";
7
+ import { fallbackMeasurer } from "./text-metrics.js";
4
8
  //#region src/compile/path.ts
5
9
  /**
6
10
  * IR 的 path-level `arrow` + `arrowShape` 映射到 PathPrim 的
@@ -72,6 +76,7 @@ var refPointOfTarget = (target, nodeIndex) => {
72
76
  case "angle": return angleBoundaryOf(node, ref.angle);
73
77
  }
74
78
  }
79
+ if (typeof target === "object" && !Array.isArray(target) && ("rel" in target || "relAccumulate" in target)) return null;
75
80
  return resolvePosition(target, nodeIndex);
76
81
  };
77
82
  /**
@@ -99,11 +104,179 @@ var clipForTarget = (target, toward, nodeIndex) => {
99
104
  case "angle": return angleBoundaryOf(node, ref.angle);
100
105
  }
101
106
  }
107
+ if (typeof target === "object" && !Array.isArray(target) && ("rel" in target || "relAccumulate" in target)) return null;
102
108
  return resolvePosition(target, nodeIndex);
103
109
  };
104
110
  /** 浅相等:两个 IRPosition 的两个分量都精确相等(未 round) */
105
111
  var samePoint = (a, b) => !!a && !!b && a[0] === b[0] && a[1] === b[1];
106
112
  /**
113
+ * 语义 stroke 档位 → 数值映射(user units)。
114
+ *
115
+ * 对齐 TikZ 比例(thin = 默认 0.4pt,retikz 默认 strokeWidth = 1,所以 thin → 1):
116
+ * ultra thin 0.1pt → 0.25
117
+ * very thin 0.2pt → 0.5
118
+ * thin 0.4pt → 1 (= 默认 strokeWidth)
119
+ * semithick 0.6pt → 1.5
120
+ * thick 0.8pt → 2
121
+ * very thick 1.2pt → 3
122
+ * ultra thick 1.6pt → 4
123
+ *
124
+ * 显式 `strokeWidth` 始终覆盖 `thickness`——thickness 仅在 strokeWidth 缺省时生效。
125
+ */
126
+ var THICKNESS_TO_WIDTH = {
127
+ ultraThin: .25,
128
+ veryThin: .5,
129
+ thin: 1,
130
+ semithick: 1.5,
131
+ thick: 2,
132
+ veryThick: 3,
133
+ ultraThick: 4
134
+ };
135
+ /** ADR-0004:边标注的默认字号 / 偏移量(user units) */
136
+ var LABEL_FONT_SIZE = 14;
137
+ var LABEL_LINE_HEIGHT_FACTOR = 1.2;
138
+ var LABEL_SIDE_OFFSET = 4;
139
+ var RAD_TO_DEG = 180 / Math.PI;
140
+ /** label.position → 段参数 t */
141
+ var tForLabelPosition = (pos) => pos === "near-start" ? .25 : pos === "near-end" ? .75 : .5;
142
+ /**
143
+ * 把 step.label + 段采样结果翻成 TextPrim(sloped 时再裹一层 group 旋转)。
144
+ *
145
+ * 几何(默认 side='above',position='midway'):
146
+ * - 'above': 锚点 (x, y - LABEL_SIDE_OFFSET),align=middle, baseline=bottom
147
+ * - 'below': 锚点 (x, y + LABEL_SIDE_OFFSET),align=middle, baseline=top
148
+ * - 'left' : 锚点 (x - LABEL_SIDE_OFFSET, y),align=end, baseline=middle
149
+ * - 'right': 锚点 (x + LABEL_SIDE_OFFSET, y),align=start, baseline=middle
150
+ * - 'sloped': 锚点 (x, y) 不偏移,align=middle baseline=bottom,外裹 group
151
+ * `transform="rotate(angle x y)"`,angle 由切线 atan2 算出(SVG y-down,CW 正向)
152
+ *
153
+ * 文本宽高交给 measureText;fallbackMeasurer 时只是估算,但不影响渲染坐标。
154
+ * 返回 primitive + 用于 viewBox 的若干外接点(sloped 时按"近似最大半径"四角扩张)。
155
+ */
156
+ var emitLabelPrimitive = (label, sample, measureText, round) => {
157
+ const fontSize = LABEL_FONT_SIZE;
158
+ const lineHeight = fontSize * LABEL_LINE_HEIGHT_FACTOR;
159
+ const m = measureText(label.text, { size: fontSize });
160
+ const measuredWidth = m.width;
161
+ const measuredHeight = m.height || lineHeight;
162
+ const side = label.side ?? "above";
163
+ let x = sample.point[0];
164
+ let y = sample.point[1];
165
+ let align = "middle";
166
+ let baseline = "middle";
167
+ if (side === "above") {
168
+ y -= LABEL_SIDE_OFFSET;
169
+ baseline = "bottom";
170
+ } else if (side === "below") {
171
+ y += LABEL_SIDE_OFFSET;
172
+ baseline = "top";
173
+ } else if (side === "left") {
174
+ x -= LABEL_SIDE_OFFSET;
175
+ align = "end";
176
+ } else if (side === "right") {
177
+ x += LABEL_SIDE_OFFSET;
178
+ align = "start";
179
+ } else baseline = "bottom";
180
+ const text = {
181
+ type: "text",
182
+ x: round(x),
183
+ y: round(y),
184
+ lines: [{ text: label.text }],
185
+ fontSize,
186
+ align,
187
+ baseline,
188
+ lineHeight: round(lineHeight),
189
+ measuredWidth: round(measuredWidth),
190
+ measuredHeight: round(measuredHeight),
191
+ fill: "currentColor"
192
+ };
193
+ if (side === "sloped") {
194
+ const groupPrim = {
195
+ type: "group",
196
+ transform: `rotate(${round(Math.atan2(sample.tangent[1], sample.tangent[0]) * RAD_TO_DEG)} ${round(x)} ${round(y)})`,
197
+ children: [text]
198
+ };
199
+ const r = Math.max(measuredWidth / 2, measuredHeight / 2);
200
+ return {
201
+ primitive: groupPrim,
202
+ points: [
203
+ [x - r, y - r],
204
+ [x + r, y - r],
205
+ [x - r, y + r],
206
+ [x + r, y + r]
207
+ ]
208
+ };
209
+ }
210
+ const halfW = measuredWidth / 2;
211
+ const halfH = measuredHeight / 2;
212
+ return {
213
+ primitive: text,
214
+ points: [
215
+ [x - halfW, y - halfH],
216
+ [x + halfW, y - halfH],
217
+ [x - halfW, y + halfH],
218
+ [x + halfW, y + halfH]
219
+ ]
220
+ };
221
+ };
222
+ /**
223
+ * 把 rel / relAccumulate 目标解析为绝对 Position,产出 step kind 不变但
224
+ * `to` 字段全为绝对坐标的步序列。
225
+ *
226
+ * 决策(ADR-0003 §影响 与 §背景 文本有矛盾):
227
+ * 两者都相对 prevEnd 解析,区别仅在是否更新 prevEnd——
228
+ * rel 不更新(保持 TikZ `+` 语义),relAccumulate 更新(TikZ `++` 累积)。
229
+ * 选这个语义因为:(1) 与 TikZ `+`/`++` 一致;(2) 与字段名"Accumulate"
230
+ * 语义匹配;(3) 与 ADR 背景段一致;§影响 段写"pathStart + offset"是 typo。
231
+ *
232
+ * 跨 step kind 的 prevEnd 推进:
233
+ * - 有 to 的 kind(move/line/step/curve/cubic/bend):prevEnd = refPointOfTarget(to)
234
+ * - arc:prevEnd = arcEndPoint(prevEnd, radius, endAngle)
235
+ * - circlePath / ellipsePath:prevEnd 不变(画完留圆心,即 prevEnd 本身)
236
+ * - cycle:prevEnd 不变(不重置到 pathStart,保持简单;后续如有需要再扩)
237
+ *
238
+ * prevEnd 为 null(首步是 rel)时回退到 [0, 0] 当锚点;解析失败保持原 step。
239
+ */
240
+ var normalizeRelativeTargets = (steps, nodeIndex) => {
241
+ let prevEnd = null;
242
+ const out = [];
243
+ for (const step of steps) {
244
+ if (step.kind === "cycle") {
245
+ out.push(step);
246
+ continue;
247
+ }
248
+ if (step.kind === "circlePath" || step.kind === "ellipsePath") {
249
+ out.push(step);
250
+ continue;
251
+ }
252
+ if (step.kind === "arc") {
253
+ out.push(step);
254
+ if (prevEnd) prevEnd = arcEndPoint(prevEnd, step.radius, step.endAngle);
255
+ continue;
256
+ }
257
+ const original = step.to;
258
+ let resolvedTo = original;
259
+ let updatePrevEnd = true;
260
+ if (typeof original === "object" && !Array.isArray(original) && "rel" in original) {
261
+ const ref = prevEnd ?? [0, 0];
262
+ resolvedTo = [ref[0] + original.rel[0], ref[1] + original.rel[1]];
263
+ updatePrevEnd = false;
264
+ } else if (typeof original === "object" && !Array.isArray(original) && "relAccumulate" in original) {
265
+ const ref = prevEnd ?? [0, 0];
266
+ resolvedTo = [ref[0] + original.relAccumulate[0], ref[1] + original.relAccumulate[1]];
267
+ }
268
+ out.push({
269
+ ...step,
270
+ to: resolvedTo
271
+ });
272
+ if (updatePrevEnd) {
273
+ const pos = refPointOfTarget(resolvedTo, nodeIndex);
274
+ if (pos) prevEnd = pos;
275
+ }
276
+ }
277
+ return out;
278
+ };
279
+ /**
107
280
  * 把 IR Path 翻译为单个 PathPrim。
108
281
  *
109
282
  * 关键算法(v0.1.0-alpha.1):每个绘制段(line / fold)**独立**地用节点中心
@@ -124,15 +297,27 @@ var samePoint = (a, b) => !!a && !!b && a[0] === b[0] && a[1] === b[1];
124
297
  *
125
298
  * 引用未定义节点 / 解析失败时返回 null(path 整体跳过)。
126
299
  */
127
- var emitPathPrimitive = (path, nodeIndex, round) => {
128
- const steps = path.children;
300
+ var emitPathPrimitive = (path, nodeIndex, round, measureText = fallbackMeasurer) => {
301
+ const steps = normalizeRelativeTargets(path.children, nodeIndex);
129
302
  if (steps.length < 2) return null;
130
- const anchors = steps.map((s) => s.kind === "cycle" ? null : refPointOfTarget(s.to, nodeIndex));
131
- /** i 之前最近的"有 to 字段的 step"(跳过 cycle) + 它的 anchor */
303
+ /** ADR-0004:每段 step.label 翻译出的 TextPrim(或裹 sloped 旋转的 group),
304
+ * 与 path 主体 primitive 同级返回;调用方 push scene.primitives */
305
+ const labelPrims = [];
306
+ /** 为当前 step 收 label:算 sample 后调用 emitLabelPrimitive,结果累积到 labelPrims / points */
307
+ const collectLabel = (step, sampleAt) => {
308
+ if (step.kind === "move" || step.kind === "cycle" || !("label" in step) || !step.label) return;
309
+ const sample = sampleAt(tForLabelPosition(step.label.position));
310
+ const r = emitLabelPrimitive(step.label, sample, measureText, round);
311
+ labelPrims.push(r.primitive);
312
+ for (const p of r.points) points.push(p);
313
+ };
314
+ const hasTo = (s) => s.kind !== "cycle" && s.kind !== "arc" && s.kind !== "circlePath" && s.kind !== "ellipsePath";
315
+ const anchors = steps.map((s) => hasTo(s) ? refPointOfTarget(s.to, nodeIndex) : null);
316
+ /** 找 i 之前最近的"有 to 字段的 step" + 它的 anchor */
132
317
  const findPrev = (i) => {
133
318
  for (let j = i - 1; j >= 0; j--) {
134
319
  const s = steps[j];
135
- if (s.kind === "cycle") continue;
320
+ if (!hasTo(s)) continue;
136
321
  const a = anchors[j];
137
322
  if (!a) return null;
138
323
  return {
@@ -144,13 +329,27 @@ var emitPathPrimitive = (path, nodeIndex, round) => {
144
329
  };
145
330
  /** 找 i 之前最近的 move 的 to——cycle 闭合的目标 */
146
331
  const findRecentMoveTo = (i) => {
147
- for (let j = i - 1; j >= 0; j--) if (steps[j].kind === "move") return steps[j].to;
332
+ for (let j = i - 1; j >= 0; j--) {
333
+ const s = steps[j];
334
+ if (s.kind === "move") return s.to;
335
+ }
148
336
  return null;
149
337
  };
150
338
  const ops = [];
151
339
  const points = [];
152
340
  let lastEnd = null;
153
341
  let subPathStart = null;
342
+ /**
343
+ * "笔位覆盖"——arc / circlePath / ellipsePath 这种没有 `to` 字段的 step
344
+ * 不能通过 `prev.step.to` 重算下一段的起点。它们设置 penOverride,下一个
345
+ * 绘制段(line/curve/cubic/bend/step)直接用这个点当 fromClip,之后清空。
346
+ *
347
+ * - arc:endpoint(弧终点)—— 与 SVG 实际 cursor 一致
348
+ * - circlePath/ellipsePath:center(圆心)—— ADR-0002 决策"画完留在圆心",
349
+ * 注意 SVG 实际 cursor 在弧端点而不在 center,必须靠 startSegment 发 M
350
+ * teleport 回中心
351
+ */
352
+ let penOverride = null;
154
353
  const emitM = (p) => {
155
354
  ops.push({
156
355
  cmd: "M",
@@ -172,6 +371,40 @@ var emitPathPrimitive = (path, nodeIndex, round) => {
172
371
  ops.push({ cmd: "Z" });
173
372
  lastEnd = subPathStart;
174
373
  };
374
+ const emitQ = (control, p) => {
375
+ ops.push({
376
+ cmd: "Q",
377
+ control,
378
+ point: p
379
+ });
380
+ points.push(control);
381
+ points.push(p);
382
+ lastEnd = p;
383
+ };
384
+ const emitC = (c1, c2, p) => {
385
+ ops.push({
386
+ cmd: "C",
387
+ control1: c1,
388
+ control2: c2,
389
+ point: p
390
+ });
391
+ points.push(c1);
392
+ points.push(c2);
393
+ points.push(p);
394
+ lastEnd = p;
395
+ };
396
+ const emitA = (rx, ry, largeArc, sweep, p) => {
397
+ ops.push({
398
+ cmd: "A",
399
+ rx,
400
+ ry,
401
+ largeArc,
402
+ sweep,
403
+ point: p
404
+ });
405
+ points.push(p);
406
+ lastEnd = p;
407
+ };
175
408
  /** 段起点:与 lastEnd 相同就复用 cursor(省掉冗余 M),否则发 M */
176
409
  const startSegment = (p) => {
177
410
  if (samePoint(p, lastEnd)) return;
@@ -199,31 +432,114 @@ var emitPathPrimitive = (path, nodeIndex, round) => {
199
432
  }
200
433
  const prev = findPrev(i);
201
434
  if (!prev) return null;
435
+ if (step.kind === "arc") {
436
+ const center = prev.anchor;
437
+ const startPt = arcEndPoint(center, step.radius, step.startAngle);
438
+ const endPt = arcEndPoint(center, step.radius, step.endAngle);
439
+ const flags = arcSvgFlags(step.startAngle, step.endAngle);
440
+ startSegment(startPt);
441
+ emitA(step.radius, step.radius, flags.largeArc, flags.sweep, endPt);
442
+ for (const p of arcBoundingPoints(center, step.radius, step.startAngle, step.endAngle)) points.push(p);
443
+ collectLabel(step, (t) => arcSegmentSample(center, step.radius, step.startAngle, step.endAngle, t));
444
+ penOverride = endPt;
445
+ continue;
446
+ }
447
+ if (step.kind === "circlePath") {
448
+ const center = prev.anchor;
449
+ const r = step.radius;
450
+ const right = [center[0] + r, center[1]];
451
+ const left = [center[0] - r, center[1]];
452
+ startSegment(right);
453
+ emitA(r, r, 0, 1, left);
454
+ emitA(r, r, 0, 1, right);
455
+ points.push([center[0] + r, center[1]]);
456
+ points.push([center[0] - r, center[1]]);
457
+ points.push([center[0], center[1] + r]);
458
+ points.push([center[0], center[1] - r]);
459
+ collectLabel(step, (t) => circleSegmentSample(center, r, t));
460
+ penOverride = center;
461
+ continue;
462
+ }
463
+ if (step.kind === "ellipsePath") {
464
+ const center = prev.anchor;
465
+ const rx = step.radiusX;
466
+ const ry = step.radiusY;
467
+ const right = [center[0] + rx, center[1]];
468
+ const left = [center[0] - rx, center[1]];
469
+ startSegment(right);
470
+ emitA(rx, ry, 0, 1, left);
471
+ emitA(rx, ry, 0, 1, right);
472
+ points.push([center[0] + rx, center[1]]);
473
+ points.push([center[0] - rx, center[1]]);
474
+ points.push([center[0], center[1] + ry]);
475
+ points.push([center[0], center[1] - ry]);
476
+ collectLabel(step, (t) => ellipseSegmentSample(center, rx, ry, t));
477
+ penOverride = center;
478
+ continue;
479
+ }
202
480
  const currAnchor = anchors[i];
203
481
  if (!currAnchor) return null;
482
+ const usedOverride = penOverride;
483
+ penOverride = null;
204
484
  if (step.kind === "line") {
205
- const fromClip = clipForTarget(prev.step.to, currAnchor, nodeIndex);
485
+ const fromClip = usedOverride ?? clipForTarget(prev.step.to, currAnchor, nodeIndex);
206
486
  const toClip = clipForTarget(step.to, prev.anchor, nodeIndex);
207
487
  if (!fromClip || !toClip) return null;
208
488
  startSegment(fromClip);
209
489
  emitL(toClip);
490
+ collectLabel(step, (t) => lineSegmentSample(fromClip, toClip, t));
491
+ continue;
492
+ }
493
+ if (step.kind === "curve") {
494
+ const fromClip = usedOverride ?? clipForTarget(prev.step.to, step.control, nodeIndex);
495
+ const toClip = clipForTarget(step.to, step.control, nodeIndex);
496
+ if (!fromClip || !toClip) return null;
497
+ startSegment(fromClip);
498
+ emitQ(step.control, toClip);
499
+ collectLabel(step, (t) => quadSegmentSample(fromClip, step.control, toClip, t));
500
+ continue;
501
+ }
502
+ if (step.kind === "cubic") {
503
+ const fromClip = usedOverride ?? clipForTarget(prev.step.to, step.control1, nodeIndex);
504
+ const toClip = clipForTarget(step.to, step.control2, nodeIndex);
505
+ if (!fromClip || !toClip) return null;
506
+ startSegment(fromClip);
507
+ emitC(step.control1, step.control2, toClip);
508
+ collectLabel(step, (t) => cubicSegmentSample(fromClip, step.control1, step.control2, toClip, t));
509
+ continue;
510
+ }
511
+ if (step.kind === "bend") {
512
+ const angle = step.bendAngle ?? 30;
513
+ const [c1, c2] = bendControlPoints(prev.anchor, currAnchor, step.bendDirection, angle);
514
+ const fromClip = usedOverride ?? clipForTarget(prev.step.to, c1, nodeIndex);
515
+ const toClip = clipForTarget(step.to, c2, nodeIndex);
516
+ if (!fromClip || !toClip) return null;
517
+ startSegment(fromClip);
518
+ emitC(c1, c2, toClip);
519
+ collectLabel(step, (t) => cubicSegmentSample(fromClip, c1, c2, toClip, t));
210
520
  continue;
211
521
  }
212
522
  const corner = cornerOf(prev.anchor, currAnchor, step.via);
213
- const fromClip = clipForTarget(prev.step.to, corner, nodeIndex);
523
+ const fromClip = usedOverride ?? clipForTarget(prev.step.to, corner, nodeIndex);
214
524
  const toClip = clipForTarget(step.to, corner, nodeIndex);
215
525
  if (!fromClip || !toClip) return null;
216
526
  startSegment(fromClip);
217
527
  emitL(corner);
218
528
  emitL(toClip);
529
+ collectLabel(step, (t) => foldSegmentSample(fromClip, corner, toClip, t));
219
530
  }
220
- const strokeWidth = path.strokeWidth ?? 1;
531
+ const strokeWidth = path.strokeWidth ?? (path.thickness ? THICKNESS_TO_WIDTH[path.thickness] : 1);
221
532
  const baseProps = {
222
533
  stroke: path.stroke ?? "currentColor",
223
534
  strokeWidth,
224
535
  fill: path.fill ?? "none",
225
536
  fillRule: path.fillRule,
226
- strokeDasharray: path.strokeDasharray
537
+ strokeDasharray: path.strokeDasharray,
538
+ strokeLinecap: path.lineCap,
539
+ strokeLinejoin: path.lineJoin,
540
+ opacity: path.opacity,
541
+ fillOpacity: path.fillOpacity,
542
+ strokeOpacity: path.drawOpacity
227
543
  };
228
544
  const markers = arrowMarkers(path.arrow, path.arrowShape);
229
545
  const hasArrows = !!markers.arrowStart || !!markers.arrowEnd;
@@ -255,6 +571,9 @@ var emitPathPrimitive = (path, nodeIndex, round) => {
255
571
  }
256
572
  const tokens = ops.map((op) => {
257
573
  if (op.cmd === "Z") return "Z";
574
+ if (op.cmd === "Q") return `Q ${round(op.control[0])} ${round(op.control[1])} ${round(op.point[0])} ${round(op.point[1])}`;
575
+ if (op.cmd === "C") return `C ${round(op.control1[0])} ${round(op.control1[1])} ${round(op.control2[0])} ${round(op.control2[1])} ${round(op.point[0])} ${round(op.point[1])}`;
576
+ if (op.cmd === "A") return `A ${round(op.rx)} ${round(op.ry)} 0 ${op.largeArc} ${op.sweep} ${round(op.point[0])} ${round(op.point[1])}`;
258
577
  return `${op.cmd} ${round(op.point[0])} ${round(op.point[1])}`;
259
578
  });
260
579
  const subPathStarts = [];
@@ -262,12 +581,12 @@ var emitPathPrimitive = (path, nodeIndex, round) => {
262
581
  if (tok.startsWith("M ")) subPathStarts.push(idx);
263
582
  });
264
583
  if (!hasArrows || subPathStarts.length <= 1) return {
265
- primitive: {
584
+ primitives: [{
266
585
  type: "path",
267
586
  d: tokens.join(" "),
268
587
  ...baseProps,
269
588
  ...markers
270
- },
589
+ }, ...labelPrims],
271
590
  points
272
591
  };
273
592
  const subPathSlices = [];
@@ -277,7 +596,7 @@ var emitPathPrimitive = (path, nodeIndex, round) => {
277
596
  subPathSlices.push(tokens.slice(start, end));
278
597
  }
279
598
  return {
280
- primitive: {
599
+ primitives: [{
281
600
  type: "group",
282
601
  children: subPathSlices.map((sub, i) => {
283
602
  const isFirst = i === 0;
@@ -290,7 +609,7 @@ var emitPathPrimitive = (path, nodeIndex, round) => {
290
609
  ...isLast && markers.arrowEnd ? { arrowEnd: markers.arrowEnd } : {}
291
610
  };
292
611
  })
293
- },
612
+ }, ...labelPrims],
294
613
  points
295
614
  };
296
615
  };
@@ -0,0 +1,34 @@
1
+ import { Position } from './point';
2
+ /**
3
+ * 给定圆心、半径、角度(度,与 polar.toPosition 同约定),返回圆周上对应点。
4
+ */
5
+ export declare const arcEndPoint: (center: Position, radius: number, angleDeg: number) => Position;
6
+ /**
7
+ * 计算 SVG `<path>` A 命令需要的 large-arc-flag 与 sweep-flag。
8
+ *
9
+ * - `largeArc`:弧跨度 `|endAngle - startAngle|` 严格大于 180° 时为 1。
10
+ * - `sweep`:`endAngle >= startAngle` 时为 1,否则为 0;与"角度增加方向 = 在
11
+ * SVG 屏幕上视觉顺时针"一致——因为我们投影时未翻转 y,SVG sweep=1(屏幕 CW)
12
+ * 恰好等价 math 角度增加。
13
+ *
14
+ * 边界:`|Δ|=180°` 时 `largeArc=0`(半弧不算大弧);恰好同点 `|Δ|=0` 也返回 0。
15
+ */
16
+ export declare const arcSvgFlags: (startAngleDeg: number, endAngleDeg: number) => {
17
+ largeArc: 0 | 1;
18
+ sweep: 0 | 1;
19
+ };
20
+ /**
21
+ * 弧的 bounding box 极值候选点:起点、终点,以及 `[startAngle, endAngle]` 区间
22
+ * 里所有 90°·k 的基本方向(0°、90°、180°、270° 及其周期延拓)对应的圆周点。
23
+ *
24
+ * 这些点是计算视图框(viewBox)/ bbox 时必须考虑的候选——因为弧线投影到 x/y
25
+ * 轴的极值只可能在弧端点或圆周上的轴向四点处出现。
26
+ *
27
+ * 说明:
28
+ * - 接受 `endAngle < startAngle`(CW math 方向):以 `min..max` 区间扫描 90°·k;
29
+ * 语义上是"无论扫描方向,此弧覆盖到的角度区间内的基本方向都算极值候选"。
30
+ * - 跨 360°(如 270° → 450°)也按数值区间处理,正确收录中间穿越的 0°、90° 等。
31
+ * - 不去重:若起点恰好落在 90°·k 上会同时出现两次,调用方根据需要再处理。
32
+ */
33
+ export declare const arcBoundingPoints: (center: Position, radius: number, startAngleDeg: number, endAngleDeg: number) => Array<Position>;
34
+ //# sourceMappingURL=arc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arc.d.ts","sourceRoot":"","sources":["../../../src/geometry/arc.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAkBxC;;GAEG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,QAAQ,EAChB,QAAQ,MAAM,EACd,UAAU,MAAM,KACf,QAMF,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,GACtB,eAAe,MAAM,EACrB,aAAa,MAAM,KAClB;IAAE,QAAQ,EAAE,CAAC,GAAG,CAAC,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,CAAC,CAAA;CAMjC,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB,GAC5B,QAAQ,QAAQ,EAChB,QAAQ,MAAM,EACd,eAAe,MAAM,EACrB,aAAa,MAAM,KAClB,KAAK,CAAC,QAAQ,CAmBhB,CAAC"}
@@ -0,0 +1,53 @@
1
+ //#region src/geometry/arc.ts
2
+ var DEG_TO_RAD = Math.PI / 180;
3
+ /**
4
+ * 给定圆心、半径、角度(度,与 polar.toPosition 同约定),返回圆周上对应点。
5
+ */
6
+ var arcEndPoint = (center, radius, angleDeg) => {
7
+ const rad = angleDeg * DEG_TO_RAD;
8
+ return [center[0] + Math.cos(rad) * radius, center[1] + Math.sin(rad) * radius];
9
+ };
10
+ /**
11
+ * 计算 SVG `<path>` A 命令需要的 large-arc-flag 与 sweep-flag。
12
+ *
13
+ * - `largeArc`:弧跨度 `|endAngle - startAngle|` 严格大于 180° 时为 1。
14
+ * - `sweep`:`endAngle >= startAngle` 时为 1,否则为 0;与"角度增加方向 = 在
15
+ * SVG 屏幕上视觉顺时针"一致——因为我们投影时未翻转 y,SVG sweep=1(屏幕 CW)
16
+ * 恰好等价 math 角度增加。
17
+ *
18
+ * 边界:`|Δ|=180°` 时 `largeArc=0`(半弧不算大弧);恰好同点 `|Δ|=0` 也返回 0。
19
+ */
20
+ var arcSvgFlags = (startAngleDeg, endAngleDeg) => {
21
+ return {
22
+ largeArc: Math.abs(endAngleDeg - startAngleDeg) > 180 ? 1 : 0,
23
+ sweep: endAngleDeg >= startAngleDeg ? 1 : 0
24
+ };
25
+ };
26
+ /**
27
+ * 弧的 bounding box 极值候选点:起点、终点,以及 `[startAngle, endAngle]` 区间
28
+ * 里所有 90°·k 的基本方向(0°、90°、180°、270° 及其周期延拓)对应的圆周点。
29
+ *
30
+ * 这些点是计算视图框(viewBox)/ bbox 时必须考虑的候选——因为弧线投影到 x/y
31
+ * 轴的极值只可能在弧端点或圆周上的轴向四点处出现。
32
+ *
33
+ * 说明:
34
+ * - 接受 `endAngle < startAngle`(CW math 方向):以 `min..max` 区间扫描 90°·k;
35
+ * 语义上是"无论扫描方向,此弧覆盖到的角度区间内的基本方向都算极值候选"。
36
+ * - 跨 360°(如 270° → 450°)也按数值区间处理,正确收录中间穿越的 0°、90° 等。
37
+ * - 不去重:若起点恰好落在 90°·k 上会同时出现两次,调用方根据需要再处理。
38
+ */
39
+ var arcBoundingPoints = (center, radius, startAngleDeg, endAngleDeg) => {
40
+ const points = [arcEndPoint(center, radius, startAngleDeg), arcEndPoint(center, radius, endAngleDeg)];
41
+ const lo = Math.min(startAngleDeg, endAngleDeg);
42
+ const hi = Math.max(startAngleDeg, endAngleDeg);
43
+ const kStart = Math.ceil(lo / 90);
44
+ const kEnd = Math.floor(hi / 90);
45
+ for (let k = kStart; k <= kEnd; k++) {
46
+ const angle = k * 90;
47
+ if (angle === startAngleDeg || angle === endAngleDeg) continue;
48
+ points.push(arcEndPoint(center, radius, angle));
49
+ }
50
+ return points;
51
+ };
52
+ //#endregion
53
+ export { arcBoundingPoints, arcEndPoint, arcSvgFlags };
@@ -0,0 +1,18 @@
1
+ import { Position } from './point';
2
+ /**
3
+ * 用 cubic Bezier 拟合 from→to 的弧形 bend。
4
+ *
5
+ * 算法(ADR-0001):
6
+ * 1. apex offset = chord × tan(bendAngle / 2)(沿 path 法向 left / right 偏移量)
7
+ * 2. 控制点取 chord 1/3 / 2/3 处沿法向偏移;为让 cubic 在 t=0.5 处穿过 apex,
8
+ * 控制点 offset = (4/3) × apex offset(推导:B(0.5) = midpoint + (3/4)·s·n)
9
+ *
10
+ * 法向定义(SVG y 向下,"left/right" 按视觉,不按数学):
11
+ * chord direction = (dx, dy) / |chord|
12
+ * visual-left normal = ( dy, -dx) / |chord| (从 from 看向 to,向左偏)
13
+ * visual-right normal = (-dy, dx) / |chord|
14
+ *
15
+ * chord 长度为 0 时无方向可推,两个控制点都返回 from。
16
+ */
17
+ export declare const bendControlPoints: (from: Position, to: Position, direction: "left" | "right", bendAngle: number) => [Position, Position];
18
+ //# sourceMappingURL=bend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bend.d.ts","sourceRoot":"","sources":["../../../src/geometry/bend.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAExC;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iBAAiB,GAC5B,MAAM,QAAQ,EACd,IAAI,QAAQ,EACZ,WAAW,MAAM,GAAG,OAAO,EAC3B,WAAW,MAAM,KAChB,CAAC,QAAQ,EAAE,QAAQ,CAgBrB,CAAC"}
@@ -0,0 +1,29 @@
1
+ //#region src/geometry/bend.ts
2
+ /**
3
+ * 用 cubic Bezier 拟合 from→to 的弧形 bend。
4
+ *
5
+ * 算法(ADR-0001):
6
+ * 1. apex offset = chord × tan(bendAngle / 2)(沿 path 法向 left / right 偏移量)
7
+ * 2. 控制点取 chord 1/3 / 2/3 处沿法向偏移;为让 cubic 在 t=0.5 处穿过 apex,
8
+ * 控制点 offset = (4/3) × apex offset(推导:B(0.5) = midpoint + (3/4)·s·n)
9
+ *
10
+ * 法向定义(SVG y 向下,"left/right" 按视觉,不按数学):
11
+ * chord direction = (dx, dy) / |chord|
12
+ * visual-left normal = ( dy, -dx) / |chord| (从 from 看向 to,向左偏)
13
+ * visual-right normal = (-dy, dx) / |chord|
14
+ *
15
+ * chord 长度为 0 时无方向可推,两个控制点都返回 from。
16
+ */
17
+ var bendControlPoints = (from, to, direction, bendAngle) => {
18
+ const dx = to[0] - from[0];
19
+ const dy = to[1] - from[1];
20
+ const chord = Math.hypot(dx, dy);
21
+ if (chord === 0) return [[from[0], from[1]], [from[0], from[1]]];
22
+ const sign = direction === "left" ? 1 : -1;
23
+ const nx = dy / chord * sign;
24
+ const ny = -dx / chord * sign;
25
+ const ctlOffset = 4 / 3 * (chord * Math.tan(bendAngle * Math.PI / 180 / 2));
26
+ return [[from[0] + dx / 3 + ctlOffset * nx, from[1] + dy / 3 + ctlOffset * ny], [from[0] + 2 * dx / 3 + ctlOffset * nx, from[1] + 2 * dy / 3 + ctlOffset * ny]];
27
+ };
28
+ //#endregion
29
+ export { bendControlPoints };
@@ -4,4 +4,7 @@ export * from './circle';
4
4
  export * from './ellipse';
5
5
  export * from './diamond';
6
6
  export * from './polar';
7
+ export * from './bend';
8
+ export * from './arc';
9
+ export * from './segment';
7
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/geometry/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,QAAQ,CAAC;AACvB,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/geometry/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,QAAQ,CAAC;AACvB,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,QAAQ,CAAC;AACvB,cAAc,OAAO,CAAC;AACtB,cAAc,WAAW,CAAC"}