diff --git a/lib/empty-example/index.html b/lib/empty-example/index.html index 56c88a89b8..54b1bfdfe2 100644 --- a/lib/empty-example/index.html +++ b/lib/empty-example/index.html @@ -12,7 +12,7 @@ background-color: #1b1b1b; } - + diff --git a/lib/empty-example/sketch.js b/lib/empty-example/sketch.js index c614f47b93..3b725f9da9 100644 --- a/lib/empty-example/sketch.js +++ b/lib/empty-example/sketch.js @@ -1,7 +1,7 @@ function setup() { - // put setup code here -} - -function draw() { - // put drawing code here -} + // put setup code here + } + + function draw() { + // put drawing code here + } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index edccbe8993..835c5da0fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,11 @@ "acorn": "^8.12.1", "acorn-walk": "^8.3.4", "colorjs.io": "^0.5.2", + "earcut": "^3.0.1", "file-saver": "^1.3.8", "gifenc": "^1.0.3", "i18next": "^19.0.2", "i18next-browser-languagedetector": "^4.0.1", - "libtess": "^1.2.2", "omggif": "^1.0.10", "pako": "^2.1.0", "zod": "^3.23.8" @@ -4123,6 +4123,12 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/earcut": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", + "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", + "license": "ISC" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6367,12 +6373,6 @@ "node": ">= 0.8.0" } }, - "node_modules/libtess": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/libtess/-/libtess-1.2.2.tgz", - "integrity": "sha512-Nps8HPeVVcsmJxUvFLKVJcCgcz+1ajPTXDVAVPs6+giOQP4AHV31uZFFkh+CKow/bkB7GbZWKmwmit7myaqDSw==", - "license": "SGI-B-2.0" - }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", diff --git a/package.json b/package.json index e34a8ef5c7..012ae3db8f 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,11 @@ "acorn": "^8.12.1", "acorn-walk": "^8.3.4", "colorjs.io": "^0.5.2", + "earcut": "^3.0.1", "file-saver": "^1.3.8", "gifenc": "^1.0.3", "i18next": "^19.0.2", "i18next-browser-languagedetector": "^4.0.1", - "libtess": "^1.2.2", "omggif": "^1.0.10", "pako": "^2.1.0", "zod": "^3.23.8" diff --git a/src/type/p5.Font.js b/src/type/p5.Font.js index ffd984b829..68bacad8ad 100644 --- a/src/type/p5.Font.js +++ b/src/type/p5.Font.js @@ -126,7 +126,7 @@ function font(p5, fn) { for (const { x, y } of contour) { this._pInst.vertex(x, y); } - this._pInst.endContour(this._pInst.CLOSE); + this._pInst.endContour(); } this._pInst.endShape(); } else { @@ -138,7 +138,7 @@ function font(p5, fn) { for (const { x, y } of contour) { this._pInst.vertex(x, y, side * extrude * 0.5); } - this._pInst.endContour(this._pInst.CLOSE); + this._pInst.endContour(); } this._pInst.endShape(); this._pInst.beginShape(); @@ -792,4 +792,4 @@ export default font; if (typeof p5 !== 'undefined') { font(p5, p5.prototype); -} +} \ No newline at end of file diff --git a/src/webgl/ShapeBuilder.js b/src/webgl/ShapeBuilder.js index 41535345e7..fd5aac1cb5 100644 --- a/src/webgl/ShapeBuilder.js +++ b/src/webgl/ShapeBuilder.js @@ -1,6 +1,6 @@ import * as constants from '../core/constants'; import { Geometry } from './p5.Geometry'; -import libtess from 'libtess'; // Fixed with exporting module from libtess +import earcut, {flatten, deviation} from 'earcut'; import { Vector } from '../math/p5.Vector'; import { RenderBuffer } from './p5.RenderBuffer'; @@ -22,7 +22,7 @@ export class ShapeBuilder { this.renderer = renderer; this.shapeMode = constants.PATH; this.geometry = new Geometry(undefined, undefined, undefined, this.renderer); - this.geometry.gid = '__IMMEDIATE_MODE_GEOMETRY__'; + this.geometry.gid = '_IMMEDIATE_MODE_GEOMETRY_'; this.contourIndices = []; this._useUserVertexProperties = undefined; @@ -33,9 +33,6 @@ export class ShapeBuilder { // Used to distinguish between user calls to vertex() and internal calls this.isProcessingVertices = false; - - // Used for converting shape outlines into triangles for rendering - this._tessy = this._initTessy(); this.tessyVertexSize = INITIAL_VERTEX_SIZE; this.bufferStrides = { ...INITIAL_BUFFER_STRIDES }; } @@ -259,19 +256,14 @@ export class ShapeBuilder { * @private */ _tesselateShape() { - // TODO: handle non-PATH shape modes that have contours this.shapeMode = constants.TRIANGLES; - // const contours = [[]]; const contours = []; for (let i = 0; i < this.geometry.vertices.length; i++) { - if ( - this.contourIndices.length > 0 && - this.contourIndices[0] === i - ) { + if (this.contourIndices.length > 0 && this.contourIndices[0] === i) { this.contourIndices.shift(); contours.push([]); } - contours[contours.length-1].push( + contours[contours.length - 1].push( this.geometry.vertices[i].x, this.geometry.vertices[i].y, this.geometry.vertices[i].z, @@ -293,7 +285,6 @@ export class ShapeBuilder { contours[contours.length-1].push(...vals); } } - const polyTriangles = this._triangulate(contours); const originalVertices = this.geometry.vertices; this.geometry.vertices = []; @@ -304,11 +295,8 @@ export class ShapeBuilder { prop.resetSrcArray(); } const colors = []; - for ( - let j = 0, polyTriLength = polyTriangles.length; - j < polyTriLength; - j = j + this.tessyVertexSize - ) { + // Loop over each vertex (remember: each triangle vertex is packed in tessyVertexSize floats) + for (let j = 0, len = polyTriangles.length; j < len; j += this.tessyVertexSize) { colors.push(...polyTriangles.slice(j + 5, j + 9)); this.geometry.vertexNormals.push(new Vector(...polyTriangles.slice(j + 9, j + 12))); { @@ -377,108 +365,142 @@ export class ShapeBuilder { this.geometry.vertexColors = colors; } - _initTessy() { - // function called for each vertex of tesselator output - function vertexCallback(data, polyVertArray) { - for (const element of data) { - polyVertArray.push(element); + _triangulate(contours) { + const allTriangleVerts = []; + const vertexSize = this.tessyVertexSize; + + // (A) Collect all 3D points from every contour. + const allPoints3D = []; + for (const contour of contours) { + for (let j = 0; j < contour.length; j += vertexSize) { + allPoints3D.push([contour[j], contour[j + 1], contour[j + 2]]); } } - - function begincallback(type) { - if (type !== libtess.primitiveType.GL_TRIANGLES) { - console.log(`expected TRIANGLES but got type: ${type}`); + // Compute a projection basis from all points. + const basis = this._computeProjectionBasis(allPoints3D); + + // (B) For each contour, build its 2D projection. + let classifiedContours = contours.map(contour => { + const polygon = []; + for (let j = 0; j < contour.length; j += vertexSize) { + const pt3 = [contour[j], contour[j + 1], contour[j + 2]]; + const pt2 = this._projectPoint(pt3, basis); + polygon.push(pt2); } - } - - function errorcallback(errno) { - console.log('error callback'); - console.log(`error number: ${errno}`); - } - - // callback for when segments intersect and must be split - const combinecallback = (coords, data, weight) => { - const result = new Array(this.tessyVertexSize).fill(0); - for (let i = 0; i < weight.length; i++) { - for (let j = 0; j < result.length; j++) { - if (weight[i] === 0 || !data[i]) continue; - result[j] += data[i][j] * weight[i]; + return { + // We will decide later if this contour is outer or a hole. + polygon, + vertexData: contour + }; + }); + + const outerContours = []; + const holeContours = []; + for (let i = 0; i < classifiedContours.length; i++) { + const c = classifiedContours[i]; + let contained = false; + for (let j = 0; j < classifiedContours.length; j++) { + if (i === j) continue; + if (this._contains(classifiedContours[j].polygon, c.polygon[0])) { + contained = true; + break; } } - return result; - }; - - function edgeCallback(flag) { - // don't really care about the flag, but need no-strip/no-fan behavior + if (!contained) { + outerContours.push(c); + } else { + holeContours.push(c); + } } - - const tessy = new libtess.GluTesselator(); - tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_VERTEX_DATA, vertexCallback); - tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_BEGIN, begincallback); - tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_ERROR, errorcallback); - tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_COMBINE, combinecallback); - tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_EDGE_FLAG, edgeCallback); - tessy.gluTessProperty( - libtess.gluEnum.GLU_TESS_WINDING_RULE, - libtess.windingRule.GLU_TESS_WINDING_NONZERO - ); - - return tessy; - } - - /** - * Runs vertices through libtess to convert them into triangles - * @private - */ - _triangulate(contours) { - // libtess will take 3d verts and flatten to a plane for tesselation. - // libtess is capable of calculating a plane to tesselate on, but - // if all of the vertices have the same z values, we'll just - // assume the face is facing the camera, letting us skip any performance - // issues or bugs in libtess's automatic calculation. - const z = contours[0] ? contours[0][2] : undefined; - let allSameZ = true; - for (const contour of contours) { - for ( - let j = 0; - j < contour.length; - j += this.tessyVertexSize - ) { - if (contour[j + 2] !== z) { - allSameZ = false; - break; + // Group each outer contour with all holes contained in it. + const contourGroups = outerContours.map(outer => ({ + outer, + holes: holeContours.filter(hole => this._contains(outer.polygon, hole.polygon[0])) + })); + + for (const group of contourGroups) { + const { outer, holes } = group; + const polygons = [outer.polygon, ...holes.map(h => h.polygon)]; + const { vertices: verts2D, holes: earcutHoles, dimensions } = flatten(polygons); + const indices = earcut(verts2D, earcutHoles, dimensions); + + const vertexDataChunks = []; + for (let j = 0; j < outer.vertexData.length; j += vertexSize) { + vertexDataChunks.push(outer.vertexData.slice(j, j + vertexSize)); + } + // Then add the holes’ vertexData (in the same order as flatten() does). + for (const h of holes) { + for (let j = 0; j < h.vertexData.length; j += vertexSize) { + vertexDataChunks.push(h.vertexData.slice(j, j + vertexSize)); } } + // Build triangles using earcut’s indices. + for (const idx of indices) { + allTriangleVerts.push(...vertexDataChunks[idx]); + } + } + return allTriangleVerts; + }; + + _projectPoint(pt, basis) { + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + return [dot(pt, basis.u), dot(pt, basis.v)]; + }; + + _computeProjectionBasis(points) { + const epsilon = 1e-6; + const firstZ = points[0][2]; + const isFlat2D = points.every(p => Math.abs(p[2] - firstZ) < epsilon); + if (isFlat2D) { + return { + normal: [0, 0, 1], + u: [1, 0, 0], + v: [0, 1, 0] + }; + } + // Otherwise compute a normal via Newell's method. + let normal = [0, 0, 0]; + for (let i = 0; i < points.length; i++) { + const current = points[i]; + const next = points[(i + 1) % points.length]; + normal[0] += (current[1] - next[1]) * (current[2] + next[2]); + normal[1] += (current[2] - next[2]) * (current[0] + next[0]); + normal[2] += (current[0] - next[0]) * (current[1] + next[1]); } - if (allSameZ) { - this._tessy.gluTessNormal(0, 0, 1); + let len = Math.hypot(normal[0], normal[1], normal[2]); + if (len === 0) { + normal = [0, 0, 1]; } else { - // Let libtess pick a plane for us - this._tessy.gluTessNormal(0, 0, 0); + normal = normal.map(n => n / len); } - - const triangleVerts = []; - this._tessy.gluTessBeginPolygon(triangleVerts); - - for (const contour of contours) { - this._tessy.gluTessBeginContour(); - for ( - let j = 0; - j < contour.length; - j += this.tessyVertexSize - ) { - const coords = contour.slice( - j, - j + this.tessyVertexSize - ); - this._tessy.gluTessVertex(coords, coords); - } - this._tessy.gluTessEndContour(); + // Choose an arbitrary vector not parallel to the normal. + let u; + if (Math.abs(normal[0]) > Math.abs(normal[1])) { + u = [-normal[2], 0, normal[0]]; + } else { + u = [0, normal[2], -normal[1]]; } - - // finish polygon - this._tessy.gluTessEndPolygon(); - - return triangleVerts; + let uLen = Math.hypot(u[0], u[1], u[2]); + if (uLen === 0) u = [1, 0, 0]; + else u = u.map(x => x / uLen); + // Compute v = normal cross u. + const v = [ + normal[1] * u[2] - normal[2] * u[1], + normal[2] * u[0] - normal[0] * u[2], + normal[0] * u[1] - normal[1] * u[0] + ]; + return { normal, u, v }; + }; + + _contains(outerPolygon, [x, y]) { + let inside = false; + for (let i = 0, j = outerPolygon.length - 1; i < outerPolygon.length; j = i++) { + const [xi, yi] = outerPolygon[i]; + const [xj, yj] = outerPolygon[j]; + const intersect = ((yi > y) !== (yj > y)) && + (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi); + if (intersect) inside = !inside; + } + return inside; } };