canvas-js-3d 0.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.
- package/README.md +15 -0
- package/package.json +30 -0
- package/src/core/engine.js +108 -0
- package/src/core/mesh.js +32 -0
- package/src/core/sceneObject.js +45 -0
- package/src/index.js +26 -0
- package/src/math/transform.js +43 -0
- package/src/math/vector2.js +40 -0
- package/src/math/vector3.js +115 -0
- package/src/rendering/camera.js +87 -0
- package/src/rendering/renderer.js +58 -0
- package/src/wavefront-loading/index.js +9 -0
- package/src/wavefront-loading/wavefront-file-loader.js +64 -0
- package/src/wavefront-loading/wavefront-lexer.js +193 -0
- package/src/wavefront-loading/wavefront-mesh-converter.js +54 -0
- package/src/wavefront-loading/wavefront-parser.js +178 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Canvas JS 3D is a lightweight 3D graphics library that uses the Canvas API for wireframe rendering. The library has zero dependencies and is written in purely vanilla JavaScript.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
Features
|
|
6
|
+
|
|
7
|
+
* Pure JavaScript - No external dependencies
|
|
8
|
+
* ES6 Modules - Clean, modular architecture
|
|
9
|
+
* Wavefront OBJ Loading - Import 3D models from .obj files
|
|
10
|
+
* Wireframe Rendering - Canvas 2D-based edge rendering
|
|
11
|
+
* Transform System - Position, rotation, and scale
|
|
12
|
+
* Frame Update Hook - Per-frame client logic with delta-time for framerate-independent animations
|
|
13
|
+
* Scene Object System - Manage multiple meshes with independent transforms
|
|
14
|
+
* Mobile Responsive Demo - Touch-friendly demo with responsive layout
|
|
15
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "canvas-js-3d",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Canvas JS 3D is a lightweight 3D graphics library that uses the Canvas API for wireframe rendering.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./wavefront-loading": "./src/wavefront-loading/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "npx http-server -p 8080 -c-1"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"3d",
|
|
19
|
+
"graphics",
|
|
20
|
+
"canvas",
|
|
21
|
+
"wireframe",
|
|
22
|
+
"obj",
|
|
23
|
+
"wavefront"
|
|
24
|
+
],
|
|
25
|
+
"author": {
|
|
26
|
+
"name": "Sebastian Bathrick",
|
|
27
|
+
"email": "sebastianbathrick@gmail.com"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Renderer } from '../rendering/renderer.js';
|
|
2
|
+
import { Camera } from '../rendering/camera.js';
|
|
3
|
+
import { Vector2 } from '../math/vector2.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The main engine that manages the render loop, camera, and scene objects.
|
|
7
|
+
*/
|
|
8
|
+
export class Engine {
|
|
9
|
+
/**
|
|
10
|
+
* Creates a new Engine.
|
|
11
|
+
* @param {HTMLCanvasElement} canvas - The canvas element to render to.
|
|
12
|
+
* @param {string} fgColor - The foreground/wireframe color.
|
|
13
|
+
* @param {string} bgColor - The background/clear color.
|
|
14
|
+
*/
|
|
15
|
+
constructor(canvas, fgColor, bgColor) {
|
|
16
|
+
/** @type {Renderer} */
|
|
17
|
+
this.renderer = new Renderer(canvas, fgColor, bgColor);
|
|
18
|
+
/** @type {Camera} */
|
|
19
|
+
this.camera = new Camera(new Vector2(canvas.width, canvas.height));
|
|
20
|
+
/** @type {SceneObject[]} */
|
|
21
|
+
this.sceneObjects = [];
|
|
22
|
+
/** @type {boolean} */
|
|
23
|
+
this.running = false;
|
|
24
|
+
/** @type {number|null} */
|
|
25
|
+
this.lastFrameTime = null;
|
|
26
|
+
/**
|
|
27
|
+
* Callback invoked each frame with delta time.
|
|
28
|
+
* @type {((deltaTime: number) => void)|null}
|
|
29
|
+
*/
|
|
30
|
+
this.onUpdate = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Internal frame update loop. Calculates delta time, calls onUpdate, and renders.
|
|
35
|
+
* @private
|
|
36
|
+
*/
|
|
37
|
+
frameUpdate() {
|
|
38
|
+
if (!this.running)
|
|
39
|
+
return;
|
|
40
|
+
|
|
41
|
+
const now = performance.now();
|
|
42
|
+
const deltaTime = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
|
|
43
|
+
this.lastFrameTime = now;
|
|
44
|
+
|
|
45
|
+
if (this.onUpdate)
|
|
46
|
+
this.onUpdate(deltaTime);
|
|
47
|
+
|
|
48
|
+
this.renderer.clear();
|
|
49
|
+
this.renderAllObjects();
|
|
50
|
+
|
|
51
|
+
requestAnimationFrame(() => this.frameUpdate());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Renders all scene objects as wireframes.
|
|
56
|
+
* @private
|
|
57
|
+
*/
|
|
58
|
+
renderAllObjects() {
|
|
59
|
+
for (const obj of this.sceneObjects) {
|
|
60
|
+
const projectedFaces = this.camera.projectSceneObject(obj);
|
|
61
|
+
|
|
62
|
+
for (const face of projectedFaces) {
|
|
63
|
+
const positions = face.screenPositions;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < positions.length; i++) {
|
|
66
|
+
this.renderer.renderEdge(
|
|
67
|
+
positions[i],
|
|
68
|
+
positions[(i + 1) % positions.length]
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adds a scene object to be rendered.
|
|
77
|
+
* @param {SceneObject} sceneObject - The object to add.
|
|
78
|
+
*/
|
|
79
|
+
addSceneObject(sceneObject) {
|
|
80
|
+
this.sceneObjects.push(sceneObject);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Removes a scene object from rendering.
|
|
85
|
+
* @param {SceneObject} sceneObject - The object to remove.
|
|
86
|
+
*/
|
|
87
|
+
removeSceneObject(sceneObject) {
|
|
88
|
+
const idx = this.sceneObjects.indexOf(sceneObject);
|
|
89
|
+
|
|
90
|
+
if (idx !== -1)
|
|
91
|
+
this.sceneObjects.splice(idx, 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Starts the render loop.
|
|
96
|
+
*/
|
|
97
|
+
start() {
|
|
98
|
+
this.running = true;
|
|
99
|
+
this.frameUpdate();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Stops the render loop.
|
|
104
|
+
*/
|
|
105
|
+
stop() {
|
|
106
|
+
this.running = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/core/mesh.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a 3D mesh with vertices and face index definitions.
|
|
3
|
+
*/
|
|
4
|
+
export class Mesh {
|
|
5
|
+
/**
|
|
6
|
+
* Creates a new Mesh.
|
|
7
|
+
* @param {Vector3[]} vertices - Array of vertex positions.
|
|
8
|
+
* @param {number[][]} faceIndices - Array of faces, each face is an array of vertex indices.
|
|
9
|
+
*/
|
|
10
|
+
constructor(vertices, faceIndices) {
|
|
11
|
+
/** @type {Vector3[]} */
|
|
12
|
+
this.vertices = vertices;
|
|
13
|
+
/** @type {number[][]} */
|
|
14
|
+
this.faceIndices = faceIndices;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gets the vertices of the mesh.
|
|
19
|
+
* @returns {Vector3[]} The vertex array.
|
|
20
|
+
*/
|
|
21
|
+
getVertices() {
|
|
22
|
+
return this.vertices;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Gets the face indices of the mesh.
|
|
27
|
+
* @returns {number[][]} Array of faces, each containing vertex indices.
|
|
28
|
+
*/
|
|
29
|
+
getFaceIndices() {
|
|
30
|
+
return this.faceIndices;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An object with a position, rotation, scale, and mesh in the scene.
|
|
3
|
+
*/
|
|
4
|
+
export class SceneObject {
|
|
5
|
+
/**
|
|
6
|
+
* Creates a new SceneObject.
|
|
7
|
+
* @param {Mesh} mesh - The mesh geometry.
|
|
8
|
+
* @param {Transform} transform - The position, rotation, and scale.
|
|
9
|
+
*/
|
|
10
|
+
constructor(mesh, transform) {
|
|
11
|
+
/** @type {Mesh} */
|
|
12
|
+
this.mesh = mesh;
|
|
13
|
+
/** @type {Transform} */
|
|
14
|
+
this.transform = transform;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gets the mesh geometry.
|
|
19
|
+
* @returns {Mesh} The mesh.
|
|
20
|
+
*/
|
|
21
|
+
getMesh() {
|
|
22
|
+
return this.mesh;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Gets the transform.
|
|
27
|
+
* @returns {Transform} The transform.
|
|
28
|
+
*/
|
|
29
|
+
getTransform() {
|
|
30
|
+
return this.transform;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gets all vertices transformed to scene's world space.
|
|
35
|
+
* Applies transformations in order: scale → rotate → translate.
|
|
36
|
+
* @returns {Vector3[]} Array of transformed vertex positions.
|
|
37
|
+
*/
|
|
38
|
+
getSceneVertices() {
|
|
39
|
+
return this.mesh.vertices.map(v =>
|
|
40
|
+
v.getScaled(this.transform.scale)
|
|
41
|
+
.getRotatedXZ(this.transform.rotation)
|
|
42
|
+
.getTranslated(this.transform.position)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canvas-js-3d - A lightweight 3D graphics library that uses the Canvas API for wireframe rendering.
|
|
3
|
+
* @module canvas-js-3d
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Core math
|
|
7
|
+
export { Vector2 } from './math/vector2.js';
|
|
8
|
+
export { Vector3 } from './math/vector3.js';
|
|
9
|
+
export { Transform } from './math/transform.js';
|
|
10
|
+
|
|
11
|
+
// Scene components
|
|
12
|
+
export { Mesh } from './core/mesh.js';
|
|
13
|
+
export { SceneObject } from './core/sceneObject.js';
|
|
14
|
+
|
|
15
|
+
// Rendering
|
|
16
|
+
export { Camera } from './rendering/camera.js';
|
|
17
|
+
export { Renderer } from './rendering/renderer.js';
|
|
18
|
+
|
|
19
|
+
// Engine
|
|
20
|
+
export { Engine } from './core/engine.js';
|
|
21
|
+
|
|
22
|
+
// OBJ loading
|
|
23
|
+
export { WavefrontMeshConverter } from './wavefront-loading/wavefront-mesh-converter.js';
|
|
24
|
+
export { WavefrontFileLoader } from './wavefront-loading/wavefront-file-loader.js';
|
|
25
|
+
export { WavefrontLexer } from './wavefront-loading/wavefront-lexer.js';
|
|
26
|
+
export { WavefrontParser } from './wavefront-loading/wavefront-parser.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents the position, rotation, and scale of an object in 3D space.
|
|
3
|
+
*/
|
|
4
|
+
export class Transform {
|
|
5
|
+
/**
|
|
6
|
+
* Creates a new Transform.
|
|
7
|
+
* @param {Vector3} position - The position in 3D space.
|
|
8
|
+
* @param {number} rotation - The rotation angle in radians (XZ plane only).
|
|
9
|
+
* @param {number} scale - The uniform scale factor.
|
|
10
|
+
*/
|
|
11
|
+
constructor(position, rotation, scale) {
|
|
12
|
+
/** @type {Vector3} */
|
|
13
|
+
this.position = position;
|
|
14
|
+
/** @type {number} */
|
|
15
|
+
this.rotation = rotation;
|
|
16
|
+
/** @type {number} */
|
|
17
|
+
this.scale = scale;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Translates the position by the given vector.
|
|
22
|
+
* @param {Vector3} translation - The translation to apply.
|
|
23
|
+
*/
|
|
24
|
+
translate(translation) {
|
|
25
|
+
this.position = this.position.getTranslated(translation);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rotates around the Y axis by the given angle.
|
|
30
|
+
* @param {number} angle - The angle to rotate by in radians.
|
|
31
|
+
*/
|
|
32
|
+
rotateXZ(angle) {
|
|
33
|
+
this.rotation += angle;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Multiplies the scale by a scalar value.
|
|
38
|
+
* @param {number} scalar - The scalar to multiply by.
|
|
39
|
+
*/
|
|
40
|
+
scaleBy(scalar) {
|
|
41
|
+
this.scale *= scalar;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A 2D vector class for screen coordinates and 2D math operations.
|
|
3
|
+
* Methods return new instances (immutable style).
|
|
4
|
+
*/
|
|
5
|
+
export class Vector2 {
|
|
6
|
+
/**
|
|
7
|
+
* Creates a new Vector2.
|
|
8
|
+
* @param {number} x - The x component.
|
|
9
|
+
* @param {number} y - The y component.
|
|
10
|
+
*/
|
|
11
|
+
constructor(x, y) {
|
|
12
|
+
this.x = x;
|
|
13
|
+
this.y = y;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Checks if both x and y are zero.
|
|
18
|
+
* @returns {boolean} True if the vector is zero.
|
|
19
|
+
*/
|
|
20
|
+
isZero() {
|
|
21
|
+
return this.x === 0 && this.y === 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gets a unit vector pointing in the same direction.
|
|
26
|
+
* @returns {Vector2} A new normalized vector.
|
|
27
|
+
*/
|
|
28
|
+
getNormalized() {
|
|
29
|
+
const mag = Math.sqrt(this.x * this.x + this.y * this.y);
|
|
30
|
+
return new Vector2(this.x / mag, this.y / mag);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gets the length of the vector.
|
|
35
|
+
* @returns {number} The magnitude.
|
|
36
|
+
*/
|
|
37
|
+
getMagnitude() {
|
|
38
|
+
return Math.sqrt(this.x * this.x + this.y * this.y);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A 3D vector class for positions and directions in 3D space.
|
|
3
|
+
* Methods return new instances (immutable style).
|
|
4
|
+
*/
|
|
5
|
+
export class Vector3 {
|
|
6
|
+
/**
|
|
7
|
+
* Creates a new Vector3.
|
|
8
|
+
* @param {number} x - The x component.
|
|
9
|
+
* @param {number} y - The y component.
|
|
10
|
+
* @param {number} z - The z component.
|
|
11
|
+
*/
|
|
12
|
+
constructor(x, y, z) {
|
|
13
|
+
this.x = x;
|
|
14
|
+
this.y = y;
|
|
15
|
+
this.z = z;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The zero vector (0, 0, 0).
|
|
20
|
+
* @type {Vector3}
|
|
21
|
+
*/
|
|
22
|
+
static zero = Object.freeze(new Vector3(0, 0, 0));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The one vector (1, 1, 1).
|
|
26
|
+
* @type {Vector3}
|
|
27
|
+
*/
|
|
28
|
+
static one = Object.freeze(new Vector3(1, 1, 1));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The left direction (-1, 0, 0).
|
|
32
|
+
* @type {Vector3}
|
|
33
|
+
*/
|
|
34
|
+
static left = Object.freeze(new Vector3(-1, 0, 0));
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The right direction (1, 0, 0).
|
|
38
|
+
* @type {Vector3}
|
|
39
|
+
*/
|
|
40
|
+
static right = Object.freeze(new Vector3(1, 0, 0));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The up direction (0, 1, 0).
|
|
44
|
+
* @type {Vector3}
|
|
45
|
+
*/
|
|
46
|
+
static up = Object.freeze(new Vector3(0, 1, 0));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The down direction (0, -1, 0).
|
|
50
|
+
* @type {Vector3}
|
|
51
|
+
*/
|
|
52
|
+
static down = Object.freeze(new Vector3(0, -1, 0));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns a new vector translated by the given direction.
|
|
56
|
+
* @param {Vector3} dir - The direction to translate by.
|
|
57
|
+
* @returns {Vector3} A new translated vector.
|
|
58
|
+
*/
|
|
59
|
+
getTranslated(dir) {
|
|
60
|
+
return new Vector3(this.x + dir.x, this.y + dir.y, this.z + dir.z);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns a new vector rotated around the Y axis (XZ plane rotation).
|
|
65
|
+
* @param {number} angle - The rotation angle in radians.
|
|
66
|
+
* @returns {Vector3} A new rotated vector.
|
|
67
|
+
*/
|
|
68
|
+
getRotatedXZ(angle) {
|
|
69
|
+
const cos = Math.cos(angle);
|
|
70
|
+
const sin = Math.sin(angle);
|
|
71
|
+
|
|
72
|
+
return new Vector3(
|
|
73
|
+
this.x * cos - this.z * sin,
|
|
74
|
+
this.y,
|
|
75
|
+
this.x * sin + this.z * cos
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns a new vector scaled by a scalar value.
|
|
81
|
+
* @param {number} scalar - The scalar to multiply by.
|
|
82
|
+
* @returns {Vector3} A new scaled vector.
|
|
83
|
+
*/
|
|
84
|
+
getScaled(scalar) {
|
|
85
|
+
return new Vector3(this.x * scalar, this.y * scalar, this.z * scalar);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checks if all components are zero.
|
|
90
|
+
* @returns {boolean} True if the vector is zero.
|
|
91
|
+
*/
|
|
92
|
+
isZero() {
|
|
93
|
+
return this.x === 0 && this.y === 0 && this.z === 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Gets a unit vector pointing in the same direction.
|
|
98
|
+
* @returns {Vector3} A new normalized vector, or zero vector if magnitude is 0.
|
|
99
|
+
*/
|
|
100
|
+
getNormalized() {
|
|
101
|
+
const mag = Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
|
|
102
|
+
|
|
103
|
+
if (mag === 0) return Vector3.zero;
|
|
104
|
+
|
|
105
|
+
return new Vector3(this.x / mag, this.y / mag, this.z / mag);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gets the length of the vector.
|
|
110
|
+
* @returns {number} The magnitude.
|
|
111
|
+
*/
|
|
112
|
+
getMagnitude() {
|
|
113
|
+
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Vector2 } from '../math/vector2.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Projects 3D scene coordinates to 2D screen coordinates.
|
|
5
|
+
*/
|
|
6
|
+
export class Camera {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a new Camera.
|
|
9
|
+
* @param {Vector2} screenSize - The canvas dimensions (width, height).
|
|
10
|
+
*/
|
|
11
|
+
constructor(screenSize) {
|
|
12
|
+
/** @type {Vector2} */
|
|
13
|
+
this.screenSize = screenSize;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Projects all faces of a scene object to screen coordinates.
|
|
18
|
+
* @param {SceneObject} sceneObject - The scene object to project.
|
|
19
|
+
* @returns {{screenPositions: Vector2[]}[]} Array of projected faces with screen positions.
|
|
20
|
+
*/
|
|
21
|
+
projectSceneObject(sceneObject) {
|
|
22
|
+
const sceneVerts = sceneObject.getSceneVertices();
|
|
23
|
+
const projectedFaces = [];
|
|
24
|
+
|
|
25
|
+
// Map vertices to their associated face indices
|
|
26
|
+
for (const face of sceneObject.getMesh().getFaceIndices()) {
|
|
27
|
+
const faceVerts = face.map(idx => sceneVerts[idx]);
|
|
28
|
+
const screenPositions = this.getScreenPositions(faceVerts);
|
|
29
|
+
|
|
30
|
+
projectedFaces.push({ screenPositions });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return projectedFaces;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Converts a normalized screen position to a screen position based on the screen's/canvas's size.
|
|
38
|
+
* @param {Vector2} normScreenPos - The normalized screen position of a point
|
|
39
|
+
* @returns {Vector2} The screen position of the point relative to the screen's/canvas's size
|
|
40
|
+
*/
|
|
41
|
+
getScaledScreenPosition(normScreenPos) {
|
|
42
|
+
/* Currently (0, 0) is the top left corner of the canvas
|
|
43
|
+
|
|
44
|
+
* Convert from normalized coordinates to respective screen coordinates based on the canvas size:
|
|
45
|
+
|
|
46
|
+
* (0, 0) -> (canvas width / 2, canvas height / 2) = Center of the canvas
|
|
47
|
+
* (-1, 1) -> (canvas width / 2, -(canvas height / 2)) = Top right corner of the canvas
|
|
48
|
+
* (1, -1) -> (-(canvas width / 2), canvas height / 2) = Bottom left corner of the canvas
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
return new Vector2(
|
|
52
|
+
(normScreenPos.x + 1) / 2 * this.screenSize.x,
|
|
53
|
+
(1 - (normScreenPos.y + 1) / 2) * this.screenSize.y);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Converts a point's 3D position in the scene to a normalized screen position by dividing x and y by z individually.
|
|
58
|
+
* @param {Vector3} scenePos - The 3D position of a point in the scene
|
|
59
|
+
* @returns {Vector2} The normalized screen position of the point
|
|
60
|
+
*/
|
|
61
|
+
getNormalizedScreenPosition(scenePos) {
|
|
62
|
+
/*
|
|
63
|
+
* If > 0 the point is in front of the camera, if <= 0 the point is not visible, because the divisor
|
|
64
|
+
* would be zero. Thus it is in the same 3D position as the camera. As z increases the 3D point moves
|
|
65
|
+
* further away from the camera. */
|
|
66
|
+
|
|
67
|
+
return new Vector2(scenePos.x / scenePos.z, scenePos.y / scenePos.z);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Projects a 3D position to screen pixel coordinates.
|
|
72
|
+
* @param {Vector3} scenePos - The 3D position in scene/world space.
|
|
73
|
+
* @returns {Vector2} The position in pixel coordinates.
|
|
74
|
+
*/
|
|
75
|
+
getVertexScreenPos(scenePos) {
|
|
76
|
+
return this.getScaledScreenPosition(this.getNormalizedScreenPosition(scenePos));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Projects multiple 3D positions to screen pixel coordinates.
|
|
81
|
+
* @param {Vector3[]} vertices - Array of 3D positions.
|
|
82
|
+
* @returns {Vector2[]} Array of screen positions in pixels.
|
|
83
|
+
*/
|
|
84
|
+
getScreenPositions(vertices) {
|
|
85
|
+
return vertices.map(v => this.getVertexScreenPos(v));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles drawing wireframe graphics to a Canvas 2D context.
|
|
3
|
+
*/
|
|
4
|
+
export class Renderer {
|
|
5
|
+
/**
|
|
6
|
+
* Creates a new Renderer.
|
|
7
|
+
* @param {HTMLCanvasElement} canvas - The canvas element to render to.
|
|
8
|
+
* @param {string} fgColor - The foreground/stroke color (e.g., 'green', '#00ff00').
|
|
9
|
+
* @param {string} bgColor - The background/clear color (e.g., 'black', '#000000').
|
|
10
|
+
*/
|
|
11
|
+
constructor(canvas, fgColor, bgColor) {
|
|
12
|
+
/** @type {HTMLCanvasElement} */
|
|
13
|
+
this.canvas = canvas;
|
|
14
|
+
/** @type {CanvasRenderingContext2D} */
|
|
15
|
+
this.ctx = canvas.getContext('2d');
|
|
16
|
+
/** @type {string} */
|
|
17
|
+
this.fgColor = fgColor;
|
|
18
|
+
/** @type {string} */
|
|
19
|
+
this.bgColor = bgColor;
|
|
20
|
+
/** @type {number} */
|
|
21
|
+
this.pointSize = 20;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Clears the canvas with the background color.
|
|
26
|
+
*/
|
|
27
|
+
clear() {
|
|
28
|
+
this.ctx.fillStyle = this.bgColor;
|
|
29
|
+
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Renders a line between two screen positions.
|
|
34
|
+
* @param {Vector2} startVector2 - The start position in screen coordinates.
|
|
35
|
+
* @param {Vector2} endVector2 - The end position in screen coordinates.
|
|
36
|
+
*/
|
|
37
|
+
renderEdge(startVector2, endVector2) {
|
|
38
|
+
this.ctx.strokeStyle = this.fgColor;
|
|
39
|
+
this.ctx.beginPath();
|
|
40
|
+
this.ctx.moveTo(startVector2.x, startVector2.y);
|
|
41
|
+
this.ctx.lineTo(endVector2.x, endVector2.y);
|
|
42
|
+
this.ctx.stroke();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders a point as a square at the given screen position.
|
|
47
|
+
* @param {Vector2} vector2 - The position in screen coordinates.
|
|
48
|
+
*/
|
|
49
|
+
renderPoint(vector2) {
|
|
50
|
+
this.ctx.fillStyle = this.fgColor;
|
|
51
|
+
this.ctx.fillRect(
|
|
52
|
+
vector2.x - this.pointSize / 2,
|
|
53
|
+
vector2.y - this.pointSize / 2,
|
|
54
|
+
this.pointSize,
|
|
55
|
+
this.pointSize
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wavefront OBJ file loading module.
|
|
3
|
+
* @module canvas-js-3d/wavefront-loading
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { WavefrontMeshConverter } from './wavefront-mesh-converter.js';
|
|
7
|
+
export { WavefrontFileLoader } from './wavefront-file-loader.js';
|
|
8
|
+
export { WavefrontLexer } from './wavefront-lexer.js';
|
|
9
|
+
export { WavefrontParser } from './wavefront-parser.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles loading OBJ file content from various sources (URL, File, file dialog).
|
|
3
|
+
*/
|
|
4
|
+
export class WavefrontFileLoader {
|
|
5
|
+
/**
|
|
6
|
+
* Loads OBJ file content from an HTTP URL.
|
|
7
|
+
* @param {string} url - The URL to fetch the OBJ file from.
|
|
8
|
+
* @returns {Promise<string>} The text content of the OBJ file.
|
|
9
|
+
* @throws {Error} If the fetch request fails.
|
|
10
|
+
*/
|
|
11
|
+
static async loadFromUrl(url) {
|
|
12
|
+
const response = await fetch(url);
|
|
13
|
+
|
|
14
|
+
if (!response.ok)
|
|
15
|
+
throw new Error(`Failed to load OBJ file: ${response.status} ${response.statusText}`);
|
|
16
|
+
|
|
17
|
+
return await response.text();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Loads OBJ file content from a File object.
|
|
22
|
+
* @param {File} file - The File object from an input element.
|
|
23
|
+
* @returns {Promise<string>} The text content of the OBJ file.
|
|
24
|
+
* @throws {Error} If reading the file fails.
|
|
25
|
+
*/
|
|
26
|
+
static async loadFromFile(file) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const reader = new FileReader();
|
|
29
|
+
|
|
30
|
+
reader.onload = () => resolve(reader.result);
|
|
31
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
32
|
+
|
|
33
|
+
reader.readAsText(file);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Opens a file dialog for the user to select an OBJ file.
|
|
39
|
+
* @returns {Promise<string>} The text content of the selected OBJ file.
|
|
40
|
+
* @throws {Error} If no file is selected or reading fails.
|
|
41
|
+
*/
|
|
42
|
+
static async loadFromFileDialog() {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const input = document.createElement('input');
|
|
45
|
+
input.type = 'file';
|
|
46
|
+
input.accept = '.obj';
|
|
47
|
+
|
|
48
|
+
input.onchange = async () => {
|
|
49
|
+
if (input.files && input.files[0]) {
|
|
50
|
+
try {
|
|
51
|
+
const text = await this.loadFromFile(input.files[0]);
|
|
52
|
+
resolve(text);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
reject(err);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
reject(new Error('No file selected'));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
input.click();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenizes OBJ file content into a stream of tokens.
|
|
3
|
+
* Produces tokens of type: KEYWORD, NUMBER, SLASH, NEWLINE, EOF.
|
|
4
|
+
*/
|
|
5
|
+
export class WavefrontLexer {
|
|
6
|
+
/**
|
|
7
|
+
* Creates a new WavefrontLexer.
|
|
8
|
+
* @param {string} source - The OBJ file content to tokenize.
|
|
9
|
+
*/
|
|
10
|
+
constructor(source) {
|
|
11
|
+
/** @type {string} */
|
|
12
|
+
this.source = source;
|
|
13
|
+
/** @type {Array<{type: string, value: *}>} */
|
|
14
|
+
this.tokens = [];
|
|
15
|
+
/** @type {number} */
|
|
16
|
+
this.start = 0;
|
|
17
|
+
/** @type {number} */
|
|
18
|
+
this.current = 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tokenizes all content and returns the token array.
|
|
23
|
+
* @returns {Array<{type: string, value: *}>} Array of token objects.
|
|
24
|
+
*/
|
|
25
|
+
lexTokens() {
|
|
26
|
+
while (!this.isAtEnd()) {
|
|
27
|
+
this.start = this.current;
|
|
28
|
+
this.lexToken();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.tokens.push({ type: 'EOF', value: null });
|
|
32
|
+
return this.tokens;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lexes a single token from the current position.
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
lexToken() {
|
|
40
|
+
const c = this.advance();
|
|
41
|
+
|
|
42
|
+
// Skip whitespace (except newlines)
|
|
43
|
+
if (c === ' ' || c === '\t' || c === '\r')
|
|
44
|
+
return;
|
|
45
|
+
|
|
46
|
+
// Newline
|
|
47
|
+
if (c === '\n') {
|
|
48
|
+
this.tokens.push({ type: 'NEWLINE', value: '\n' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Comment - skip to end of line
|
|
53
|
+
if (c === '#') {
|
|
54
|
+
while (this.peek() !== '\n' && !this.isAtEnd())
|
|
55
|
+
this.advance();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Slash (for face format like 1/2/3)
|
|
60
|
+
if (c === '/') {
|
|
61
|
+
this.tokens.push({ type: 'SLASH', value: '/' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Number (including negative)
|
|
66
|
+
if (this.isDigit(c) || (c === '-' && this.isDigit(this.peek()))) {
|
|
67
|
+
this.lexNumber();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Keyword/identifier
|
|
72
|
+
if (this.isAlpha(c)) {
|
|
73
|
+
this.lexKeyword();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lexes a number token (integer, float, or scientific notation).
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
lexNumber() {
|
|
83
|
+
// Consume digits before decimal
|
|
84
|
+
while (this.isDigit(this.peek()))
|
|
85
|
+
this.advance();
|
|
86
|
+
|
|
87
|
+
// Look for decimal part
|
|
88
|
+
if (this.peek() === '.' && this.isDigit(this.peekNext())) {
|
|
89
|
+
this.advance(); // consume '.'
|
|
90
|
+
|
|
91
|
+
while (this.isDigit(this.peek()))
|
|
92
|
+
this.advance();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle scientific notation (e.g., 1.5e-10)
|
|
96
|
+
if (this.peek() === 'e' || this.peek() === 'E') {
|
|
97
|
+
this.advance(); // consume 'e'
|
|
98
|
+
|
|
99
|
+
if (this.peek() === '+' || this.peek() === '-')
|
|
100
|
+
this.advance(); // consume sign
|
|
101
|
+
|
|
102
|
+
while (this.isDigit(this.peek()))
|
|
103
|
+
this.advance();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const value = parseFloat(this.source.substring(this.start, this.current));
|
|
107
|
+
this.tokens.push({ type: 'NUMBER', value: value });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Lexes a keyword token.
|
|
112
|
+
* @private
|
|
113
|
+
*/
|
|
114
|
+
lexKeyword() {
|
|
115
|
+
while (this.isAlphaNumeric(this.peek()))
|
|
116
|
+
this.advance();
|
|
117
|
+
|
|
118
|
+
const value = this.source.substring(this.start, this.current);
|
|
119
|
+
this.tokens.push({ type: 'KEYWORD', value: value });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the current character and advances the position.
|
|
124
|
+
* @returns {string} The current character.
|
|
125
|
+
* @private
|
|
126
|
+
*/
|
|
127
|
+
advance() {
|
|
128
|
+
return this.source.charAt(this.current++);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns the current character without advancing.
|
|
133
|
+
* @returns {string} The current character, or '\0' if at end.
|
|
134
|
+
* @private
|
|
135
|
+
*/
|
|
136
|
+
peek() {
|
|
137
|
+
if (this.isAtEnd())
|
|
138
|
+
return '\0';
|
|
139
|
+
|
|
140
|
+
return this.source.charAt(this.current);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Returns the next character without advancing.
|
|
145
|
+
* @returns {string} The next character, or '\0' if at end.
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
peekNext() {
|
|
149
|
+
if (this.current + 1 >= this.source.length)
|
|
150
|
+
return '\0';
|
|
151
|
+
|
|
152
|
+
return this.source.charAt(this.current + 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Checks if we've reached the end of the source.
|
|
157
|
+
* @returns {boolean} True if at end.
|
|
158
|
+
* @private
|
|
159
|
+
*/
|
|
160
|
+
isAtEnd() {
|
|
161
|
+
return this.current >= this.source.length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Checks if a character is a digit (0-9).
|
|
166
|
+
* @param {string} c - The character to check.
|
|
167
|
+
* @returns {boolean} True if digit.
|
|
168
|
+
* @private
|
|
169
|
+
*/
|
|
170
|
+
isDigit(c) {
|
|
171
|
+
return c >= '0' && c <= '9';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Checks if a character is alphabetic (a-z, A-Z, _).
|
|
176
|
+
* @param {string} c - The character to check.
|
|
177
|
+
* @returns {boolean} True if alphabetic.
|
|
178
|
+
* @private
|
|
179
|
+
*/
|
|
180
|
+
isAlpha(c) {
|
|
181
|
+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Checks if a character is alphanumeric.
|
|
186
|
+
* @param {string} c - The character to check.
|
|
187
|
+
* @returns {boolean} True if alphanumeric.
|
|
188
|
+
* @private
|
|
189
|
+
*/
|
|
190
|
+
isAlphaNumeric(c) {
|
|
191
|
+
return this.isAlpha(c) || this.isDigit(c);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { WavefrontFileLoader } from './wavefront-file-loader.js';
|
|
2
|
+
import { WavefrontLexer } from './wavefront-lexer.js';
|
|
3
|
+
import { WavefrontParser } from './wavefront-parser.js';
|
|
4
|
+
import { Mesh } from '../core/mesh.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* High-level API for loading OBJ files and converting them to Mesh objects.
|
|
8
|
+
* Orchestrates the file loading, lexing, and parsing pipeline.
|
|
9
|
+
*/
|
|
10
|
+
export class WavefrontMeshConverter {
|
|
11
|
+
/**
|
|
12
|
+
* Loads an OBJ file from a URL and converts it to a Mesh.
|
|
13
|
+
* @param {string} url - The URL to fetch the OBJ file from.
|
|
14
|
+
* @returns {Promise<Mesh>} The loaded mesh.
|
|
15
|
+
*/
|
|
16
|
+
static async fromUrl(url) {
|
|
17
|
+
const text = await WavefrontFileLoader.loadFromUrl(url);
|
|
18
|
+
return this.fromText(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Loads an OBJ file from a File object and converts it to a Mesh.
|
|
23
|
+
* @param {File} file - The File object from an input element.
|
|
24
|
+
* @returns {Promise<Mesh>} The loaded mesh.
|
|
25
|
+
*/
|
|
26
|
+
static async fromFile(file) {
|
|
27
|
+
const text = await WavefrontFileLoader.loadFromFile(file);
|
|
28
|
+
return this.fromText(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Opens a file dialog and converts the selected OBJ file to a Mesh.
|
|
33
|
+
* @returns {Promise<Mesh>} The loaded mesh.
|
|
34
|
+
*/
|
|
35
|
+
static async fromFileDialog() {
|
|
36
|
+
const text = await WavefrontFileLoader.loadFromFileDialog();
|
|
37
|
+
return this.fromText(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Converts OBJ text content directly to a Mesh.
|
|
42
|
+
* @param {string} text - The OBJ file content as a string.
|
|
43
|
+
* @returns {Mesh} The parsed mesh.
|
|
44
|
+
*/
|
|
45
|
+
static fromText(text) {
|
|
46
|
+
const lexer = new WavefrontLexer(text);
|
|
47
|
+
const tokens = lexer.lexTokens();
|
|
48
|
+
|
|
49
|
+
const parser = new WavefrontParser(tokens);
|
|
50
|
+
const { vertices, faceIndices } = parser.parse();
|
|
51
|
+
|
|
52
|
+
return new Mesh(vertices, faceIndices);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Vector3 } from '../math/vector3.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses OBJ tokens into vertices and face indices.
|
|
5
|
+
* Handles 'v' (vertex) and 'f' (face) keywords, ignoring texture coords and normals.
|
|
6
|
+
*/
|
|
7
|
+
export class WavefrontParser {
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new WavefrontParser.
|
|
10
|
+
* @param {Array<{type: string, value: *}>} tokens - The tokens from WavefrontLexer.
|
|
11
|
+
*/
|
|
12
|
+
constructor(tokens) {
|
|
13
|
+
/** @type {Array<{type: string, value: *}>} */
|
|
14
|
+
this.tokens = tokens;
|
|
15
|
+
/** @type {number} */
|
|
16
|
+
this.current = 0;
|
|
17
|
+
/** @type {Vector3[]} */
|
|
18
|
+
this.vertices = [];
|
|
19
|
+
/** @type {number[][]} */
|
|
20
|
+
this.faceIndices = [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parses all tokens and returns the extracted mesh data.
|
|
25
|
+
* @returns {{vertices: Vector3[], faceIndices: number[][]}} The parsed mesh data.
|
|
26
|
+
*/
|
|
27
|
+
parse() {
|
|
28
|
+
while (!this.isAtEnd()) {
|
|
29
|
+
this.parseStatement();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
vertices: this.vertices,
|
|
34
|
+
faceIndices: this.faceIndices
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parses a single statement (line).
|
|
40
|
+
* @private
|
|
41
|
+
*/
|
|
42
|
+
parseStatement() {
|
|
43
|
+
const token = this.peek();
|
|
44
|
+
|
|
45
|
+
// Skip newlines
|
|
46
|
+
if (token.type === 'NEWLINE') {
|
|
47
|
+
this.advance();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Handle keywords
|
|
52
|
+
if (token.type === 'KEYWORD') {
|
|
53
|
+
const keyword = token.value;
|
|
54
|
+
|
|
55
|
+
if (keyword === 'v') {
|
|
56
|
+
this.advance(); // consume 'v'
|
|
57
|
+
this.parseVertex();
|
|
58
|
+
} else if (keyword === 'f') {
|
|
59
|
+
this.advance(); // consume 'f'
|
|
60
|
+
this.parseFace();
|
|
61
|
+
} else {
|
|
62
|
+
// Unknown keyword - skip to end of line
|
|
63
|
+
this.skipToNextLine();
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Skip anything else
|
|
69
|
+
this.advance();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parses a vertex definition (v x y z).
|
|
74
|
+
* @private
|
|
75
|
+
*/
|
|
76
|
+
parseVertex() {
|
|
77
|
+
const x = this.consumeNumber();
|
|
78
|
+
const y = this.consumeNumber();
|
|
79
|
+
const z = this.consumeNumber();
|
|
80
|
+
|
|
81
|
+
this.vertices.push(new Vector3(x, y, z));
|
|
82
|
+
this.skipToNextLine();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parses a face definition (f v1 v2 v3 ... or f v1/vt1/vn1 ...).
|
|
87
|
+
* Converts OBJ's 1-indexed vertices to 0-indexed.
|
|
88
|
+
* @private
|
|
89
|
+
*/
|
|
90
|
+
parseFace() {
|
|
91
|
+
const indices = [];
|
|
92
|
+
|
|
93
|
+
// Consume all vertex indices until newline or EOF
|
|
94
|
+
while (!this.isAtEnd() && this.peek().type !== 'NEWLINE') {
|
|
95
|
+
if (this.peek().type === 'NUMBER') {
|
|
96
|
+
// Get vertex index (1-indexed in OBJ, convert to 0-indexed)
|
|
97
|
+
const vertexIndex = this.consumeNumber() - 1;
|
|
98
|
+
indices.push(vertexIndex);
|
|
99
|
+
|
|
100
|
+
// Skip texture/normal indices (e.g., /2/3)
|
|
101
|
+
while (this.peek().type === 'SLASH') {
|
|
102
|
+
this.advance(); // consume '/'
|
|
103
|
+
|
|
104
|
+
// Consume the index after slash if present
|
|
105
|
+
if (this.peek().type === 'NUMBER')
|
|
106
|
+
this.advance();
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
this.advance();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (indices.length >= 3)
|
|
114
|
+
this.faceIndices.push(indices);
|
|
115
|
+
|
|
116
|
+
this.skipToNextLine();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Consumes and returns a number token value.
|
|
121
|
+
* @returns {number} The number value, or 0 if not a number token.
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
consumeNumber() {
|
|
125
|
+
const token = this.peek();
|
|
126
|
+
|
|
127
|
+
if (token.type === 'NUMBER') {
|
|
128
|
+
this.advance();
|
|
129
|
+
return token.value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Return 0 if no number found (error case)
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Skips tokens until the next newline or EOF.
|
|
138
|
+
* @private
|
|
139
|
+
*/
|
|
140
|
+
skipToNextLine() {
|
|
141
|
+
while (!this.isAtEnd() && this.peek().type !== 'NEWLINE')
|
|
142
|
+
this.advance();
|
|
143
|
+
|
|
144
|
+
// Consume the newline
|
|
145
|
+
if (!this.isAtEnd() && this.peek().type === 'NEWLINE')
|
|
146
|
+
this.advance();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Returns the current token without advancing.
|
|
151
|
+
* @returns {{type: string, value: *}} The current token.
|
|
152
|
+
* @private
|
|
153
|
+
*/
|
|
154
|
+
peek() {
|
|
155
|
+
return this.tokens[this.current];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Returns the current token and advances the position.
|
|
160
|
+
* @returns {{type: string, value: *}} The current token.
|
|
161
|
+
* @private
|
|
162
|
+
*/
|
|
163
|
+
advance() {
|
|
164
|
+
if (!this.isAtEnd())
|
|
165
|
+
this.current++;
|
|
166
|
+
|
|
167
|
+
return this.tokens[this.current - 1];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks if we've reached the end of tokens.
|
|
172
|
+
* @returns {boolean} True if at end or at EOF token.
|
|
173
|
+
* @private
|
|
174
|
+
*/
|
|
175
|
+
isAtEnd() {
|
|
176
|
+
return this.current >= this.tokens.length || this.peek().type === 'EOF';
|
|
177
|
+
}
|
|
178
|
+
}
|