@design-core/core 0.0.6 → 0.0.7
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/.eslintrc.json +4 -0
- package/TODO.md +49 -6
- package/core/core/core.js +0 -3
- package/core/dimensions/alignedDimension.js +5 -4
- package/core/dimensions/angularDimension.js +6 -5
- package/core/dimensions/baseCircularDimension.js +3 -2
- package/core/dimensions/baseDimension.js +59 -93
- package/core/dimensions/baseLinearDimension.js +6 -5
- package/core/dimensions/diametricDimension.js +4 -3
- package/core/dimensions/dimension.js +21 -14
- package/core/dimensions/radialDimension.js +4 -3
- package/core/dimensions/rotatedDimension.js +12 -11
- package/core/entities/arc.js +112 -145
- package/core/entities/arctext.js +212 -174
- package/core/entities/basePolyline.js +30 -282
- package/core/entities/circle.js +77 -99
- package/core/entities/entity.js +114 -93
- package/core/entities/hatch.js +322 -249
- package/core/entities/insert.js +66 -81
- package/core/entities/line.js +21 -142
- package/core/entities/lwpolyline.js +16 -11
- package/core/entities/polyline.js +4 -4
- package/core/entities/rectangle.js +3 -1
- package/core/entities/solid.js +6 -15
- package/core/entities/text.js +188 -199
- package/core/lib/auxiliary/rubberBand.js +32 -0
- package/core/lib/auxiliary/selectionWindow.js +63 -0
- package/core/lib/auxiliary/snapPoint.js +113 -0
- package/core/lib/auxiliary/trackingLine.js +76 -0
- package/core/lib/canvas.js +228 -166
- package/core/lib/clipboard.js +7 -1
- package/core/lib/colour.js +16 -0
- package/core/lib/commandLine.js +1 -6
- package/core/lib/commandManager.js +18 -8
- package/core/lib/constants.js +28 -0
- package/core/lib/dxf/dxf.js +4 -3
- package/core/lib/entityManager.js +18 -11
- package/core/lib/inputManager.js +35 -11
- package/core/lib/intersect.js +25 -0
- package/core/lib/patterns.js +8 -1
- package/core/lib/plotOptions.js +67 -0
- package/core/lib/polylineUtils.js +75 -0
- package/core/lib/renderers/cairoRenderer.js +371 -0
- package/core/lib/renderers/canvasRenderer.js +315 -0
- package/core/lib/renderers/pdfRenderer.js +366 -0
- package/core/lib/renderers/rendererBase.js +284 -0
- package/core/lib/renderers/svgRenderer.js +343 -0
- package/core/lib/scene.js +27 -5
- package/core/lib/selectionManager.js +2 -2
- package/core/lib/settings.js +4 -2
- package/core/lib/snapping.js +4 -191
- package/core/lib/stateManager.js +30 -1
- package/core/lib/utils.js +21 -2
- package/core/properties/entityProperties.js +104 -0
- package/core/properties/property.js +106 -2
- package/core/properties/propertyManager.js +29 -13
- package/core/tables/block.js +4 -37
- package/core/tables/endblk.js +1 -1
- package/core/tables/layer.js +8 -0
- package/core/tables/styleManagerBase.js +3 -2
- package/core/tables/tableManagerBase.js +8 -3
- package/core/tools/chamfer.js +111 -137
- package/core/tools/chamferFilletBase.js +135 -34
- package/core/tools/copy.js +3 -3
- package/core/tools/explode.js +2 -2
- package/core/tools/extend.js +132 -19
- package/core/tools/fillet.js +86 -183
- package/core/tools/matchProp.js +2 -11
- package/core/tools/mirror.js +7 -6
- package/core/tools/move.js +3 -3
- package/core/tools/offset.js +3 -5
- package/core/tools/pasteclip.js +1 -1
- package/core/tools/rotate.js +6 -3
- package/core/tools/scale.js +1 -1
- package/core/tools/trim.js +265 -23
- package/core/tools/zoom.js +1 -1
- package/jest.config.js +6 -0
- package/package.json +5 -3
- package/test/dimensions/alignedDimension.test.js +14 -7
- package/test/dimensions/angularDimension.test.js +6 -6
- package/test/dimensions/baseDimension.test.js +69 -8
- package/test/dimensions/baseLinearDimension.test.js +62 -0
- package/test/dimensions/diametricDimension.test.js +6 -6
- package/test/dimensions/radialDimension.test.js +6 -6
- package/test/dimensions/rotatedDimension.test.js +14 -7
- package/test/entities/arc.test.js +80 -83
- package/test/entities/arctext.test.js +180 -53
- package/test/entities/basePolyline.test.js +68 -2
- package/test/entities/circle.test.js +57 -85
- package/test/entities/entity.test.js +42 -8
- package/test/entities/hatch.test.js +543 -70
- package/test/entities/insert.test.js +96 -1
- package/test/entities/line.test.js +15 -41
- package/test/entities/lwpolyline.test.js +104 -0
- package/test/entities/polyline.test.js +11 -0
- package/test/entities/rectangle.test.js +11 -0
- package/test/entities/solid.test.js +1 -1
- package/test/entities/text.test.js +294 -69
- package/test/integration/exportIntegration.reference.dxf +9 -9
- package/test/integration/exportIntegration.test.js +2 -2
- package/test/integration/intersectionIntegration.test.js +4 -5
- package/test/integration/pdfRendererIntegration.reference.pdf +17709 -0
- package/test/integration/pdfRendererIntegration.test.js +61 -0
- package/test/integration/svgRendererIntegration.reference.svg +3343 -0
- package/test/integration/svgRendererIntegration.test.js +61 -0
- package/test/integration/userInputIntegration.reference.dxf +1165 -105
- package/test/integration/userInputIntegration.test.js +239 -132
- package/test/lib/canvas.test.js +742 -123
- package/test/lib/constants.test.js +27 -0
- package/test/lib/entityManager.test.js +4 -4
- package/test/lib/inputManager.test.js +80 -19
- package/test/lib/intersect.test.js +83 -0
- package/test/lib/plotOptions.test.js +179 -0
- package/test/lib/rubberBand.test.js +84 -0
- package/test/lib/settings.test.js +5 -6
- package/test/lib/snapping.test.js +71 -129
- package/test/lib/stateManager.test.js +7 -7
- package/test/lib/utils.test.js +36 -2
- package/test/properties/entityProperties.test.js +132 -0
- package/test/properties/property.test.js +53 -0
- package/test/properties/propertyManager.test.js +3 -3
- package/test/renderers/canvasRenderer.test.js +317 -0
- package/test/renderers/pdfRenderer.test.js +271 -0
- package/test/renderers/rendererBase.test.js +213 -0
- package/test/renderers/svgRenderer.test.js +326 -0
- package/test/tables/block.test.js +31 -1
- package/test/tables/endblk.test.js +2 -2
- package/test/tables/layer.test.js +30 -0
- package/test/test-helpers/hatchGridRenderer.js +180 -0
- package/test/test-helpers/test-helpers.js +152 -0
- package/test/tools/chamfer.test.js +227 -5
- package/test/tools/chamferFilletBase.test.js +98 -9
- package/test/tools/copy.test.js +2 -2
- package/test/tools/explode.test.js +3 -3
- package/test/tools/extend.test.js +331 -0
- package/test/tools/fillet.test.js +181 -0
- package/test/tools/matchProp.test.js +5 -5
- package/test/tools/mirror.test.js +13 -13
- package/test/tools/offset.test.js +170 -3
- package/test/tools/pasteclip.test.js +5 -5
- package/test/tools/rotate.test.js +32 -2
- package/test/tools/scale.test.js +5 -5
- package/test/tools/trim.test.js +526 -0
- package/test/tools/zoom.test.js +7 -7
- 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 center = p.bulgeCentrePoint(next);
|
|
163
|
+
const radius = p.bulgeRadius(next);
|
|
164
|
+
const startAngle = center.angle(p);
|
|
165
|
+
const endAngle = center.angle(next);
|
|
166
|
+
if (p.bulge > 0) {
|
|
167
|
+
this.#cr.arc(center.x, center.y, radius, startAngle, endAngle);
|
|
168
|
+
} else {
|
|
169
|
+
this.#cr.arcNegative(center.x, center.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 center = p.bulgeCentrePoint(next);
|
|
145
|
+
const radius = p.bulgeRadius(next);
|
|
146
|
+
const startAngle = center.angle(p);
|
|
147
|
+
const endAngle = center.angle(next);
|
|
148
|
+
// Canvas arc: CCW = false (default), CW = true (anticlockwise flag inverted)
|
|
149
|
+
this.#ctx.arc(center.x, center.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
|
+
}
|