@design-core/core 0.0.6 → 50.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/.eslintrc.json +4 -0
  2. package/.github/workflows/DesignCoreQA.yml +3 -0
  3. package/TODO.md +49 -6
  4. package/core/core/core.js +0 -3
  5. package/core/designCore.js +1 -1
  6. package/core/dimensions/alignedDimension.js +12 -11
  7. package/core/dimensions/angularDimension.js +38 -37
  8. package/core/dimensions/{baseCircularDimension.js → circularDimensionBase.js} +22 -21
  9. package/core/dimensions/diametricDimension.js +14 -13
  10. package/core/dimensions/dimension.js +62 -55
  11. package/core/dimensions/{baseDimension.js → dimensionBase.js} +73 -107
  12. package/core/dimensions/{baseLinearDimension.js → linearDimensionBase.js} +15 -14
  13. package/core/dimensions/radialDimension.js +12 -11
  14. package/core/dimensions/rotatedDimension.js +20 -19
  15. package/core/entities/arc.js +123 -156
  16. package/core/entities/arctext.js +222 -184
  17. package/core/entities/circle.js +77 -99
  18. package/core/entities/entity.js +114 -93
  19. package/core/entities/hatch.js +346 -273
  20. package/core/entities/insert.js +68 -83
  21. package/core/entities/line.js +21 -142
  22. package/core/entities/lwpolyline.js +16 -11
  23. package/core/entities/point.js +19 -19
  24. package/core/entities/polyline.js +7 -7
  25. package/core/entities/{basePolyline.js → polylineBase.js} +47 -299
  26. package/core/entities/rectangle.js +3 -1
  27. package/core/entities/solid.js +6 -15
  28. package/core/entities/text.js +190 -201
  29. package/core/lib/auxiliary/rubberBand.js +32 -0
  30. package/core/lib/auxiliary/selectionWindow.js +63 -0
  31. package/core/lib/auxiliary/snapPoint.js +113 -0
  32. package/core/lib/auxiliary/trackingLine.js +76 -0
  33. package/core/lib/boundingBox.js +11 -11
  34. package/core/lib/canvas.js +232 -168
  35. package/core/lib/clipboard.js +9 -3
  36. package/core/lib/colour.js +16 -0
  37. package/core/lib/commandLine.js +1 -6
  38. package/core/lib/commandManager.js +18 -8
  39. package/core/lib/constants.js +28 -0
  40. package/core/lib/dxf/dxf.js +15 -14
  41. package/core/lib/dxf/dxfIterator.js +2 -2
  42. package/core/lib/entityManager.js +33 -26
  43. package/core/lib/inputManager.js +36 -12
  44. package/core/lib/intersect.js +25 -0
  45. package/core/lib/matrix.js +1 -1
  46. package/core/lib/patterns.js +9 -2
  47. package/core/lib/plotOptions.js +67 -0
  48. package/core/lib/polylineUtils.js +75 -0
  49. package/core/lib/renderers/cairoRenderer.js +371 -0
  50. package/core/lib/renderers/canvasRenderer.js +315 -0
  51. package/core/lib/renderers/pdfRenderer.js +366 -0
  52. package/core/lib/renderers/rendererBase.js +284 -0
  53. package/core/lib/renderers/svgRenderer.js +343 -0
  54. package/core/lib/scene.js +28 -6
  55. package/core/lib/selectionManager.js +20 -20
  56. package/core/lib/settings.js +4 -2
  57. package/core/lib/snapping.js +4 -191
  58. package/core/lib/stateManager.js +30 -1
  59. package/core/lib/utils.js +26 -7
  60. package/core/properties/entityProperties.js +104 -0
  61. package/core/properties/property.js +106 -2
  62. package/core/properties/propertyManager.js +60 -44
  63. package/core/tables/block.js +50 -81
  64. package/core/tables/dimStyle.js +5 -5
  65. package/core/tables/endblk.js +1 -1
  66. package/core/tables/layer.js +8 -0
  67. package/core/tables/ltypeManager.js +1 -1
  68. package/core/tables/styleManagerBase.js +3 -2
  69. package/core/tables/tableManagerBase.js +8 -3
  70. package/core/tables/vport.js +2 -2
  71. package/core/tools/chamfer.js +117 -143
  72. package/core/tools/chamferFilletBase.js +138 -37
  73. package/core/tools/copy.js +11 -11
  74. package/core/tools/copybase.js +3 -3
  75. package/core/tools/copyclip.js +3 -3
  76. package/core/tools/cornerEntity.js +2 -2
  77. package/core/tools/cutclip.js +3 -3
  78. package/core/tools/explode.js +7 -7
  79. package/core/tools/extend.js +138 -25
  80. package/core/tools/fillet.js +92 -189
  81. package/core/tools/matchProp.js +3 -12
  82. package/core/tools/mirror.js +15 -14
  83. package/core/tools/move.js +8 -8
  84. package/core/tools/offset.js +31 -33
  85. package/core/tools/pasteclip.js +1 -1
  86. package/core/tools/purge.js +10 -10
  87. package/core/tools/rotate.js +19 -16
  88. package/core/tools/scale.js +7 -7
  89. package/core/tools/trim.js +271 -29
  90. package/core/tools/zoom.js +1 -1
  91. package/cspell.json +321 -0
  92. package/jest.config.js +6 -0
  93. package/package.json +5 -3
  94. package/test/dimensions/alignedDimension.test.js +17 -10
  95. package/test/dimensions/angularDimension.test.js +13 -13
  96. package/test/dimensions/diametricDimension.test.js +15 -15
  97. package/test/dimensions/dimension.test.js +12 -12
  98. package/test/dimensions/{baseDimension.test.js → dimensionBase.test.js} +73 -12
  99. package/test/dimensions/linearDimensionBase.test.js +62 -0
  100. package/test/dimensions/radialDimension.test.js +15 -15
  101. package/test/dimensions/rotatedDimension.test.js +18 -11
  102. package/test/entities/arc.test.js +83 -86
  103. package/test/entities/arctext.test.js +184 -57
  104. package/test/entities/circle.test.js +68 -96
  105. package/test/entities/entity.test.js +42 -8
  106. package/test/entities/hatch.test.js +569 -96
  107. package/test/entities/insert.test.js +99 -4
  108. package/test/entities/line.test.js +15 -41
  109. package/test/entities/lwpolyline.test.js +104 -0
  110. package/test/entities/point.test.js +3 -3
  111. package/test/entities/polyline.test.js +11 -0
  112. package/test/entities/{basePolyline.test.js → polylineBase.test.js} +192 -57
  113. package/test/entities/rectangle.test.js +11 -0
  114. package/test/entities/solid.test.js +1 -1
  115. package/test/entities/text.test.js +302 -77
  116. package/test/integration/exportIntegration.reference.dxf +9 -9
  117. package/test/integration/exportIntegration.test.js +5 -5
  118. package/test/integration/intersectionIntegration.test.js +4 -5
  119. package/test/integration/pdfRendererIntegration.reference.pdf +17709 -0
  120. package/test/integration/pdfRendererIntegration.test.js +61 -0
  121. package/test/integration/svgRendererIntegration.reference.svg +3343 -0
  122. package/test/integration/svgRendererIntegration.test.js +61 -0
  123. package/test/integration/userInputIntegration.reference.dxf +1165 -105
  124. package/test/integration/userInputIntegration.test.js +239 -132
  125. package/test/lib/boundingBox.test.js +3 -3
  126. package/test/lib/canvas.test.js +744 -125
  127. package/test/lib/constants.test.js +27 -0
  128. package/test/lib/entityManager.test.js +4 -4
  129. package/test/lib/inputManager.test.js +81 -20
  130. package/test/lib/intersect.test.js +83 -0
  131. package/test/lib/plotOptions.test.js +179 -0
  132. package/test/lib/rubberBand.test.js +84 -0
  133. package/test/lib/scene.test.js +9 -9
  134. package/test/lib/selectionManager.test.js +3 -3
  135. package/test/lib/settings.test.js +5 -6
  136. package/test/lib/snapping.test.js +72 -130
  137. package/test/lib/stateManager.test.js +7 -7
  138. package/test/lib/utils.test.js +36 -2
  139. package/test/properties/entityProperties.test.js +132 -0
  140. package/test/properties/property.test.js +53 -0
  141. package/test/properties/propertyManager.test.js +25 -25
  142. package/test/renderers/canvasRenderer.test.js +317 -0
  143. package/test/renderers/pdfRenderer.test.js +271 -0
  144. package/test/renderers/rendererBase.test.js +213 -0
  145. package/test/renderers/svgRenderer.test.js +326 -0
  146. package/test/tables/block.test.js +92 -13
  147. package/test/tables/endblk.test.js +2 -2
  148. package/test/tables/layer.test.js +30 -0
  149. package/test/tables/layerManager.test.js +2 -2
  150. package/test/test-helpers/hatchGridRenderer.js +180 -0
  151. package/test/test-helpers/test-helpers.js +158 -6
  152. package/test/tools/chamfer.test.js +273 -51
  153. package/test/tools/chamferFilletBase.test.js +125 -36
  154. package/test/tools/copy.test.js +8 -8
  155. package/test/tools/copybase.test.js +2 -2
  156. package/test/tools/copyclip.test.js +2 -2
  157. package/test/tools/cornerEntity.test.js +8 -8
  158. package/test/tools/cutclip.test.js +2 -2
  159. package/test/tools/erase.test.js +6 -6
  160. package/test/tools/explode.test.js +5 -5
  161. package/test/tools/extend.test.js +373 -42
  162. package/test/tools/fillet.test.js +230 -49
  163. package/test/tools/matchProp.test.js +11 -11
  164. package/test/tools/mirror.test.js +39 -39
  165. package/test/tools/move.test.js +7 -7
  166. package/test/tools/offset.test.js +222 -55
  167. package/test/tools/pasteclip.test.js +5 -5
  168. package/test/tools/purge.test.js +4 -4
  169. package/test/tools/rotate.test.js +39 -9
  170. package/test/tools/scale.test.js +33 -33
  171. package/test/tools/trim.test.js +579 -53
  172. package/test/tools/zoom.test.js +12 -12
  173. package/core/lib/selectionWindow.js +0 -83
@@ -0,0 +1,371 @@
1
+ import { RendererBase } from './rendererBase.js';
2
+ import { Colours } from '../colours.js';
3
+
4
+ /**
5
+ * GJS Cairo renderer.
6
+ * Wraps a GJS `cairo.Context` and implements the abstract Renderer interface
7
+ * via direct method calls — no Proxy involved.
8
+ *
9
+ * Key Cairo-specific behaviour:
10
+ * - Bulge segments are decoded to cairo_arc / cairo_arc_negative calls.
11
+ * - Text highlight: cairo_text_path is not available in the current GNOME SDK
12
+ * version of GJS, so a highlight/halo is approximated by drawing the text at
13
+ * 8 small offsets (half the current line width in each cardinal + diagonal
14
+ * direction).
15
+ * - Font size: Cairo's nominal font size does not match the drawn text height.
16
+ * drawText() uses a two-pass correction (measure 'A', scale by ratio) to
17
+ * make the rendered height match the design value.
18
+ */
19
+ export class CairoRenderer extends RendererBase {
20
+ #cr;
21
+ #currentRGB = null; // last colour set via setColour(); used for alpha fills
22
+
23
+ // Highlight state set by setHighlight()
24
+ #isHighlighted = false;
25
+ #highlightColour = null;
26
+ #highlightLineWidthDelta = 0;
27
+ #currentLineWidth = 0;
28
+
29
+ /**
30
+ * @param {Object} cr - GJS Cairo context
31
+ */
32
+ constructor(cr) {
33
+ super();
34
+ this.#cr = cr;
35
+ }
36
+
37
+ // --- Frame setup ---
38
+
39
+ /** @inheritdoc */
40
+ setTransform(matrix) {
41
+ this.#cr.setLineCap(1); // Cairo.LineCap.ROUND — round line ends and circular dots on dotted linetypes
42
+ this.#cr.translate(matrix.e, matrix.f);
43
+ this.#cr.scale(matrix.a, matrix.d);
44
+ }
45
+
46
+ /** @inheritdoc */
47
+ fillBackground(origin, width, height, scale) {
48
+ const rgb = Colours.rgbToScaledRGB(this.getBackgroundColour());
49
+ this.#cr.setSourceRGB(rgb.r, rgb.g, rgb.b);
50
+ const w = width / scale;
51
+ const h = height / scale;
52
+ this.#cr.moveTo(origin.x, origin.y);
53
+ this.#cr.lineTo(origin.x, origin.y + h);
54
+ this.#cr.lineTo(origin.x + w, origin.y + h);
55
+ this.#cr.lineTo(origin.x + w, origin.y);
56
+ this.#cr.lineTo(origin.x, origin.y);
57
+ this.#cr.fill();
58
+ }
59
+
60
+ // --- High-level drawing ---
61
+
62
+ /**
63
+ * @inheritdoc
64
+ * @param {Array} points
65
+ * @param {Object} [options]
66
+ */
67
+ drawShape(points, options = {}) {
68
+ if (points.length === 0) return;
69
+
70
+ // Highlight pass: wide stroke in the highlight colour before the normal draw.
71
+ if (this.#isHighlighted && this.#highlightColour) {
72
+ this.#cr.save();
73
+ const s = Colours.rgbToScaledRGB(this.#highlightColour);
74
+ this.#cr.setSourceRGB(s.r, s.g, s.b);
75
+ this.#cr.setLineWidth(this.#currentLineWidth + this.#highlightLineWidthDelta);
76
+ this.#cr.newPath();
77
+ this.tracePath(points);
78
+ if (options.closed) this.#cr.closePath();
79
+ this.#cr.stroke();
80
+ this.#cr.restore();
81
+ }
82
+
83
+ // Normal pass
84
+ this.#cr.newPath();
85
+ this.tracePath(points);
86
+ this.#applyFillAndStroke(options);
87
+ }
88
+
89
+ /**
90
+ * @inheritdoc
91
+ * @param {Array} characters
92
+ * @param {string} fontName
93
+ * @param {number} height
94
+ */
95
+ drawText(characters, fontName, height) {
96
+ if (!characters || characters.length === 0) return;
97
+
98
+ this.#applyFontWithCorrection(fontName, height);
99
+
100
+ // Same Y-flip logic as CanvasRenderer — Cairo's matrix also has d=-1.
101
+ // scale(1,-1) re-flips Y locally so text renders right side up.
102
+ // Rotation is negated to compensate.
103
+
104
+ // Highlight pass: 8-offset halo approximation (cairo_text_path not yet in GJS).
105
+ if (this.#isHighlighted && this.#highlightColour) {
106
+ const s = Colours.rgbToScaledRGB(this.#highlightColour);
107
+ this.#cr.save();
108
+ this.#cr.setSourceRGB(s.r, s.g, s.b);
109
+ for (const ch of characters) {
110
+ this.#cr.save();
111
+ this.#cr.translate(ch.x, ch.y);
112
+ this.#cr.scale(1, -1);
113
+ if (ch.rotation) this.#cr.rotate(-ch.rotation);
114
+ if (ch.upsideDownOffset) {
115
+ this.#cr.translate(0, -ch.upsideDownOffset); this.#cr.scale(1, -1);
116
+ }
117
+ if (ch.backwardsOffset) {
118
+ this.#cr.translate(ch.backwardsOffset, 0); this.#cr.scale(-1, 1);
119
+ }
120
+ this.#cr.moveTo(0, 0); // showText draws at the current point; reset to origin
121
+ this.#drawTextHalo(ch.char);
122
+ this.#cr.restore();
123
+ }
124
+ this.#cr.restore();
125
+ }
126
+
127
+ // Normal pass
128
+ for (const ch of characters) {
129
+ this.#cr.save();
130
+ this.#cr.translate(ch.x, ch.y);
131
+ this.#cr.scale(1, -1);
132
+ if (ch.rotation) this.#cr.rotate(-ch.rotation);
133
+ if (ch.upsideDownOffset) {
134
+ this.#cr.translate(0, -ch.upsideDownOffset); this.#cr.scale(1, -1);
135
+ }
136
+ if (ch.backwardsOffset) {
137
+ this.#cr.translate(ch.backwardsOffset, 0); this.#cr.scale(-1, 1);
138
+ }
139
+ this.#cr.moveTo(0, 0); // showText draws at the current point; reset to origin
140
+ this.#cr.showText(ch.char);
141
+ this.#cr.restore();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Trace bulge-encoded points into the current path without starting a new
147
+ * path and without stroking or filling. Moves to the first point, then
148
+ * traces each segment as a straight line (bulge=0) or an arc (bulge≠0).
149
+ * Multiple tracePath calls accumulate sub-paths for compound operations
150
+ * such as Hatch clipping.
151
+ * @param {Array} points
152
+ */
153
+ tracePath(points) {
154
+ if (points.length === 0) return;
155
+ this.#cr.moveTo(points[0].x, points[0].y);
156
+ for (let i = 0; i < points.length - 1; i++) {
157
+ const p = points[i];
158
+ const next = points[i + 1];
159
+ if (!p.bulge) {
160
+ this.#cr.lineTo(next.x, next.y);
161
+ } else {
162
+ const centre = p.bulgeCentrePoint(next);
163
+ const radius = p.bulgeRadius(next);
164
+ const startAngle = centre.angle(p);
165
+ const endAngle = centre.angle(next);
166
+ if (p.bulge > 0) {
167
+ this.#cr.arc(centre.x, centre.y, radius, startAngle, endAngle);
168
+ } else {
169
+ this.#cr.arcNegative(centre.x, centre.y, radius, startAngle, endAngle);
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ // --- Low-level path ---
176
+
177
+ /** @inheritdoc */
178
+ drawSegments(segments, dashes = []) {
179
+ if (!segments.length) return;
180
+
181
+ // Highlight pass: wide stroke in the highlight colour before the normal draw.
182
+ if (this.#isHighlighted && this.#highlightColour) {
183
+ this.#cr.save();
184
+ const s = Colours.rgbToScaledRGB(this.#highlightColour);
185
+ this.#cr.setSourceRGB(s.r, s.g, s.b);
186
+ this.#cr.setLineWidth(this.#currentLineWidth + this.#highlightLineWidthDelta);
187
+ if (!dashes.length) {
188
+ this.#cr.newPath();
189
+ for (const seg of segments) {
190
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
191
+ }
192
+ this.#cr.stroke();
193
+ } else {
194
+ for (const seg of segments) {
195
+ this.#cr.setDash(dashes, seg.dashPhase ?? 0);
196
+ this.#cr.newPath();
197
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
198
+ this.#cr.stroke();
199
+ }
200
+ }
201
+ this.#cr.restore();
202
+ }
203
+
204
+ // Normal pass
205
+ if (!dashes.length) {
206
+ this.#cr.newPath();
207
+ for (const seg of segments) {
208
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
209
+ }
210
+ this.#cr.stroke();
211
+ } else {
212
+ for (const seg of segments) {
213
+ this.#cr.setDash(dashes, seg.dashPhase ?? 0);
214
+ this.#cr.newPath();
215
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
216
+ this.#cr.stroke();
217
+ }
218
+ }
219
+ }
220
+
221
+ /** @inheritdoc */
222
+ beginPath() {
223
+ this.#cr.newPath();
224
+ }
225
+
226
+ /** @inheritdoc */
227
+ stroke() {
228
+ this.#cr.stroke();
229
+ }
230
+
231
+ /** @inheritdoc */
232
+ closePath() {
233
+ this.#cr.closePath();
234
+ }
235
+
236
+ /** @inheritdoc */
237
+ applyPath(options = {}) {
238
+ this.#applyFillAndStroke(options);
239
+ }
240
+
241
+ // --- State ---
242
+
243
+ /** @inheritdoc @param {{ r: number, g: number, b: number }} rgb */
244
+ setColour(rgb) {
245
+ this.#currentRGB = Colours.rgbToScaledRGB(this.applyStyle(rgb));
246
+ this.#cr.setSourceRGB(this.#currentRGB.r, this.#currentRGB.g, this.#currentRGB.b);
247
+ }
248
+
249
+ /** @inheritdoc @param {number} width */
250
+ setLineWidth(width) {
251
+ this.#currentLineWidth = width;
252
+ this.#cr.setLineWidth(width);
253
+ }
254
+
255
+ /** @inheritdoc @param {number[]} array @param {number} [offset=0] */
256
+ setDash(array, offset = 0) {
257
+ this.#cr.setDash(array, offset);
258
+ }
259
+
260
+ /** @inheritdoc @param {boolean} isHighlighted @param {Object} [colour] @param {number} [lineWidthDelta=0] */
261
+ setHighlight(isHighlighted, colour = null, lineWidthDelta = 0) {
262
+ this.#isHighlighted = isHighlighted;
263
+ this.#highlightColour = colour;
264
+ this.#highlightLineWidthDelta = lineWidthDelta;
265
+ }
266
+
267
+ // --- Transform ---
268
+
269
+ /** @inheritdoc @param {Object} [transform] */
270
+ applyTransform({ x = 0, y = 0, rotation = 0 } = {}) {
271
+ this.#cr.translate(x, y);
272
+ if (rotation !== 0) this.#cr.rotate(rotation);
273
+ }
274
+
275
+ /** @inheritdoc */
276
+ save() {
277
+ this.#cr.save();
278
+ }
279
+
280
+ /** @inheritdoc */
281
+ restore() {
282
+ this.#cr.restore();
283
+ }
284
+
285
+ // --- Measurement ---
286
+
287
+ /** @inheritdoc @param {string} str @param {string} [fontName] @param {number} [height] @return {Object} */
288
+ measureText(str, fontName, height) {
289
+ if (fontName !== undefined && height !== undefined) {
290
+ this.#applyFontWithCorrection(fontName, height);
291
+ }
292
+ return this.#cr.textExtents(str);
293
+ }
294
+
295
+ /** @inheritdoc @param {string} character @return {number} */
296
+ measureCharWidth(character) {
297
+ return this.#cr.textExtents(character).x_advance;
298
+ }
299
+
300
+ // --- Private helpers ---
301
+
302
+ /**
303
+ * Apply fill and/or stroke to the current path.
304
+ * Uses fillPreserve when both fill and stroke are requested so the path
305
+ * is not consumed by the fill() call.
306
+ * Respects the fillRule option for both fill and clip operations.
307
+ * Cairo's fill rule is a persistent state flag; the caller's save/restore
308
+ * (applied by canvas.js around each entity) cleans it up automatically.
309
+ * @param {Object} options
310
+ */
311
+ #applyFillAndStroke(options) {
312
+ const { closed = false, stroke = true, fill = false, fillRule, clip = false, alpha = 1 } = options;
313
+
314
+ if (closed) this.#cr.closePath();
315
+
316
+ if (clip) {
317
+ if (fillRule === 'evenodd') this.#cr.setFillRule(1); // Cairo.FillRule.EVEN_ODD
318
+ this.#cr.clip();
319
+ return;
320
+ }
321
+
322
+ if (fill) {
323
+ if (fillRule === 'evenodd') this.#cr.setFillRule(1);
324
+ if (alpha !== 1 && this.#currentRGB) {
325
+ this.#cr.setSourceRGBA(this.#currentRGB.r, this.#currentRGB.g, this.#currentRGB.b, alpha);
326
+ stroke ? this.#cr.fillPreserve() : this.#cr.fill();
327
+ this.#cr.setSourceRGB(this.#currentRGB.r, this.#currentRGB.g, this.#currentRGB.b);
328
+ } else {
329
+ stroke ? this.#cr.fillPreserve() : this.#cr.fill();
330
+ }
331
+ }
332
+
333
+ if (stroke) this.#cr.stroke();
334
+ }
335
+
336
+ /**
337
+ * Set the Cairo font face and apply a two-pass size correction so that the
338
+ * rendered glyph height matches the requested scene height.
339
+ * Cairo's nominal font size != actual drawn glyph height, so we measure 'A'
340
+ * at the nominal size and scale accordingly.
341
+ * @param {string} fontName
342
+ * @param {number} height
343
+ */
344
+ #applyFontWithCorrection(fontName, height) {
345
+ this.#cr.selectFontFace(fontName, null, null); // SLANT_NORMAL, WEIGHT_NORMAL
346
+ this.#cr.setFontSize(height);
347
+ const extents = this.#cr.textExtents('A');
348
+ if (extents.height > 0) {
349
+ this.#cr.setFontSize(height * height / extents.height);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Approximate a text highlight halo by drawing the string at 8 small offsets
355
+ * around the origin. Each offset uses highlightLineWidthDelta (already
356
+ * scaled to scene units) so the glow spread matches the shape glow width.
357
+ * Replace with cr.textPath once GJS ships it.
358
+ * @param {string} str
359
+ */
360
+ #drawTextHalo(str) {
361
+ const d = this.#highlightLineWidthDelta / 2;
362
+ for (const [dx, dy] of [
363
+ [d, 0], [-d, 0], [0, d], [0, -d],
364
+ [d, d], [-d, d], [d, -d], [-d, -d],
365
+ ]) {
366
+ this.#cr.moveTo(dx, dy);
367
+ this.#cr.showText(str);
368
+ }
369
+ }
370
+ }
371
+
@@ -0,0 +1,315 @@
1
+ import { RendererBase } from './rendererBase.js';
2
+ import { Colours } from '../colours.js';
3
+
4
+ /**
5
+ * HTML Canvas 2D renderer.
6
+ * Wraps a CanvasRenderingContext2D and implements the RendererBase interface
7
+ * via direct method calls — no Proxy involved.
8
+ */
9
+ export class CanvasRenderer extends RendererBase {
10
+ #ctx;
11
+
12
+ // Highlight state set by setHighlight()
13
+ #isHighlighted = false;
14
+ #highlightColour = null;
15
+ #highlightLineWidthDelta = 0;
16
+ #currentLineWidth = 0;
17
+
18
+ /**
19
+ * @param {CanvasRenderingContext2D} ctx
20
+ */
21
+ constructor(ctx) {
22
+ super();
23
+ this.#ctx = ctx;
24
+ }
25
+
26
+ // --- Frame setup ---
27
+
28
+ /** @inheritdoc */
29
+ setTransform(matrix) {
30
+ this.#ctx.setTransform(matrix);
31
+ }
32
+
33
+ /** @inheritdoc */
34
+ fillBackground(origin, width, height, scale) {
35
+ this.#ctx.fillStyle = Colours.rgbToString(this.getBackgroundColour());
36
+ this.#ctx.fillRect(origin.x, origin.y, width / scale, height / scale);
37
+ }
38
+
39
+ // --- High-level drawing ---
40
+
41
+ /**
42
+ * @inheritdoc
43
+ * @param {Array} points
44
+ * @param {Object} [options]
45
+ */
46
+ drawShape(points, options = {}) {
47
+ if (points.length === 0) return;
48
+
49
+ // Highlight pass: wide stroke in the highlight colour before the normal draw.
50
+ if (this.#isHighlighted && this.#highlightColour) {
51
+ this.#ctx.save();
52
+ const s = Colours.rgbToString(this.#highlightColour);
53
+ this.#ctx.strokeStyle = s;
54
+ this.#ctx.fillStyle = s;
55
+ this.#ctx.lineWidth = this.#currentLineWidth + this.#highlightLineWidthDelta;
56
+ this.#ctx.beginPath();
57
+ this.tracePath(points);
58
+ if (options.closed) this.#ctx.closePath();
59
+ this.#ctx.stroke();
60
+ this.#ctx.restore();
61
+ }
62
+
63
+ // Normal pass
64
+ this.#ctx.beginPath();
65
+ this.tracePath(points);
66
+ this.#applyFillAndStroke(options);
67
+ }
68
+
69
+ /**
70
+ * @inheritdoc
71
+ * @param {Array} characters
72
+ * @param {string} fontName
73
+ * @param {number} height
74
+ */
75
+ drawText(characters, fontName, height) {
76
+ if (!characters || characters.length === 0) return;
77
+
78
+ this.#ctx.font = `${height}pt ${fontName}`;
79
+
80
+ // The canvas matrix has d=-1 (Y inverted for scene coordinates).
81
+ // Text glyphs rendered by fillText/strokeText appear upside down in that
82
+ // space. Each character is therefore wrapped in scale(1,-1) to re-flip Y
83
+ // locally, and the rotation is negated to compensate for the flip.
84
+ // upsideDownOffset / backwardsOffset are optional and only set for Text
85
+ // entities with the corresponding DXF flags.
86
+
87
+ // Highlight pass
88
+ if (this.#isHighlighted && this.#highlightColour) {
89
+ const s = Colours.rgbToString(this.#highlightColour);
90
+ this.#ctx.save();
91
+ this.#ctx.strokeStyle = s;
92
+ this.#ctx.lineWidth = this.#currentLineWidth + this.#highlightLineWidthDelta;
93
+ for (const ch of characters) {
94
+ this.#ctx.save();
95
+ this.#ctx.translate(ch.x, ch.y);
96
+ this.#ctx.scale(1, -1);
97
+ if (ch.rotation) this.#ctx.rotate(-ch.rotation);
98
+ if (ch.upsideDownOffset) {
99
+ this.#ctx.translate(0, -ch.upsideDownOffset); this.#ctx.scale(1, -1);
100
+ }
101
+ if (ch.backwardsOffset) {
102
+ this.#ctx.translate(ch.backwardsOffset, 0); this.#ctx.scale(-1, 1);
103
+ }
104
+ this.#ctx.strokeText(ch.char, 0, 0);
105
+ this.#ctx.restore();
106
+ }
107
+ this.#ctx.restore();
108
+ }
109
+
110
+ // Normal pass
111
+ for (const ch of characters) {
112
+ this.#ctx.save();
113
+ this.#ctx.translate(ch.x, ch.y);
114
+ this.#ctx.scale(1, -1);
115
+ if (ch.rotation) this.#ctx.rotate(-ch.rotation);
116
+ if (ch.upsideDownOffset) {
117
+ this.#ctx.translate(0, -ch.upsideDownOffset); this.#ctx.scale(1, -1);
118
+ }
119
+ if (ch.backwardsOffset) {
120
+ this.#ctx.translate(ch.backwardsOffset, 0); this.#ctx.scale(-1, 1);
121
+ }
122
+ this.#ctx.fillText(ch.char, 0, 0);
123
+ this.#ctx.restore();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Trace bulge-encoded points into the current path without starting a new
129
+ * path and without stroking or filling. Moves to the first point, then
130
+ * traces each segment as a straight line (bulge=0) or an arc (bulge≠0).
131
+ * Multiple tracePath calls accumulate sub-paths for compound operations
132
+ * such as Hatch clipping.
133
+ * @param {Array} points
134
+ */
135
+ tracePath(points) {
136
+ if (points.length === 0) return;
137
+ this.#ctx.moveTo(points[0].x, points[0].y);
138
+ for (let i = 0; i < points.length - 1; i++) {
139
+ const p = points[i];
140
+ const next = points[i + 1];
141
+ if (!p.bulge) {
142
+ this.#ctx.lineTo(next.x, next.y);
143
+ } else {
144
+ const centre = p.bulgeCentrePoint(next);
145
+ const radius = p.bulgeRadius(next);
146
+ const startAngle = centre.angle(p);
147
+ const endAngle = centre.angle(next);
148
+ // Canvas arc: CCW = false (default), CW = true (anticlockwise flag inverted)
149
+ this.#ctx.arc(centre.x, centre.y, radius, startAngle, endAngle, p.bulge < 0);
150
+ }
151
+ }
152
+ }
153
+
154
+ // --- Low-level path ---
155
+
156
+ /** @inheritdoc */
157
+ drawSegments(segments, dashes = []) {
158
+ if (!segments.length) return;
159
+
160
+ // Highlight pass: wide stroke in the highlight colour before the normal draw.
161
+ if (this.#isHighlighted && this.#highlightColour) {
162
+ this.#ctx.save();
163
+ const s = Colours.rgbToString(this.#highlightColour);
164
+ this.#ctx.strokeStyle = s;
165
+ this.#ctx.fillStyle = s;
166
+ this.#ctx.lineWidth = this.#currentLineWidth + this.#highlightLineWidthDelta;
167
+ if (!dashes.length) {
168
+ this.#ctx.beginPath();
169
+ for (const seg of segments) {
170
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
171
+ }
172
+ this.#ctx.stroke();
173
+ } else {
174
+ for (const seg of segments) {
175
+ this.#ctx.setLineDash(dashes);
176
+ this.#ctx.lineDashOffset = seg.dashPhase ?? 0;
177
+ this.#ctx.beginPath();
178
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
179
+ this.#ctx.stroke();
180
+ }
181
+ }
182
+ this.#ctx.restore();
183
+ }
184
+
185
+ // Normal pass
186
+ if (!dashes.length) {
187
+ this.#ctx.beginPath();
188
+ for (const seg of segments) {
189
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
190
+ }
191
+ this.#ctx.stroke();
192
+ } else {
193
+ for (const seg of segments) {
194
+ this.#ctx.setLineDash(dashes);
195
+ this.#ctx.lineDashOffset = seg.dashPhase ?? 0;
196
+ this.#ctx.beginPath();
197
+ this.tracePath([{ x: seg.x1, y: seg.y1 }, { x: seg.x2, y: seg.y2 }]);
198
+ this.#ctx.stroke();
199
+ }
200
+ }
201
+ }
202
+
203
+ /** @inheritdoc */
204
+ beginPath() {
205
+ this.#ctx.beginPath();
206
+ }
207
+
208
+ /** @inheritdoc */
209
+ stroke() {
210
+ this.#ctx.stroke();
211
+ }
212
+
213
+ /** @inheritdoc */
214
+ closePath() {
215
+ this.#ctx.closePath();
216
+ }
217
+
218
+ /** @inheritdoc */
219
+ applyPath(options = {}) {
220
+ this.#applyFillAndStroke(options);
221
+ }
222
+
223
+ // --- State ---
224
+
225
+ /** @inheritdoc @param {{ r: number, g: number, b: number }} rgb */
226
+ setColour(rgb) {
227
+ const s = Colours.rgbToString(this.applyStyle(rgb));
228
+ this.#ctx.strokeStyle = s;
229
+ this.#ctx.fillStyle = s;
230
+ }
231
+
232
+ /** @inheritdoc @param {number} width */
233
+ setLineWidth(width) {
234
+ this.#currentLineWidth = width;
235
+ this.#ctx.lineWidth = width;
236
+ }
237
+
238
+ /** @inheritdoc @param {number[]} array @param {number} [offset=0] */
239
+ setDash(array, offset = 0) {
240
+ this.#ctx.setLineDash(array);
241
+ this.#ctx.lineDashOffset = offset;
242
+ }
243
+
244
+ /** @inheritdoc @param {boolean} isHighlighted @param {Object} [colour] @param {number} [lineWidthDelta=0] */
245
+ setHighlight(isHighlighted, colour = null, lineWidthDelta = 0) {
246
+ this.#isHighlighted = isHighlighted;
247
+ this.#highlightColour = colour;
248
+ this.#highlightLineWidthDelta = lineWidthDelta;
249
+ }
250
+
251
+ // --- Transform ---
252
+
253
+ /** @inheritdoc @param {Object} [transform] */
254
+ applyTransform({ x = 0, y = 0, rotation = 0 } = {}) {
255
+ this.#ctx.translate(x, y);
256
+ if (rotation !== 0) this.#ctx.rotate(rotation);
257
+ }
258
+
259
+ /** @inheritdoc */
260
+ save() {
261
+ this.#ctx.save();
262
+ }
263
+
264
+ /** @inheritdoc */
265
+ restore() {
266
+ this.#ctx.restore();
267
+ }
268
+
269
+ // --- Measurement ---
270
+
271
+ /** @inheritdoc @param {string} str @param {string} [fontName] @param {number} [height] @return {Object} */
272
+ measureText(str, fontName, height) {
273
+ if (fontName !== undefined && height !== undefined) {
274
+ this.#ctx.font = `${height}pt ${fontName}`;
275
+ }
276
+ return this.#ctx.measureText(str);
277
+ }
278
+
279
+ /** @inheritdoc @param {string} character @return {number} */
280
+ measureCharWidth(character) {
281
+ return this.#ctx.measureText(character).width;
282
+ }
283
+
284
+ // --- Private helpers ---
285
+
286
+ /**
287
+ * Apply fill and/or stroke to the current path.
288
+ * Uses fillPreserve equivalent (fill then stroke on same path via beginPath
289
+ * not being called between) when both are requested.
290
+ * @param {Object} options
291
+ */
292
+ #applyFillAndStroke(options) {
293
+ const { closed = false, stroke = true, fill = false, fillRule, clip = false, alpha = 1 } = options;
294
+
295
+ if (closed) this.#ctx.closePath();
296
+
297
+ if (clip) {
298
+ this.#ctx.clip(fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
299
+ return;
300
+ }
301
+
302
+ if (fill) {
303
+ if (alpha !== 1) {
304
+ this.#ctx.save();
305
+ this.#ctx.globalAlpha = alpha;
306
+ this.#ctx.fill(fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
307
+ this.#ctx.restore();
308
+ } else {
309
+ this.#ctx.fill(fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
310
+ }
311
+ }
312
+
313
+ if (stroke) this.#ctx.stroke();
314
+ }
315
+ }