@guinetik/gcanvas 1.0.5 → 2.0.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 (78) hide show
  1. package/dist/aizawa.html +27 -0
  2. package/dist/clifford.html +25 -0
  3. package/dist/cmb.html +24 -0
  4. package/dist/dadras.html +26 -0
  5. package/dist/dejong.html +25 -0
  6. package/dist/gcanvas.es.js +5130 -372
  7. package/dist/gcanvas.es.min.js +1 -1
  8. package/dist/gcanvas.umd.js +1 -1
  9. package/dist/gcanvas.umd.min.js +1 -1
  10. package/dist/halvorsen.html +27 -0
  11. package/dist/index.html +96 -48
  12. package/dist/js/aizawa.js +425 -0
  13. package/dist/js/bezier.js +5 -5
  14. package/dist/js/clifford.js +236 -0
  15. package/dist/js/cmb.js +594 -0
  16. package/dist/js/dadras.js +405 -0
  17. package/dist/js/dejong.js +257 -0
  18. package/dist/js/halvorsen.js +405 -0
  19. package/dist/js/isometric.js +34 -46
  20. package/dist/js/lorenz.js +425 -0
  21. package/dist/js/painter.js +8 -8
  22. package/dist/js/rossler.js +480 -0
  23. package/dist/js/schrodinger.js +314 -18
  24. package/dist/js/thomas.js +394 -0
  25. package/dist/lorenz.html +27 -0
  26. package/dist/rossler.html +27 -0
  27. package/dist/scene-interactivity-test.html +220 -0
  28. package/dist/thomas.html +27 -0
  29. package/package.json +1 -1
  30. package/readme.md +30 -22
  31. package/src/game/objects/go.js +7 -0
  32. package/src/game/objects/index.js +2 -0
  33. package/src/game/objects/isometric-scene.js +53 -3
  34. package/src/game/objects/layoutscene.js +57 -0
  35. package/src/game/objects/mask.js +241 -0
  36. package/src/game/objects/scene.js +19 -0
  37. package/src/game/objects/wrapper.js +14 -2
  38. package/src/game/pipeline.js +17 -0
  39. package/src/game/ui/button.js +101 -16
  40. package/src/game/ui/theme.js +0 -6
  41. package/src/game/ui/togglebutton.js +25 -14
  42. package/src/game/ui/tooltip.js +12 -4
  43. package/src/index.js +3 -0
  44. package/src/io/gesture.js +409 -0
  45. package/src/io/index.js +4 -1
  46. package/src/io/keys.js +9 -1
  47. package/src/io/screen.js +476 -0
  48. package/src/math/attractors.js +664 -0
  49. package/src/math/heat.js +106 -0
  50. package/src/math/index.js +1 -0
  51. package/src/mixins/draggable.js +15 -19
  52. package/src/painter/painter.shapes.js +11 -5
  53. package/src/particle/particle-system.js +165 -1
  54. package/src/physics/index.js +26 -0
  55. package/src/physics/physics-updaters.js +333 -0
  56. package/src/physics/physics.js +375 -0
  57. package/src/shapes/image.js +5 -5
  58. package/src/shapes/index.js +2 -0
  59. package/src/shapes/parallelogram.js +147 -0
  60. package/src/shapes/righttriangle.js +115 -0
  61. package/src/shapes/svg.js +281 -100
  62. package/src/shapes/text.js +22 -6
  63. package/src/shapes/transformable.js +5 -0
  64. package/src/sound/effects.js +807 -0
  65. package/src/sound/index.js +13 -0
  66. package/src/webgl/index.js +7 -0
  67. package/src/webgl/shaders/clifford-point-shaders.js +131 -0
  68. package/src/webgl/shaders/dejong-point-shaders.js +131 -0
  69. package/src/webgl/shaders/point-sprite-shaders.js +152 -0
  70. package/src/webgl/webgl-clifford-renderer.js +477 -0
  71. package/src/webgl/webgl-dejong-renderer.js +472 -0
  72. package/src/webgl/webgl-line-renderer.js +391 -0
  73. package/src/webgl/webgl-particle-renderer.js +410 -0
  74. package/types/index.d.ts +30 -2
  75. package/types/io.d.ts +217 -0
  76. package/types/physics.d.ts +299 -0
  77. package/types/shapes.d.ts +8 -0
  78. package/types/webgl.d.ts +188 -109
package/src/shapes/svg.js CHANGED
@@ -5,6 +5,34 @@ import { Shape } from "./shape";
5
5
  * SVGShape - A specialized Shape class that can render SVG path data
6
6
  */
7
7
  export class SVGShape extends Shape {
8
+ /**
9
+ * Load an SVG file from a URL and create an SVGShape
10
+ * @param {string} url - URL to the SVG file
11
+ * @param {object} options - Standard shape options plus SVG-specific options
12
+ * @returns {Promise<SVGShape>} Promise that resolves to the SVGShape instance
13
+ */
14
+ static async fromURL(url, options = {}) {
15
+ const response = await fetch(url);
16
+ const svgText = await response.text();
17
+
18
+ // Parse SVG to extract path data
19
+ const parser = new DOMParser();
20
+ const doc = parser.parseFromString(svgText, 'image/svg+xml');
21
+
22
+ // Get the first path element's d attribute
23
+ const pathElement = doc.querySelector('path');
24
+ if (!pathElement) {
25
+ throw new Error('No path element found in SVG');
26
+ }
27
+
28
+ const pathData = pathElement.getAttribute('d');
29
+ if (!pathData) {
30
+ throw new Error('Path element has no d attribute');
31
+ }
32
+
33
+ return new SVGShape(pathData, options);
34
+ }
35
+
8
36
  /**
9
37
  * @param {number} x - Center X position
10
38
  * @param {number} y - Center Y position
@@ -15,9 +43,10 @@ export class SVGShape extends Shape {
15
43
  * @param {number} [options.animationProgress=1] - Animation progress (0-1)
16
44
  */
17
45
  constructor(svgPathData, options = {}) {
18
- console.log("SVGShape", options.x)
19
46
  super(options);
20
47
  // SVG specific options
48
+ // Note: 'scale' is for pre-scaling the path data
49
+ // scaleX/scaleY are inherited from Transformable for runtime transforms (e.g., flipping)
21
50
  this.scale = options.scale || 1;
22
51
  this.centerPath =
23
52
  options.centerPath !== undefined ? options.centerPath : true;
@@ -46,76 +75,201 @@ export class SVGShape extends Shape {
46
75
 
47
76
  /**
48
77
  * Parse an SVG path string into a command array for rendering
78
+ * Parses commands in sequential order to maintain path integrity
49
79
  * @param {string} svgPath - SVG path data string
50
80
  * @returns {Array} Array of path commands
51
81
  */
52
82
  parseSVGPath(svgPath) {
53
- // Regular expressions to match SVG path commands
54
- const moveRegex = /M\s*([-\d.]+)[,\s]*([-\d.]+)/g;
55
- const lineRegex = /L\s*([-\d.]+)[,\s]*([-\d.]+)/g;
56
- const curveRegex =
57
- /C\s*([-\d.]+)[,\s]*([-\d.]+)\s*([-\d.]+)[,\s]*([-\d.]+)\s*([-\d.]+)[,\s]*([-\d.]+)/g;
58
- const zRegex = /Z/g;
59
-
60
- // Arrays to store the parsed commands
61
83
  const commands = [];
62
-
63
- // Match and process Move commands (M)
84
+
85
+ // Track current position for converting lines to curves
86
+ let currentX = 0;
87
+ let currentY = 0;
88
+ let subpathStartX = 0;
89
+ let subpathStartY = 0;
90
+
91
+ // Single regex to match all commands in order
92
+ // Matches: M, m, L, l, H, h, V, v, C, c, S, s, Q, q, T, t, A, a, Z, z
93
+ const commandRegex = /([MLHVCSQTAZmlhvcsqtaz])([^MLHVCSQTAZmlhvcsqtaz]*)/g;
94
+
64
95
  let match;
65
- while ((match = moveRegex.exec(svgPath)) !== null) {
66
- commands.push(["M", parseFloat(match[1]), parseFloat(match[2])]);
67
- }
68
-
69
- // Match and process Line commands (L)
70
- while ((match = lineRegex.exec(svgPath)) !== null) {
71
- // Convert line to bezier curve for animation
72
- const x = parseFloat(match[1]);
73
- const y = parseFloat(match[2]);
74
-
75
- // Find the previous point to calculate the control points
76
- let prevX = 0;
77
- let prevY = 0;
78
- for (let i = commands.length - 1; i >= 0; i--) {
79
- const cmd = commands[i];
80
- if (cmd[0] === "M") {
81
- prevX = cmd[1];
82
- prevY = cmd[2];
96
+ while ((match = commandRegex.exec(svgPath)) !== null) {
97
+ const command = match[1];
98
+ const argsStr = match[2].trim();
99
+
100
+ // Parse numbers from arguments
101
+ const args = argsStr.length > 0
102
+ ? argsStr.split(/[\s,]+/).map(parseFloat).filter(n => !isNaN(n))
103
+ : [];
104
+
105
+ switch (command) {
106
+ case 'M': // Absolute moveto
107
+ for (let i = 0; i < args.length; i += 2) {
108
+ currentX = args[i];
109
+ currentY = args[i + 1];
110
+ if (i === 0) {
111
+ subpathStartX = currentX;
112
+ subpathStartY = currentY;
113
+ commands.push(['M', currentX, currentY]);
114
+ } else {
115
+ // Subsequent coordinate pairs are implicit lineto
116
+ commands.push(['L', currentX, currentY]);
117
+ }
118
+ }
119
+ break;
120
+
121
+ case 'm': // Relative moveto
122
+ for (let i = 0; i < args.length; i += 2) {
123
+ currentX += args[i];
124
+ currentY += args[i + 1];
125
+ if (i === 0) {
126
+ subpathStartX = currentX;
127
+ subpathStartY = currentY;
128
+ commands.push(['M', currentX, currentY]);
129
+ } else {
130
+ commands.push(['L', currentX, currentY]);
131
+ }
132
+ }
83
133
  break;
84
- } else if (cmd[0] === "C") {
85
- prevX = cmd[5];
86
- prevY = cmd[6];
134
+
135
+ case 'L': // Absolute lineto
136
+ for (let i = 0; i < args.length; i += 2) {
137
+ const x = args[i];
138
+ const y = args[i + 1];
139
+ commands.push(['L', x, y]);
140
+ currentX = x;
141
+ currentY = y;
142
+ }
87
143
  break;
88
- }
144
+
145
+ case 'l': // Relative lineto
146
+ for (let i = 0; i < args.length; i += 2) {
147
+ currentX += args[i];
148
+ currentY += args[i + 1];
149
+ commands.push(['L', currentX, currentY]);
150
+ }
151
+ break;
152
+
153
+ case 'H': // Absolute horizontal lineto
154
+ for (let i = 0; i < args.length; i++) {
155
+ currentX = args[i];
156
+ commands.push(['L', currentX, currentY]);
157
+ }
158
+ break;
159
+
160
+ case 'h': // Relative horizontal lineto
161
+ for (let i = 0; i < args.length; i++) {
162
+ currentX += args[i];
163
+ commands.push(['L', currentX, currentY]);
164
+ }
165
+ break;
166
+
167
+ case 'V': // Absolute vertical lineto
168
+ for (let i = 0; i < args.length; i++) {
169
+ currentY = args[i];
170
+ commands.push(['L', currentX, currentY]);
171
+ }
172
+ break;
173
+
174
+ case 'v': // Relative vertical lineto
175
+ for (let i = 0; i < args.length; i++) {
176
+ currentY += args[i];
177
+ commands.push(['L', currentX, currentY]);
178
+ }
179
+ break;
180
+
181
+ case 'C': // Absolute cubic bezier
182
+ for (let i = 0; i < args.length; i += 6) {
183
+ commands.push(['C', args[i], args[i+1], args[i+2], args[i+3], args[i+4], args[i+5]]);
184
+ currentX = args[i + 4];
185
+ currentY = args[i + 5];
186
+ }
187
+ break;
188
+
189
+ case 'c': // Relative cubic bezier
190
+ for (let i = 0; i < args.length; i += 6) {
191
+ commands.push(['C',
192
+ currentX + args[i], currentY + args[i+1],
193
+ currentX + args[i+2], currentY + args[i+3],
194
+ currentX + args[i+4], currentY + args[i+5]
195
+ ]);
196
+ currentX += args[i + 4];
197
+ currentY += args[i + 5];
198
+ }
199
+ break;
200
+
201
+ case 'S': // Absolute smooth cubic bezier
202
+ for (let i = 0; i < args.length; i += 4) {
203
+ // Reflect previous control point
204
+ const lastCmd = commands[commands.length - 1];
205
+ let cp1x = currentX, cp1y = currentY;
206
+ if (lastCmd && lastCmd[0] === 'C') {
207
+ cp1x = 2 * currentX - lastCmd[3];
208
+ cp1y = 2 * currentY - lastCmd[4];
209
+ }
210
+ commands.push(['C', cp1x, cp1y, args[i], args[i+1], args[i+2], args[i+3]]);
211
+ currentX = args[i + 2];
212
+ currentY = args[i + 3];
213
+ }
214
+ break;
215
+
216
+ case 's': // Relative smooth cubic bezier
217
+ for (let i = 0; i < args.length; i += 4) {
218
+ const lastCmd = commands[commands.length - 1];
219
+ let cp1x = currentX, cp1y = currentY;
220
+ if (lastCmd && lastCmd[0] === 'C') {
221
+ cp1x = 2 * currentX - lastCmd[3];
222
+ cp1y = 2 * currentY - lastCmd[4];
223
+ }
224
+ commands.push(['C', cp1x, cp1y,
225
+ currentX + args[i], currentY + args[i+1],
226
+ currentX + args[i+2], currentY + args[i+3]
227
+ ]);
228
+ currentX += args[i + 2];
229
+ currentY += args[i + 3];
230
+ }
231
+ break;
232
+
233
+ case 'Q': // Absolute quadratic bezier - convert to cubic
234
+ for (let i = 0; i < args.length; i += 4) {
235
+ const qx = args[i], qy = args[i+1];
236
+ const x = args[i+2], y = args[i+3];
237
+ // Convert quadratic to cubic control points
238
+ const cp1x = currentX + 2/3 * (qx - currentX);
239
+ const cp1y = currentY + 2/3 * (qy - currentY);
240
+ const cp2x = x + 2/3 * (qx - x);
241
+ const cp2y = y + 2/3 * (qy - y);
242
+ commands.push(['C', cp1x, cp1y, cp2x, cp2y, x, y]);
243
+ currentX = x;
244
+ currentY = y;
245
+ }
246
+ break;
247
+
248
+ case 'q': // Relative quadratic bezier - convert to cubic
249
+ for (let i = 0; i < args.length; i += 4) {
250
+ const qx = currentX + args[i], qy = currentY + args[i+1];
251
+ const x = currentX + args[i+2], y = currentY + args[i+3];
252
+ const cp1x = currentX + 2/3 * (qx - currentX);
253
+ const cp1y = currentY + 2/3 * (qy - currentY);
254
+ const cp2x = x + 2/3 * (qx - x);
255
+ const cp2y = y + 2/3 * (qy - y);
256
+ commands.push(['C', cp1x, cp1y, cp2x, cp2y, x, y]);
257
+ currentX = x;
258
+ currentY = y;
259
+ }
260
+ break;
261
+
262
+ case 'Z': // Close path
263
+ case 'z':
264
+ commands.push(['Z']);
265
+ currentX = subpathStartX;
266
+ currentY = subpathStartY;
267
+ break;
268
+
269
+ // T, t (smooth quadratic) and A, a (arc) could be added if needed
89
270
  }
90
-
91
- // Calculate control points for an approximated curve
92
- // For a line, we can use control points that are 1/3 and 2/3 along the line
93
- const cp1x = prevX + (x - prevX) / 3;
94
- const cp1y = prevY + (y - prevY) / 3;
95
- const cp2x = prevX + (2 * (x - prevX)) / 3;
96
- const cp2y = prevY + (2 * (y - prevY)) / 3;
97
-
98
- commands.push(["C", cp1x, cp1y, cp2x, cp2y, x, y]);
99
- }
100
-
101
- // Match and process Curve commands (C)
102
- while ((match = curveRegex.exec(svgPath)) !== null) {
103
- commands.push([
104
- "C",
105
- parseFloat(match[1]),
106
- parseFloat(match[2]),
107
- parseFloat(match[3]),
108
- parseFloat(match[4]),
109
- parseFloat(match[5]),
110
- parseFloat(match[6]),
111
- ]);
112
271
  }
113
-
114
- // Match and process Close commands (Z)
115
- if (zRegex.test(svgPath)) {
116
- commands.push(["Z"]);
117
- }
118
-
272
+
119
273
  return commands;
120
274
  }
121
275
 
@@ -133,7 +287,7 @@ export class SVGShape extends Shape {
133
287
  maxY = -Infinity;
134
288
 
135
289
  for (const cmd of path) {
136
- if (cmd[0] === "M") {
290
+ if (cmd[0] === "M" || cmd[0] === "L") {
137
291
  minX = Math.min(minX, cmd[1]);
138
292
  minY = Math.min(minY, cmd[2]);
139
293
  maxX = Math.max(maxX, cmd[1]);
@@ -156,9 +310,9 @@ export class SVGShape extends Shape {
156
310
 
157
311
  // Translate the center to the origin and scale
158
312
  return path.map((cmd) => {
159
- if (cmd[0] === "M") {
313
+ if (cmd[0] === "M" || cmd[0] === "L") {
160
314
  return [
161
- "M",
315
+ cmd[0],
162
316
  (cmd[1] - pathCenterX) * scale,
163
317
  (cmd[2] - pathCenterY) * scale,
164
318
  ];
@@ -192,7 +346,7 @@ export class SVGShape extends Shape {
192
346
  maxY = -Infinity;
193
347
 
194
348
  for (const cmd of path) {
195
- if (cmd[0] === "M") {
349
+ if (cmd[0] === "M" || cmd[0] === "L") {
196
350
  minX = Math.min(minX, cmd[1]);
197
351
  minY = Math.min(minY, cmd[2]);
198
352
  maxX = Math.max(maxX, cmd[1]);
@@ -211,8 +365,8 @@ export class SVGShape extends Shape {
211
365
 
212
366
  // Scale without centering
213
367
  return path.map((cmd) => {
214
- if (cmd[0] === "M") {
215
- return ["M", cmd[1] * scale, cmd[2] * scale];
368
+ if (cmd[0] === "M" || cmd[0] === "L") {
369
+ return [cmd[0], cmd[1] * scale, cmd[2] * scale];
216
370
  } else if (cmd[0] === "C") {
217
371
  return [
218
372
  "C",
@@ -230,15 +384,20 @@ export class SVGShape extends Shape {
230
384
  }
231
385
 
232
386
  /**
233
- * Calculate a point along a bezier curve at time t
387
+ * Calculate a point along a segment at time t
234
388
  * @param {Array} segment - Path segment command
235
389
  * @param {number} t - Time parameter (0-1)
236
390
  * @returns {Object} Point coordinates {x, y}
237
391
  */
238
- getBezierPoint(segment, t) {
392
+ getPointOnSegment(segment, t) {
239
393
  if (segment[0] === "M") {
240
394
  // For move commands, just return the point
241
395
  return { x: segment[1], y: segment[2] };
396
+ } else if (segment[0] === "L") {
397
+ // For line commands, linear interpolation
398
+ const x = this.prevX + (segment[1] - this.prevX) * t;
399
+ const y = this.prevY + (segment[2] - this.prevY) * t;
400
+ return { x, y };
242
401
  } else if (segment[0] === "C") {
243
402
  // For Cubic Bezier curves, calculate the point at t
244
403
  const startX = this.prevX;
@@ -268,6 +427,13 @@ export class SVGShape extends Shape {
268
427
 
269
428
  return { x: 0, y: 0 };
270
429
  }
430
+
431
+ /**
432
+ * @deprecated Use getPointOnSegment instead
433
+ */
434
+ getBezierPoint(segment, t) {
435
+ return this.getPointOnSegment(segment, t);
436
+ }
271
437
 
272
438
  /**
273
439
  * Get a subset of the path up to the current animation progress
@@ -279,7 +445,7 @@ export class SVGShape extends Shape {
279
445
  let segmentIndex = Math.floor(this.animationProgress * totalSegments);
280
446
  let segmentProgress = (this.animationProgress * totalSegments) % 1;
281
447
 
282
- // Initialize with a null previous point to indicate no previous point exists
448
+ // Initialize previous point tracking
283
449
  let hasPrevPoint = false;
284
450
  this.prevX = 0;
285
451
  this.prevY = 0;
@@ -287,18 +453,14 @@ export class SVGShape extends Shape {
287
453
  // Add all completed segments
288
454
  for (let i = 0; i < segmentIndex; i++) {
289
455
  const cmd = this.pathCommands[i];
290
-
291
- // Add to result
292
456
  result.push([...cmd]);
293
457
 
294
- // Track points for bezier calculations
295
- if (cmd[0] === "M") {
296
- // For Move commands, just update position
458
+ // Track current position
459
+ if (cmd[0] === "M" || cmd[0] === "L") {
297
460
  this.prevX = cmd[1];
298
461
  this.prevY = cmd[2];
299
- hasPrevPoint = true; // Now we have a previous point
462
+ hasPrevPoint = true;
300
463
  } else if (cmd[0] === "C") {
301
- // For Bezier commands, update to end point
302
464
  this.prevX = cmd[5];
303
465
  this.prevY = cmd[6];
304
466
  hasPrevPoint = true;
@@ -315,31 +477,23 @@ export class SVGShape extends Shape {
315
477
  this.prevX = currentSegment[1];
316
478
  this.prevY = currentSegment[2];
317
479
  this.currentPoint = { x: currentSegment[1], y: currentSegment[2] };
318
- hasPrevPoint = true;
480
+ } else if (currentSegment[0] === "L") {
481
+ // For Line commands, interpolate position
482
+ if (!hasPrevPoint) {
483
+ this.findPreviousPoint(segmentIndex);
484
+ }
485
+ const point = this.getPointOnSegment(currentSegment, segmentProgress);
486
+ result.push(['L', point.x, point.y]);
487
+ this.currentPoint = point;
319
488
  } else if (currentSegment[0] === "C") {
489
+ // For Curve commands, interpolate along bezier
320
490
  if (!hasPrevPoint) {
321
- // If there's no previous point but we're trying to draw a curve,
322
- // we need to find the closest previous Move command
323
- for (let i = segmentIndex - 1; i >= 0; i--) {
324
- if (this.pathCommands[i][0] === "M") {
325
- this.prevX = this.pathCommands[i][1];
326
- this.prevY = this.pathCommands[i][2];
327
- hasPrevPoint = true;
328
- break;
329
- }
330
- }
331
-
332
- // If still no previous point, use (0,0)
333
- if (!hasPrevPoint) {
334
- this.prevX = 0;
335
- this.prevY = 0;
336
- }
491
+ this.findPreviousPoint(segmentIndex);
337
492
  }
338
-
339
- // Calculate the partial curve point
340
- const point = this.getBezierPoint(currentSegment, segmentProgress);
341
-
342
- // Add partial curve to result
493
+ const point = this.getPointOnSegment(currentSegment, segmentProgress);
494
+
495
+ // For partial bezier, we need to split the curve
496
+ // Simplified: just draw to the current point
343
497
  result.push([
344
498
  "C",
345
499
  currentSegment[1],
@@ -349,13 +503,40 @@ export class SVGShape extends Shape {
349
503
  point.x,
350
504
  point.y,
351
505
  ]);
352
-
353
506
  this.currentPoint = point;
507
+ } else if (currentSegment[0] === "Z") {
508
+ // Close path - add if we've progressed past it
509
+ if (segmentProgress > 0.5) {
510
+ result.push(['Z']);
511
+ }
512
+ this.currentPoint = { x: this.prevX, y: this.prevY };
354
513
  }
355
514
  }
356
515
 
357
516
  return result;
358
517
  }
518
+
519
+ /**
520
+ * Find the previous point by looking back through commands
521
+ * @param {number} fromIndex - Index to search backwards from
522
+ */
523
+ findPreviousPoint(fromIndex) {
524
+ for (let i = fromIndex - 1; i >= 0; i--) {
525
+ const cmd = this.pathCommands[i];
526
+ if (cmd[0] === "M" || cmd[0] === "L") {
527
+ this.prevX = cmd[1];
528
+ this.prevY = cmd[2];
529
+ return;
530
+ } else if (cmd[0] === "C") {
531
+ this.prevX = cmd[5];
532
+ this.prevY = cmd[6];
533
+ return;
534
+ }
535
+ }
536
+ // Default to origin
537
+ this.prevX = 0;
538
+ this.prevY = 0;
539
+ }
359
540
 
360
541
  /**
361
542
  * Draw the SVG path
@@ -32,6 +32,10 @@ export class TextShape extends Shape {
32
32
 
33
33
  /**
34
34
  * Draw the text using Painter
35
+ *
36
+ * Text is drawn at an offset to center it within its bounding box.
37
+ * This ensures that regardless of alignment setting, the text's
38
+ * bounding box is centered at the object's (x, y) position.
35
39
  */
36
40
  draw() {
37
41
  super.draw();
@@ -39,7 +43,8 @@ export class TextShape extends Shape {
39
43
  Painter.text.setFont(this.font);
40
44
  Painter.text.setTextAlign(this.align);
41
45
  Painter.text.setTextBaseline(this.baseline);
42
- Painter.text.fillText(this.text, 0, 0, this.color);
46
+ // Apply alignment offset to center text within its bounding box
47
+ Painter.text.fillText(this.text, -this._centerOffsetX, -this._centerOffsetY, this.color);
43
48
  }
44
49
 
45
50
  _calculateAlignmentOffsets() {
@@ -47,7 +52,13 @@ export class TextShape extends Shape {
47
52
  if (!Painter.text) return;
48
53
  // Measure text dimensions
49
54
  const metrics = Painter.text.measureTextDimensions(this.text, this.font);
55
+
50
56
  // Calculate horizontal center point offset
57
+ // Goal: center the text bounding box at (0, 0) in local coordinates
58
+ // We draw at (-_centerOffsetX), so:
59
+ // left: draw at -width/2 so text spans [-width/2, +width/2]
60
+ // center: draw at 0 (already centered)
61
+ // right: draw at +width/2 so text spans [-width/2, +width/2]
51
62
  switch (this._align) {
52
63
  case "left":
53
64
  this._centerOffsetX = metrics.width / 2;
@@ -56,22 +67,27 @@ export class TextShape extends Shape {
56
67
  this._centerOffsetX = 0;
57
68
  break;
58
69
  case "right":
59
- this._centerOffsetX = -metrics.width / 2 - 5;
70
+ this._centerOffsetX = -metrics.width / 2;
60
71
  break;
61
72
  }
73
+
62
74
  // Calculate vertical center point offset
75
+ // Goal: center the text bounding box at (0, 0) in local coordinates
76
+ // We draw at (-_centerOffsetY), so:
77
+ // top: draw at -height/2 so text spans [-height/2, +height/2]
78
+ // middle: draw at 0 (already centered)
79
+ // bottom: draw at +height/2 so text spans [-height/2, +height/2]
63
80
  switch (this._baseline) {
64
81
  case "top":
65
- this._centerOffsetY = metrics.height/4;
82
+ this._centerOffsetY = metrics.height / 2;
66
83
  break;
67
84
  case "middle":
68
- this._centerOffsetY = -2;
85
+ this._centerOffsetY = 0;
69
86
  break;
70
87
  case "bottom":
71
- this._centerOffsetY = -metrics.height;
88
+ this._centerOffsetY = -metrics.height / 2;
72
89
  break;
73
90
  }
74
- //console.log("calculateAlignmentOffsets", this._centerOffsetY, this._centerOffsetX);
75
91
  }
76
92
 
77
93
  getTextBounds() {
@@ -70,6 +70,11 @@ export class Transformable extends Renderable {
70
70
  */
71
71
  draw() {
72
72
  this.applyTransforms();
73
+ // Flush any stale canvas fill state before shape draws
74
+ // This prevents color bleeding between consecutive shape renders
75
+ const ctx = Painter.ctx;
76
+ ctx.beginPath();
77
+ ctx.fill();
73
78
  this.drawDebug();
74
79
  }
75
80