@guinetik/gcanvas 1.0.1 → 1.0.2

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 (42) hide show
  1. package/demos/coordinates.html +698 -0
  2. package/demos/cube3d.html +23 -0
  3. package/demos/demos.css +17 -3
  4. package/demos/dino.html +42 -0
  5. package/demos/gameobjects.html +626 -0
  6. package/demos/index.html +17 -7
  7. package/demos/js/coordinates.js +840 -0
  8. package/demos/js/cube3d.js +789 -0
  9. package/demos/js/dino.js +1420 -0
  10. package/demos/js/gameobjects.js +176 -0
  11. package/demos/js/plane3d.js +256 -0
  12. package/demos/js/platformer.js +1579 -0
  13. package/demos/js/sphere3d.js +229 -0
  14. package/demos/js/sprite.js +473 -0
  15. package/demos/js/tde/accretiondisk.js +3 -3
  16. package/demos/js/tde/tidalstream.js +2 -2
  17. package/demos/plane3d.html +24 -0
  18. package/demos/platformer.html +43 -0
  19. package/demos/sphere3d.html +24 -0
  20. package/demos/sprite.html +18 -0
  21. package/docs/concepts/coordinate-system.md +384 -0
  22. package/docs/concepts/shapes-vs-gameobjects.md +187 -0
  23. package/docs/fluid-dynamics.md +99 -97
  24. package/package.json +1 -1
  25. package/src/game/game.js +11 -5
  26. package/src/game/objects/index.js +3 -0
  27. package/src/game/objects/platformer-scene.js +411 -0
  28. package/src/game/objects/scene.js +14 -0
  29. package/src/game/objects/sprite.js +529 -0
  30. package/src/game/pipeline.js +20 -16
  31. package/src/game/ui/theme.js +123 -121
  32. package/src/io/input.js +75 -45
  33. package/src/io/mouse.js +44 -19
  34. package/src/io/touch.js +35 -12
  35. package/src/shapes/cube3d.js +599 -0
  36. package/src/shapes/index.js +2 -0
  37. package/src/shapes/plane3d.js +687 -0
  38. package/src/shapes/sphere3d.js +75 -6
  39. package/src/util/camera2d.js +315 -0
  40. package/src/util/index.js +1 -0
  41. package/src/webgl/shaders/plane-shaders.js +332 -0
  42. package/src/webgl/shaders/sphere-shaders.js +4 -2
@@ -0,0 +1,687 @@
1
+ import { Shape } from "./shape.js";
2
+ import { Painter } from "../painter/painter.js";
3
+ import { WebGLRenderer } from "../webgl/webgl-renderer.js";
4
+ import { PLANE_SHADERS } from "../webgl/shaders/plane-shaders.js";
5
+
6
+ /**
7
+ * Plane3D - A true 3D plane that integrates with Camera3D
8
+ *
9
+ * A flat rectangular surface in 3D space with proper perspective projection,
10
+ * lighting, and optional WebGL shader effects.
11
+ *
12
+ * Features:
13
+ * - Integrates with Camera3D for rotation and perspective
14
+ * - Self-rotation around any axis
15
+ * - Solid color with lighting
16
+ * - Image texture support
17
+ * - WebGL shader support (gradient, grid, checkerboard)
18
+ * - Double-sided rendering option
19
+ *
20
+ * @example
21
+ * // Basic plane with Canvas 2D rendering
22
+ * const plane = new Plane3D(100, 80, {
23
+ * color: "#4A90D9",
24
+ * camera: this.camera,
25
+ * });
26
+ *
27
+ * @example
28
+ * // Plane with WebGL gradient shader
29
+ * const gradientPlane = new Plane3D(100, 80, {
30
+ * camera: this.camera,
31
+ * useShader: true,
32
+ * shaderType: "gradient",
33
+ * shaderUniforms: {
34
+ * uColor1: [1.0, 0.2, 0.4],
35
+ * uColor2: [0.2, 0.4, 1.0],
36
+ * uAngle: Math.PI / 4,
37
+ * },
38
+ * });
39
+ */
40
+ export class Plane3D extends Shape {
41
+ // Shared WebGL renderer for all shader-enabled planes
42
+ static _glRenderer = null;
43
+ static _glRendererSize = { width: 0, height: 0 };
44
+
45
+ /**
46
+ * Get or create shared WebGL renderer
47
+ * @param {number} width - Required width
48
+ * @param {number} height - Required height
49
+ * @returns {WebGLRenderer|null}
50
+ * @private
51
+ */
52
+ static _getGLRenderer(width, height) {
53
+ if (!Plane3D._glRenderer) {
54
+ Plane3D._glRenderer = new WebGLRenderer(width, height);
55
+ Plane3D._glRendererSize = { width, height };
56
+ } else if (
57
+ Plane3D._glRendererSize.width !== width ||
58
+ Plane3D._glRendererSize.height !== height
59
+ ) {
60
+ Plane3D._glRenderer.resize(width, height);
61
+ Plane3D._glRendererSize = { width, height };
62
+ }
63
+ return Plane3D._glRenderer;
64
+ }
65
+
66
+ /**
67
+ * Create a 3D plane
68
+ * @param {number} width - Plane width
69
+ * @param {number} height - Plane height
70
+ * @param {object} options - Configuration options
71
+ * @param {number} [options.x=0] - X position in 3D space
72
+ * @param {number} [options.y=0] - Y position in 3D space
73
+ * @param {number} [options.z=0] - Z position in 3D space
74
+ * @param {string} [options.color="#888"] - Fill color (hex format)
75
+ * @param {Camera3D} [options.camera] - Camera for projection
76
+ * @param {boolean} [options.debug=false] - Show wireframe
77
+ * @param {string} [options.stroke] - Wireframe line color
78
+ * @param {number} [options.lineWidth=1] - Wireframe line width
79
+ * @param {boolean} [options.doubleSided=false] - Render both sides
80
+ * @param {HTMLImageElement} [options.texture] - Image texture
81
+ * @param {number} [options.selfRotationX=0] - Self-rotation around X axis (radians)
82
+ * @param {number} [options.selfRotationY=0] - Self-rotation around Y axis (radians)
83
+ * @param {number} [options.selfRotationZ=0] - Self-rotation around Z axis (radians)
84
+ * @param {boolean} [options.useShader=false] - Use WebGL shader rendering
85
+ * @param {string} [options.shaderType='gradient'] - Shader type: 'gradient', 'grid', 'checkerboard'
86
+ * @param {Object} [options.shaderUniforms={}] - Custom shader uniforms
87
+ */
88
+ constructor(width, height, options = {}) {
89
+ super(options);
90
+
91
+ this.planeWidth = width;
92
+ this.planeHeight = height;
93
+
94
+ // 3D position
95
+ this.x = options.x ?? 0;
96
+ this.y = options.y ?? 0;
97
+ this.z = options.z ?? 0;
98
+
99
+ // Camera reference
100
+ this.camera = options.camera ?? null;
101
+
102
+ // Rendering options
103
+ this.debug = options.debug ?? false;
104
+ this.doubleSided = options.doubleSided ?? true; // Default to double-sided for standalone planes
105
+ this.texture = options.texture ?? null;
106
+
107
+ // Self-rotation (radians)
108
+ this.selfRotationX = options.selfRotationX ?? 0;
109
+ this.selfRotationY = options.selfRotationY ?? 0;
110
+ this.selfRotationZ = options.selfRotationZ ?? 0;
111
+
112
+ // WebGL shader options
113
+ this.useShader = options.useShader ?? false;
114
+ this.shaderType = options.shaderType ?? "gradient";
115
+ this.shaderUniforms = options.shaderUniforms ?? {};
116
+ this._shaderInitialized = false;
117
+
118
+ // Generate plane geometry
119
+ this._generateGeometry();
120
+ }
121
+
122
+ /**
123
+ * Set or update the camera reference
124
+ * @param {Camera3D} camera - Camera instance
125
+ * @returns {Plane3D} this for chaining
126
+ */
127
+ setCamera(camera) {
128
+ this.camera = camera;
129
+ return this;
130
+ }
131
+
132
+ /**
133
+ * Set texture image
134
+ * @param {HTMLImageElement} image - Image element
135
+ * @returns {Plane3D} this for chaining
136
+ */
137
+ setTexture(image) {
138
+ this.texture = image;
139
+ return this;
140
+ }
141
+
142
+ /**
143
+ * Update shader uniforms dynamically
144
+ * @param {Object} uniforms - Uniform name -> value pairs
145
+ * @returns {Plane3D} this for chaining
146
+ */
147
+ setShaderUniforms(uniforms) {
148
+ Object.assign(this.shaderUniforms, uniforms);
149
+ return this;
150
+ }
151
+
152
+ /**
153
+ * Generate plane geometry (4 vertices, 2 triangular faces)
154
+ * @private
155
+ */
156
+ _generateGeometry() {
157
+ const hw = this.planeWidth / 2;
158
+ const hh = this.planeHeight / 2;
159
+
160
+ // 4 vertices with normals and UVs
161
+ // Plane at z=0, facing -Z (towards camera by default)
162
+ this.vertices = [
163
+ { x: -hw, y: -hh, z: 0, nx: 0, ny: 0, nz: -1, u: 0, v: 0 }, // top-left
164
+ { x: hw, y: -hh, z: 0, nx: 0, ny: 0, nz: -1, u: 1, v: 0 }, // top-right
165
+ { x: hw, y: hh, z: 0, nx: 0, ny: 0, nz: -1, u: 1, v: 1 }, // bottom-right
166
+ { x: -hw, y: hh, z: 0, nx: 0, ny: 0, nz: -1, u: 0, v: 1 }, // bottom-left
167
+ ];
168
+
169
+ // 2 triangular faces (CCW winding)
170
+ this.faces = [
171
+ [0, 1, 2], // upper-right triangle
172
+ [0, 2, 3], // lower-left triangle
173
+ ];
174
+ }
175
+
176
+ /**
177
+ * Get fragment shader source for the current shader type
178
+ * @returns {string}
179
+ * @private
180
+ */
181
+ _getFragmentShader() {
182
+ switch (this.shaderType) {
183
+ case "gradient":
184
+ return PLANE_SHADERS.gradient;
185
+ case "grid":
186
+ return PLANE_SHADERS.grid;
187
+ case "checkerboard":
188
+ return PLANE_SHADERS.checkerboard;
189
+ case "noise":
190
+ return PLANE_SHADERS.noise;
191
+ default:
192
+ return PLANE_SHADERS.gradient;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Initialize or update the WebGL shader
198
+ * @param {number} renderWidth - Render width
199
+ * @param {number} renderHeight - Render height
200
+ * @private
201
+ */
202
+ _initShader(renderWidth, renderHeight) {
203
+ const gl = Plane3D._getGLRenderer(renderWidth, renderHeight);
204
+ if (!gl || !gl.isAvailable()) {
205
+ return false;
206
+ }
207
+
208
+ const programName = `plane_${this.shaderType}`;
209
+ const vertexShader = PLANE_SHADERS.vertex;
210
+ const fragmentShader = this._getFragmentShader();
211
+
212
+ try {
213
+ gl.useProgram(programName, vertexShader, fragmentShader);
214
+ this._shaderInitialized = true;
215
+ return true;
216
+ } catch (e) {
217
+ console.warn("Plane3D shader init failed:", e);
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Render plane using WebGL shader
224
+ * @param {CanvasRenderingContext2D} ctx - 2D context for compositing
225
+ * @param {number} screenX - Screen X position
226
+ * @param {number} screenY - Screen Y position
227
+ * @param {number} screenWidth - Screen width after projection
228
+ * @param {number} screenHeight - Screen height after projection
229
+ * @returns {boolean} True if shader rendering succeeded
230
+ * @private
231
+ */
232
+ _renderWithShader(ctx, screenX, screenY, screenWidth, screenHeight) {
233
+ // Ensure minimum size for rendering
234
+ const renderSize = Math.max(
235
+ Math.ceil(Math.max(screenWidth, screenHeight)),
236
+ 16
237
+ );
238
+
239
+ if (!this._shaderInitialized) {
240
+ if (!this._initShader(renderSize, renderSize)) {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ const gl = Plane3D._getGLRenderer(renderSize, renderSize);
246
+ if (!gl || !gl.isAvailable()) {
247
+ return false;
248
+ }
249
+
250
+ // Ensure correct program is active
251
+ const programName = `plane_${this.shaderType}`;
252
+ gl.useProgram(programName, PLANE_SHADERS.vertex, this._getFragmentShader());
253
+
254
+ // Set common uniforms
255
+ const time = performance.now() / 1000;
256
+ gl.setUniforms({
257
+ uTime: time,
258
+ uResolution: [renderSize, renderSize],
259
+ });
260
+
261
+ // Set shader-specific uniforms
262
+ gl.setUniforms(this.shaderUniforms);
263
+
264
+ // Render
265
+ gl.render();
266
+
267
+ // Composite onto 2D canvas at the projected position
268
+ const drawX = screenX - screenWidth / 2;
269
+ const drawY = screenY - screenHeight / 2;
270
+ gl.compositeOnto(ctx, drawX, drawY, screenWidth, screenHeight);
271
+
272
+ return true;
273
+ }
274
+
275
+ /**
276
+ * Apply self-rotation to a point (vertex or normal)
277
+ * @param {number} x - X component
278
+ * @param {number} y - Y component
279
+ * @param {number} z - Z component
280
+ * @returns {{x: number, y: number, z: number}} Rotated point
281
+ * @private
282
+ */
283
+ _applySelfRotation(x, y, z) {
284
+ // Rotate around Y axis first (most common for spinning objects)
285
+ if (this.selfRotationY !== 0) {
286
+ const cosY = Math.cos(this.selfRotationY);
287
+ const sinY = Math.sin(this.selfRotationY);
288
+ const x1 = x * cosY - z * sinY;
289
+ const z1 = x * sinY + z * cosY;
290
+ x = x1;
291
+ z = z1;
292
+ }
293
+
294
+ // Rotate around X axis
295
+ if (this.selfRotationX !== 0) {
296
+ const cosX = Math.cos(this.selfRotationX);
297
+ const sinX = Math.sin(this.selfRotationX);
298
+ const y1 = y * cosX - z * sinX;
299
+ const z1 = y * sinX + z * cosX;
300
+ y = y1;
301
+ z = z1;
302
+ }
303
+
304
+ // Rotate around Z axis
305
+ if (this.selfRotationZ !== 0) {
306
+ const cosZ = Math.cos(this.selfRotationZ);
307
+ const sinZ = Math.sin(this.selfRotationZ);
308
+ const x1 = x * cosZ - y * sinZ;
309
+ const y1 = x * sinZ + y * cosZ;
310
+ x = x1;
311
+ y = y1;
312
+ }
313
+
314
+ return { x, y, z };
315
+ }
316
+
317
+ /**
318
+ * Calculate lighting intensity based on surface normal
319
+ * @param {number} nx - Normal x component
320
+ * @param {number} ny - Normal y component
321
+ * @param {number} nz - Normal z component
322
+ * @returns {number} Intensity 0-1
323
+ * @private
324
+ */
325
+ _calculateLighting(nx, ny, nz) {
326
+ // Simple directional light from top-right-front
327
+ const lightX = 0.5;
328
+ const lightY = 0.7;
329
+ const lightZ = 0.5;
330
+ const lightLen = Math.sqrt(
331
+ lightX * lightX + lightY * lightY + lightZ * lightZ
332
+ );
333
+
334
+ // Normalized light direction
335
+ const lx = lightX / lightLen;
336
+ const ly = lightY / lightLen;
337
+ const lz = lightZ / lightLen;
338
+
339
+ // Dot product for diffuse lighting
340
+ let intensity = nx * lx + ny * ly + nz * lz;
341
+
342
+ // Clamp and add ambient light
343
+ intensity = Math.max(0, intensity) * 0.7 + 0.3;
344
+
345
+ return intensity;
346
+ }
347
+
348
+ /**
349
+ * Apply lighting to a color
350
+ * @param {string} color - Base color (hex format)
351
+ * @param {number} intensity - Light intensity 0-1
352
+ * @returns {string} RGB color string
353
+ * @private
354
+ */
355
+ _applyLighting(color, intensity) {
356
+ if (!color || typeof color !== "string" || !color.startsWith("#")) {
357
+ return color;
358
+ }
359
+
360
+ // Parse hex color
361
+ const hex = color.replace("#", "");
362
+ const r = parseInt(hex.substring(0, 2), 16);
363
+ const g = parseInt(hex.substring(2, 4), 16);
364
+ const b = parseInt(hex.substring(4, 6), 16);
365
+
366
+ // Apply intensity
367
+ const lr = Math.round(r * intensity);
368
+ const lg = Math.round(g * intensity);
369
+ const lb = Math.round(b * intensity);
370
+
371
+ return `rgb(${lr}, ${lg}, ${lb})`;
372
+ }
373
+
374
+ /**
375
+ * Main render method
376
+ */
377
+ draw() {
378
+ super.draw();
379
+
380
+ if (!this.camera) {
381
+ // Fallback: draw a simple rectangle if no camera
382
+ if (this.color) {
383
+ Painter.shapes.fillRect(
384
+ -this.planeWidth / 2,
385
+ -this.planeHeight / 2,
386
+ this.planeWidth,
387
+ this.planeHeight,
388
+ this.color
389
+ );
390
+ }
391
+ return;
392
+ }
393
+
394
+ // Check if any self-rotation is applied
395
+ const hasSelfRotation =
396
+ this.selfRotationX !== 0 ||
397
+ this.selfRotationY !== 0 ||
398
+ this.selfRotationZ !== 0;
399
+
400
+ // Project all vertices
401
+ const projectedVertices = this.vertices.map((v) => {
402
+ let vx = v.x;
403
+ let vy = v.y;
404
+ let vz = v.z;
405
+ let nx = v.nx;
406
+ let ny = v.ny;
407
+ let nz = v.nz;
408
+
409
+ // Apply self-rotation
410
+ if (hasSelfRotation) {
411
+ const rotatedPos = this._applySelfRotation(vx, vy, vz);
412
+ vx = rotatedPos.x;
413
+ vy = rotatedPos.y;
414
+ vz = rotatedPos.z;
415
+
416
+ const rotatedNormal = this._applySelfRotation(nx, ny, nz);
417
+ nx = rotatedNormal.x;
418
+ ny = rotatedNormal.y;
419
+ nz = rotatedNormal.z;
420
+ }
421
+
422
+ // Project through camera
423
+ const projected = this.camera.project(
424
+ vx + this.x,
425
+ vy + this.y,
426
+ vz + this.z
427
+ );
428
+
429
+ // Rotate normals through camera rotation
430
+ // Z axis rotation
431
+ if (this.camera.rotationZ !== 0) {
432
+ const cosZ = Math.cos(this.camera.rotationZ);
433
+ const sinZ = Math.sin(this.camera.rotationZ);
434
+ const nx0 = nx;
435
+ const ny0 = ny;
436
+ nx = nx0 * cosZ - ny0 * sinZ;
437
+ ny = nx0 * sinZ + ny0 * cosZ;
438
+ }
439
+
440
+ // Y axis rotation
441
+ const cosY = Math.cos(this.camera.rotationY);
442
+ const sinY = Math.sin(this.camera.rotationY);
443
+ const nx1 = nx * cosY - nz * sinY;
444
+ const nz1 = nx * sinY + nz * cosY;
445
+
446
+ // X axis rotation
447
+ const cosX = Math.cos(this.camera.rotationX);
448
+ const sinX = Math.sin(this.camera.rotationX);
449
+ const ny1 = ny * cosX - nz1 * sinX;
450
+ const nz2 = ny * sinX + nz1 * cosX;
451
+
452
+ return {
453
+ ...projected,
454
+ nx: nx1,
455
+ ny: ny1,
456
+ nz: nz2,
457
+ u: v.u,
458
+ v: v.v,
459
+ };
460
+ });
461
+
462
+ // Calculate average normal for backface culling
463
+ const avgNz =
464
+ (projectedVertices[0].nz +
465
+ projectedVertices[1].nz +
466
+ projectedVertices[2].nz +
467
+ projectedVertices[3].nz) /
468
+ 4;
469
+
470
+ // Backface culling (unless double-sided)
471
+ if (!this.doubleSided && avgNz > 0.1) {
472
+ return; // Face is pointing away from camera
473
+ }
474
+
475
+ // Calculate average depth for z-sorting (used by parent containers)
476
+ const avgZ =
477
+ (projectedVertices[0].z +
478
+ projectedVertices[1].z +
479
+ projectedVertices[2].z +
480
+ projectedVertices[3].z) /
481
+ 4;
482
+
483
+ // Check if any vertex is behind camera
484
+ const behindCamera = projectedVertices.some(
485
+ (v) => v.z < -this.camera.perspective + 10
486
+ );
487
+ if (behindCamera) {
488
+ return;
489
+ }
490
+
491
+ // For WebGL shader rendering, calculate screen bounds
492
+ if (this.useShader && !this.debug) {
493
+ const xs = projectedVertices.map((v) => v.x);
494
+ const ys = projectedVertices.map((v) => v.y);
495
+ const minX = Math.min(...xs);
496
+ const maxX = Math.max(...xs);
497
+ const minY = Math.min(...ys);
498
+ const maxY = Math.max(...ys);
499
+ const screenWidth = maxX - minX;
500
+ const screenHeight = maxY - minY;
501
+ const centerX = (minX + maxX) / 2;
502
+ const centerY = (minY + maxY) / 2;
503
+
504
+ // Get canvas transform for absolute positioning
505
+ const ctx = Painter.ctx;
506
+ const transform = ctx.getTransform();
507
+ const sceneX = transform.e;
508
+ const sceneY = transform.f;
509
+
510
+ ctx.save();
511
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
512
+ const success = this._renderWithShader(
513
+ ctx,
514
+ sceneX + centerX,
515
+ sceneY + centerY,
516
+ screenWidth,
517
+ screenHeight
518
+ );
519
+ ctx.restore();
520
+
521
+ if (success) {
522
+ return;
523
+ }
524
+ // Fall through to Canvas 2D if shader failed
525
+ }
526
+
527
+ // Canvas 2D rendering path
528
+ const ctx = Painter.ctx;
529
+
530
+ // Calculate lighting based on normal direction
531
+ let avgNx =
532
+ (projectedVertices[0].nx +
533
+ projectedVertices[1].nx +
534
+ projectedVertices[2].nx +
535
+ projectedVertices[3].nx) /
536
+ 4;
537
+ let avgNy =
538
+ (projectedVertices[0].ny +
539
+ projectedVertices[1].ny +
540
+ projectedVertices[2].ny +
541
+ projectedVertices[3].ny) /
542
+ 4;
543
+
544
+ // Flip normal for lighting when viewing the back side
545
+ let lightNx = avgNx;
546
+ let lightNy = avgNy;
547
+ let lightNz = avgNz;
548
+ if (this.doubleSided && avgNz > 0) {
549
+ lightNx = -avgNx;
550
+ lightNy = -avgNy;
551
+ lightNz = -avgNz;
552
+ }
553
+ const intensity = this._calculateLighting(lightNx, lightNy, lightNz);
554
+
555
+ // Render as two triangles
556
+ for (const face of this.faces) {
557
+ const v0 = projectedVertices[face[0]];
558
+ const v1 = projectedVertices[face[1]];
559
+ const v2 = projectedVertices[face[2]];
560
+
561
+ if (this.debug) {
562
+ // Wireframe mode
563
+ ctx.beginPath();
564
+ ctx.moveTo(v0.x, v0.y);
565
+ ctx.lineTo(v1.x, v1.y);
566
+ ctx.lineTo(v2.x, v2.y);
567
+ ctx.closePath();
568
+
569
+ if (this.stroke) {
570
+ ctx.strokeStyle = this.stroke;
571
+ ctx.lineWidth = this.lineWidth ?? 1;
572
+ ctx.stroke();
573
+ }
574
+ } else if (this.texture) {
575
+ // Texture mapping (simplified - for accurate perspective would need more complex math)
576
+ this._renderTexturedTriangle(ctx, v0, v1, v2);
577
+ } else if (this.color) {
578
+ // Solid color with lighting
579
+ const faceColor = this._applyLighting(this.color, intensity);
580
+
581
+ ctx.beginPath();
582
+ ctx.moveTo(v0.x, v0.y);
583
+ ctx.lineTo(v1.x, v1.y);
584
+ ctx.lineTo(v2.x, v2.y);
585
+ ctx.closePath();
586
+ ctx.fillStyle = faceColor;
587
+ ctx.fill();
588
+ }
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Render a textured triangle (simplified affine mapping)
594
+ * @param {CanvasRenderingContext2D} ctx - Canvas context
595
+ * @param {Object} v0 - Vertex 0 with x, y, u, v
596
+ * @param {Object} v1 - Vertex 1 with x, y, u, v
597
+ * @param {Object} v2 - Vertex 2 with x, y, u, v
598
+ * @private
599
+ */
600
+ _renderTexturedTriangle(ctx, v0, v1, v2) {
601
+ if (!this.texture || !this.texture.complete) {
602
+ return;
603
+ }
604
+
605
+ const img = this.texture;
606
+ const imgW = img.width;
607
+ const imgH = img.height;
608
+
609
+ // Use affine texture mapping (works well for roughly planar surfaces)
610
+ // Transform from UV space to screen space
611
+
612
+ // Source triangle in texture coordinates
613
+ const su0 = v0.u * imgW;
614
+ const sv0 = v0.v * imgH;
615
+ const su1 = v1.u * imgW;
616
+ const sv1 = v1.v * imgH;
617
+ const su2 = v2.u * imgW;
618
+ const sv2 = v2.v * imgH;
619
+
620
+ // Destination triangle in screen coordinates
621
+ const dx0 = v0.x;
622
+ const dy0 = v0.y;
623
+ const dx1 = v1.x;
624
+ const dy1 = v1.y;
625
+ const dx2 = v2.x;
626
+ const dy2 = v2.y;
627
+
628
+ // Calculate affine transformation matrix
629
+ // This maps from texture space to screen space
630
+ const det =
631
+ (su1 - su0) * (sv2 - sv0) - (su2 - su0) * (sv1 - sv0);
632
+
633
+ if (Math.abs(det) < 0.0001) {
634
+ return; // Degenerate triangle
635
+ }
636
+
637
+ const a =
638
+ ((dx1 - dx0) * (sv2 - sv0) - (dx2 - dx0) * (sv1 - sv0)) / det;
639
+ const b =
640
+ ((dx2 - dx0) * (su1 - su0) - (dx1 - dx0) * (su2 - su0)) / det;
641
+ const c = dx0 - a * su0 - b * sv0;
642
+ const d =
643
+ ((dy1 - dy0) * (sv2 - sv0) - (dy2 - dy0) * (sv1 - sv0)) / det;
644
+ const e =
645
+ ((dy2 - dy0) * (su1 - su0) - (dy1 - dy0) * (su2 - su0)) / det;
646
+ const f = dy0 - d * su0 - e * sv0;
647
+
648
+ ctx.save();
649
+
650
+ // Clip to triangle
651
+ ctx.beginPath();
652
+ ctx.moveTo(dx0, dy0);
653
+ ctx.lineTo(dx1, dy1);
654
+ ctx.lineTo(dx2, dy2);
655
+ ctx.closePath();
656
+ ctx.clip();
657
+
658
+ // Apply transformation and draw image
659
+ ctx.setTransform(a, d, b, e, c, f);
660
+ ctx.drawImage(img, 0, 0);
661
+
662
+ ctx.restore();
663
+ }
664
+
665
+ /**
666
+ * Get the center point in world coordinates
667
+ * @returns {{x: number, y: number, z: number}}
668
+ */
669
+ getCenter() {
670
+ return { x: this.x, y: this.y, z: this.z };
671
+ }
672
+
673
+ /**
674
+ * Get bounding box for the plane
675
+ * @returns {{x: number, y: number, width: number, height: number}}
676
+ */
677
+ getBounds() {
678
+ // Simple bounds based on plane dimensions
679
+ // Note: This doesn't account for rotation
680
+ return {
681
+ x: this.x - this.planeWidth / 2,
682
+ y: this.y - this.planeHeight / 2,
683
+ width: this.planeWidth,
684
+ height: this.planeHeight,
685
+ };
686
+ }
687
+ }