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 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
+ }
@@ -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
+ }