From ac25cef872b268ce2352cbad0ed58515938b6118 Mon Sep 17 00:00:00 2001 From: K-Adam Date: Mon, 26 Oct 2026 18:07:42 +0100 Subject: [PATCH 1/9] Restore cube transparency and support texture alpha --- examples/three_interactive_cubes_tween.html | 1 + packages/c3r-three/README.md | 4 ++-- packages/c3r-three/src/C3RRenderer.ts | 2 +- packages/c3r-three/test/C3RRenderer.test.ts | 5 ++++- packages/c3r/README.md | 20 ++++++++++---------- packages/c3r/src/css/Material.ts | 14 ++++++++++++++ packages/c3r/src/css/styles.css | 2 ++ packages/c3r/src/dom/Container.ts | 1 - packages/c3r/src/dom/Polygon.ts | 8 ++++++-- 9 files changed, 40 insertions(+), 17 deletions(-) diff --git a/examples/three_interactive_cubes_tween.html b/examples/three_interactive_cubes_tween.html index 4e6325e..9d50c88 100644 --- a/examples/three_interactive_cubes_tween.html +++ b/examples/three_interactive_cubes_tween.html @@ -65,6 +65,7 @@ new THREE.MeshBasicMaterial({ color: Math.random() * 0xffffff, opacity: 0.5, + transparent: true, }), ); object.position.x = Math.random() * 800 - 400; diff --git a/packages/c3r-three/README.md b/packages/c3r-three/README.md index 399c204..fc5087d 100644 --- a/packages/c3r-three/README.md +++ b/packages/c3r-three/README.md @@ -22,9 +22,9 @@ renderer.render(scene, camera); `CSS3DObject` and `CSS3DSprite` are re-exported from `three/addons`, so their elements receive pointer events as with three's renderer. -Perspective and orthographic cameras are supported, including `zoom`. Material `color`, `map` (image or canvas, with `repeat` and `offset`), `opacity` (when `transparent`) and `side` (`BackSide` shows both sides) are applied, and a material's style is removed on its `dispose()`. `renderer.dispose()` removes every rendered scene. +Perspective and orthographic cameras are supported, including `zoom`. Material `color`, `map` (image or canvas, with `repeat` and `offset`), `opacity` and map alpha (when `transparent`) and `side` (`BackSide` shows both sides) are applied, and a material's style is removed on its `dispose()`. `renderer.dispose()` removes every rendered scene. -Not supported yet: texture alpha on meshes (transparent pixels show the material color), `ImageBitmap` maps such as GLTFLoader's, `flipY: false`, and map `rotation` and `center`. +Not supported yet: `ImageBitmap` maps such as GLTFLoader's, `flipY: false`, and map `rotation` and `center`. ## Node diff --git a/packages/c3r-three/src/C3RRenderer.ts b/packages/c3r-three/src/C3RRenderer.ts index 1887746..e82db65 100644 --- a/packages/c3r-three/src/C3RRenderer.ts +++ b/packages/c3r-three/src/C3RRenderer.ts @@ -334,7 +334,7 @@ export class C3RRenderer { } target.opacity = material.transparent ? material.opacity : 1; - // ponytail: BackSide renders as double-sided, CSS can't cull front faces only + target.transparent = material.transparent; target.doubleSided = material.side !== FrontSide; return map; diff --git a/packages/c3r-three/test/C3RRenderer.test.ts b/packages/c3r-three/test/C3RRenderer.test.ts index da90356..b319e30 100644 --- a/packages/c3r-three/test/C3RRenderer.test.ts +++ b/packages/c3r-three/test/C3RRenderer.test.ts @@ -71,6 +71,7 @@ it("places a nested sprite at its world position, facing the camera", () => { expect(Math.hypot(...column)).toBeCloseTo(s, 6); column.forEach((c, j) => expect(c / s).toBeCloseTo(cam[i * 4 + j], 6)); }); + sprite.material.dispose(); }); it("follows visibility, geometry updates and removal", () => { @@ -166,17 +167,19 @@ it("applies the camera zoom", () => { expect(cssText()).toMatch(/--c3r-camera-fov: ?53\.13/); }); -it("ignores opacity unless the material is transparent, as WebGL", () => { +it("ignores opacity and map alpha unless the material is transparent, as WebGL", () => { const { renderer, scene, camera } = setup(); const material = new MeshBasicMaterial({ opacity: 0.5 }); scene.add(new Mesh(new BoxGeometry(), material)); renderer.render(scene, camera); expect(cssText()).not.toMatch(/--c3r-opacity:/); + expect(cssText()).not.toMatch(/--c3r-mask-image:/); material.transparent = true; renderer.render(scene, camera); expect(cssText()).toMatch(/--c3r-opacity: ?0\.5/); + expect(cssText()).toMatch(/--c3r-mask-image: ?var\(--c3r-background-image/); material.dispose(); }); diff --git a/packages/c3r/README.md b/packages/c3r/README.md index aa5c727..de11614 100644 --- a/packages/c3r/README.md +++ b/packages/c3r/README.md @@ -48,16 +48,16 @@ cube.material.backgroundImageSrc = "crate.jpg"; container.camera.matrix = mat4.fromTranslation(mat4.create(), [0, 0, -400]); ``` -| Class | What it is | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Container` | The viewport: `domElement`, `root` group, `camera`, `setSize()` | -| `Group` | A transformed node: `matrix`, `addChild()`, `addPolygon()`, `addTriangles()` merges into polygons | -| `Polygon` | A flat, convex or concave textured polygon from CCW vertices, with `uvScale`, `uvOffset` | -| `ElementObject` | Any HTML element in the scene, centered on the origin, with `scale`, `center`, `rotation` | -| `Material` | `backgroundColor`, `backgroundImageSrc`, `opacity`, `doubleSided`, inherited by the object's descendants; on an `ElementObject`, its element only | -| `PerspectiveCamera`, `OrthographicCamera` | `matrix` (inverse world) and `fov`, or `left`, `right`, `top`, `bottom` | - -Every property applies on assignment; there is no `update()` to call. A polygon is laid out from its vertices on construction, so put it in a group to move it. +| Class | What it is | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Container` | The viewport: `domElement`, `root` group, `camera`, `setSize()` | +| `Group` | A transformed node: `matrix`, `addChild()`, `addPolygon()`, `addTriangles()` merges into polygons | +| `Polygon` | A flat, convex or concave textured polygon from CCW vertices, with `uvScale`, `uvOffset` | +| `ElementObject` | Any HTML element in the scene, centered on the origin, with `scale`, `center`, `rotation` | +| `Material` | `backgroundColor`, `backgroundImageSrc`, `opacity`, `transparent`, `doubleSided`, inherited by the object's descendants; on an `ElementObject`, its element only | +| `PerspectiveCamera`, `OrthographicCamera` | `matrix` (inverse world) and `fov`, or `left`, `right`, `top`, `bottom` | + +A `transparent` material's image alpha also cuts out its color. Every property applies on assignment; there is no `update()` to call. A polygon is laid out from its vertices on construction, so put it in a group to move it. A `Material` or camera is a class rule in one shared stylesheet, so the objects using it share one style. The rule stays until `[Symbol.dispose]()` is called, or the owner leaves a `using` block; otherwise it is removed once the owner and its elements are garbage-collected. diff --git a/packages/c3r/src/css/Material.ts b/packages/c3r/src/css/Material.ts index ff0d286..41be0af 100644 --- a/packages/c3r/src/css/Material.ts +++ b/packages/c3r/src/css/Material.ts @@ -13,6 +13,7 @@ export class Material { #backgroundColor: ReadonlyVec4 | null = null; #opacity = 1; #doubleSided = false; + #transparent = false; #rule: CSSStyleRule | null = insertRule(`.${this.className}`, this); @@ -68,6 +69,19 @@ export class Material { this.#set("backface-visibility", doubleSided ? "visible" : null); } + get transparent(): boolean { + return this.#transparent; + } + + set transparent(transparent: boolean) { + if (transparent === this.#transparent) return; + this.#transparent = transparent; + this.#set( + "--c3r-mask-image", + transparent ? "var(--c3r-background-image, none)" : null, + ); + } + #set(name: string, value: string | null): void { const style = this.#rule?.style; if (!style) return; diff --git a/packages/c3r/src/css/styles.css b/packages/c3r/src/css/styles.css index 8b26db6..ba0e4a2 100644 --- a/packages/c3r/src/css/styles.css +++ b/packages/c3r/src/css/styles.css @@ -91,6 +91,8 @@ background-image: var(--c3r-background-image, none); background-size: 100% 100%; background-blend-mode: multiply; + mask-image: var(--c3r-mask-image, none); + mask-size: 100% 100%; transform: matrix(1, 0, 0, 1, 0, 0); transform-origin: 0 0; } diff --git a/packages/c3r/src/dom/Container.ts b/packages/c3r/src/dom/Container.ts index a994f5c..6bdbff1 100644 --- a/packages/c3r/src/dom/Container.ts +++ b/packages/c3r/src/dom/Container.ts @@ -83,7 +83,6 @@ export class Container { const { style } = this.domElement; style.removeProperty("--c3r-viewport-width"); style.removeProperty("--c3r-viewport-height"); - // ponytail: window size at reset, ortho needs setSize for other sizes or resizes style.setProperty("--c3r-viewport-x", String(innerWidth)); style.setProperty("--c3r-viewport-y", String(innerHeight)); } diff --git a/packages/c3r/src/dom/Polygon.ts b/packages/c3r/src/dom/Polygon.ts index f53075f..75fc391 100644 --- a/packages/c3r/src/dom/Polygon.ts +++ b/packages/c3r/src/dom/Polygon.ts @@ -354,6 +354,8 @@ export class Polygon extends Object3D { if (!cross2(uvs[1], uvs[2], uvs[0])) { style.removeProperty("background-position"); style.removeProperty("background-size"); + style.removeProperty("mask-position"); + style.removeProperty("mask-size"); style.removeProperty("transform"); return; } @@ -386,8 +388,10 @@ export class Polygon extends Object3D { const scx = 1 / xsize; const scy = 1 / ysize; - style.backgroundPosition = `calc(var(--c3r-width) * ${-txmin * scx}) calc(var(--c3r-height) * ${1 + tymin * scy})`; - style.backgroundSize = `${100 * scx}% ${100 * scy}%`; + const position = `calc(var(--c3r-width) * ${-txmin * scx}) calc(var(--c3r-height) * ${1 + tymin * scy})`; + const size = `${100 * scx}% ${100 * scy}%`; + style.backgroundPosition = style.maskPosition = position; + style.backgroundSize = style.maskSize = size; const uvPoint = ([u, v]: vec2): Point => [ (u - txmin) * scx, From 67440d1d6b1ec3aa82bad6267ef38578ee53ad94 Mon Sep 17 00:00:00 2001 From: K-Adam Date: Mon, 26 Oct 2026 18:54:19 +0100 Subject: [PATCH 2/9] Merge mesh faces with MeshBuilder and tile the FPS diamond plate --- examples/public/models/platform/plate.jpg | Bin 0 -> 20148 bytes examples/public/models/platform/platform.json | 2 +- examples/three_fps.html | 13 +- packages/c3r-three/src/C3RRenderer.ts | 27 +- packages/c3r-three/test/C3RRenderer.test.ts | 27 ++ packages/c3r/src/C3R.ts | 2 + packages/c3r/src/dom/Group.ts | 108 ++----- packages/c3r/src/dom/Polygon.ts | 284 +++++++++--------- packages/c3r/src/math/MeshBuilder.ts | 223 ++++++++++++++ packages/c3r/src/math/planar.ts | 93 ++++++ packages/c3r/test/c3r.test.ts | 245 ++++++++++++++- 11 files changed, 770 insertions(+), 254 deletions(-) create mode 100644 examples/public/models/platform/plate.jpg create mode 100644 packages/c3r/src/math/MeshBuilder.ts create mode 100644 packages/c3r/src/math/planar.ts diff --git a/examples/public/models/platform/plate.jpg b/examples/public/models/platform/plate.jpg new file mode 100644 index 0000000000000000000000000000000000000000..61cdaa1ef63249a5e64ead2d3b796305c8baae79 GIT binary patch literal 20148 zcmeHOYfwyK7(Vy2m!cKxnx?WzXr`M>m|RPkF(pdwxow)swx}4PSgFBGNW@UIW@PyHd}q!#duG2q^FH6%bKd89&i7jCElq^Y1it`3 zf+Puo6gI+gn;1h-q}ars5*tH{J(bBF%X`q^Awzw}j2-9aKR#f>l;Ek;LPDp9&0nx^(PDMPl4Z+R#Kgv}T(y3K zZe!A>{}Q*gGhsJNueP=5JJMP=31>o;!J*4?Ua zFq-b(yZ_+fqsLF4HotoP=55Qn*7qMoTq4fz?U%R|0xn9t2E2%iq+*4gRxqrCJJ)Gc zAU`kK*3lzD(s^`hR$i&p$ur2@C0tu0Q}*#PshUM-ZOHx%SmIwH`vL4HE+b(@lfuQL z6@)MGNpJqVH7|G8<;;N%%VV0(Y4ioF+b3tjpl}XRZwL3*nf92H96dO{_)6=Xtg|MmR*rs zqf71_d-d#|=5lG4u~EJ!-_t3lGHmUZ)x-KGo=`qB7*noUmpxGVeax)$*JWQf)iG&LAdttbf*{wlR#llkOiSFO>BUsW5ik_{p78#}rZzpKq>ws#Wg4H>X2Y z(=lC%xkaCE&=dy*UU=-CYKnMMv?9SidGFP9-rBQQn)5naLyOOqf>`Cc!i6=r6JM02 z)Hb*1Oc$+2X4vIkDn94r;j>ql-FnNAALnX{IzQsb@l%a%LBUH)tQW+E#k<+f^}oB= z<4~z#QiUoYlfi^x!tf8nEs1BCm_hK+Z)eKIgki!!E5O4*T*34qjeaLgktLBOktLBO zktLBOktLBO|B>GfS^-)CS^-)CS^-)CS^-)CT7fs;SSoP!L5Mdy~K!kw^ k0}%!y3`7`+Fc4uN!a#(92m=uYA`C node.removeChild(g.node)); - state = { key, groups: ranges.map((r) => buildGroup(geometry, r)) }; + const builder = new C3R.MeshBuilder( + ranges.map((r) => readTriangles(geometry, r)), + ); + const groups = ranges.map((_, i) => { + const node = new C3R.Group(); + return { node, polygons: node.addFaces(builder.faces(i)) }; + }); + state = { key, groups }; state.groups.forEach((g) => node.addChild(g.node)); this.#meshes.set(node, state); } @@ -269,10 +277,8 @@ export class C3RRenderer { : [mesh.material]; ranges.forEach((r, i) => { const group = state.groups[i]; - const map = this.#syncMaterial( - materials[r.materialIndex % materials.length], - group.node, - ); + const material = materials[r.materialIndex % materials.length]; + const map = this.#syncMaterial(material, group.node); const repeat: [number, number] = map ? [map.repeat.x, map.repeat.y] @@ -283,6 +289,10 @@ export class C3RRenderer { for (const polygon of group.polygons) { polygon.uvScale = repeat; polygon.uvOffset = offset; + polygon.overlapFolds = !material.transparent; + polygon.clampUV = + map?.wrapS === ClampToEdgeWrapping && + map.wrapT === ClampToEdgeWrapping; } }); } @@ -373,10 +383,10 @@ function createNode(object: Object3D): C3R.Group { return new C3R.Group(); } -function buildGroup( +function readTriangles( geometry: BufferGeometry, { start, count }: Range, -): MeshState["groups"][number] { +): C3R.Vertex[][] { const { index } = geometry; const { position, uv } = geometry.attributes; @@ -393,6 +403,5 @@ function buildGroup( triangles.push([vertex(i), vertex(i + 1), vertex(i + 2)]); } - const node = new C3R.Group(); - return { node, polygons: node.addTriangles(triangles) }; + return triangles; } diff --git a/packages/c3r-three/test/C3RRenderer.test.ts b/packages/c3r-three/test/C3RRenderer.test.ts index b319e30..6ab3949 100644 --- a/packages/c3r-three/test/C3RRenderer.test.ts +++ b/packages/c3r-three/test/C3RRenderer.test.ts @@ -183,6 +183,33 @@ it("ignores opacity and map alpha unless the material is transparent, as WebGL", material.dispose(); }); +it("overlaps the folds of opaque meshes, convex ones thinly", () => { + const { renderer, scene, camera } = setup(); + + const width = () => + [...renderer.domElement.querySelectorAll(".c3r-polygon")].map( + (d) => d.style.getPropertyValue("--c3r-width"), + ); + + const box = new Mesh(new BoxGeometry(), new MeshBasicMaterial()); + scene.add(box); + renderer.render(scene, camera); + for (const w of width()) expect(parseFloat(w)).toBeCloseTo(250.8); + scene.remove(box); + box.material.dispose(); + + // Inside out, as a skybox + const material = new MeshBasicMaterial(); + scene.add(new Mesh(new BoxGeometry().scale(-1, 1, 1), material)); + renderer.render(scene, camera); + expect(new Set(width())).toEqual(new Set(["252px"])); + + material.transparent = true; + renderer.render(scene, camera); + expect(new Set(width())).toEqual(new Set(["250px"])); + material.dispose(); +}); + it("keeps a sprite's material off its child elements", () => { const { renderer, scene, camera } = setup(); diff --git a/packages/c3r/src/C3R.ts b/packages/c3r/src/C3R.ts index 3a4d5d9..2caf6c8 100644 --- a/packages/c3r/src/C3R.ts +++ b/packages/c3r/src/C3R.ts @@ -8,6 +8,8 @@ export { Polygon, type PolygonOptions } from "./dom/Polygon.js"; export { ElementObject } from "./dom/ElementObject.js"; export type { Vertex } from "./math/Vertex.js"; +export { MeshBuilder, type MeshFace } from "./math/MeshBuilder.js"; +export type { EdgeNeighbour, Fold, NeighbourSide } from "./math/planar.js"; export { Camera } from "./css/Camera.js"; export { PerspectiveCamera } from "./css/PerspectiveCamera.js"; diff --git a/packages/c3r/src/dom/Group.ts b/packages/c3r/src/dom/Group.ts index 8b7040a..b3aa1d7 100644 --- a/packages/c3r/src/dom/Group.ts +++ b/packages/c3r/src/dom/Group.ts @@ -1,16 +1,12 @@ -import { - vec2, - vec3, - type mat4, - type ReadonlyMat4, - type ReadonlyVec3, -} from "gl-matrix"; - -import { Triangle } from "../math/Triangle.js"; +import type { ReadonlyMat4 } from "gl-matrix"; + +import { MeshBuilder, type MeshFace } from "../math/MeshBuilder.js"; import type { Vertex } from "../math/Vertex.js"; import { Object3D } from "./Object3D.js"; -import { Polygon, type PolygonOptions } from "./Polygon.js"; +import { defaultDomSize, Polygon, type PolygonOptions } from "./Polygon.js"; + +const MAX_DOM_SIZE = 4096; const parents = new WeakMap(); @@ -61,86 +57,26 @@ export class Group extends Object3D { return poly; } + addFaces(faces: readonly MeshFace[], options?: PolygonOptions): Polygon[] { + const domSize = options?.getDomSize ?? defaultDomSize; + return faces.map(({ vertices, edges, unit }) => + this.addPolygon(vertices, { + ...options, + edges, + getDomSize: (size) => + Math.min( + Math.max(domSize(size), (domSize(unit) / unit) * size), + MAX_DOM_SIZE, + ), + }), + ); + } + // Merges coplanar neighbours, e.g. two triangles into a quad addTriangles( triangles: readonly (readonly Vertex[])[], options?: PolygonOptions, ): Polygon[] { - const polygons: Polygon[] = []; - let current: Face | null = null; - - for (const vertices of triangles) { - const tri = new Triangle( - vertices[0].position, - vertices[1].position, - vertices[2].position, - ); - - // Skip slivers - if ( - tri.calculateArea() <= - 1e-6 * Math.max(...tri.calculateEdgeLengths()) ** 2 - ) - continue; - - if (current && vec3.equals(current.norm, tri.norm)) { - const merged = merge(current, vertices); - if (merged) { - current.vertices = merged; - continue; - } - } - - if (current) polygons.push(this.addPolygon(current.vertices, options)); - current = face(vertices, tri); - } - - if (current) polygons.push(this.addPolygon(current.vertices, options)); - return polygons; - } -} - -interface Face { - vertices: readonly Vertex[]; - norm: ReadonlyVec3; - vec2tex: mat4; -} - -const face = (vertices: readonly Vertex[], tri: Triangle): Face => { - const uvPoint = (v: Vertex) => vec3.fromValues(v.uv[0], v.uv[1], 0); - return { - vertices, - norm: tri.norm, - vec2tex: tri.getTransformationTo( - new Triangle( - uvPoint(vertices[0]), - uvPoint(vertices[1]), - uvPoint(vertices[2]), - ), - ), - }; -}; - -// Inserts the triangle into the face if the two share an edge and the texture mapping -function merge( - { vertices: face, vec2tex }: Face, - tri: readonly Vertex[], -): Vertex[] | null { - const same = (a: Vertex, b: Vertex) => - vec2.equals(a.uv, b.uv) && vec3.equals(a.position, b.position); - - for (let i = 0; i < face.length; i++) { - for (let j = 0; j < 3; j++) { - if (!same(face[i], tri[j])) continue; - if (!same(face[(i + 1) % face.length], tri[(j + 2) % 3])) continue; - - const apex = tri[(j + 1) % 3]; - const uv = vec3.transformMat4(vec3.create(), apex.position, vec2tex); - if (!vec2.equals([uv[0], uv[1]], apex.uv)) continue; - - return [...face.slice(0, i + 1), apex, ...face.slice(i + 1)]; - } + return this.addFaces(new MeshBuilder([triangles]).faces(0), options); } - - return null; } diff --git a/packages/c3r/src/dom/Polygon.ts b/packages/c3r/src/dom/Polygon.ts index 75fc391..cdba320 100644 --- a/packages/c3r/src/dom/Polygon.ts +++ b/packages/c3r/src/dom/Polygon.ts @@ -1,49 +1,19 @@ -import { mat2, mat3, vec2, vec3, type ReadonlyVec2 } from "gl-matrix"; +import { mat3, mat4, vec2, vec3, type ReadonlyVec2 } from "gl-matrix"; import { Object3D } from "./Object3D.js"; +import { + bounds, + convexHull, + cross2, + minRect, + simplify, + toPlane, + type EdgeNeighbour, + type NeighbourSide, +} from "../math/planar.js"; import { Triangle } from "../math/Triangle.js"; import type { Vertex } from "../math/Vertex.js"; -type Bounds = [xmin: number, xmax: number, ymin: number, ymax: number]; - -const cross2 = (a: ReadonlyVec2, b: ReadonlyVec2, o: ReadonlyVec2) => - (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); - -const bounds = (points: ReadonlyVec2[]): Bounds => - points.reduce( - ([xmin, xmax, ymin, ymax], [x, y]) => [ - Math.min(xmin, x), - Math.max(xmax, x), - Math.min(ymin, y), - Math.max(ymax, y), - ], - [Infinity, -Infinity, Infinity, -Infinity], - ); - -// Monotone chain, https://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Convex_hull/Monotone_chain -const convexHull = (points: ReadonlyVec2[]): ReadonlyVec2[] => { - const sorted = points - .slice() - .sort((a, b) => (a[0] === b[0] ? a[1] - b[1] : a[0] - b[0])); - - const chain = (pts: ReadonlyVec2[]) => { - const out: ReadonlyVec2[] = []; - for (const p of pts) { - while ( - out.length >= 2 && - cross2(out[out.length - 2], out[out.length - 1], p) <= 0 - ) { - out.pop(); - } - out.push(p); - } - out.pop(); - return out; - }; - - return [...chain(sorted), ...chain(sorted.slice().reverse())]; -}; - // Newell's method const newellNormal = (vertices: readonly Vertex[]): vec3 => { const n = vec3.create(); @@ -60,8 +30,28 @@ const newellNormal = (vertices: readonly Vertex[]): vec3 => { const MITER_LIMIT = 4; -// Offsets every edge outward by d -const dilate = (points: ReadonlyVec2[], d: number): vec2[] => { +interface Edge { + offset: number; + sides: [ReadonlyVec2 | null, ReadonlyVec2 | null]; +} + +// Ends the overlap on the neighbour's side instead of past it +const cap = ( + p: ReadonlyVec2, + n: ReadonlyVec2, + d: number, + side: ReadonlyVec2 | null, +): vec2 => { + if (side) { + const u = vec2.normalize(vec2.create(), vec2.sub(vec2.create(), side, p)); + const k = vec2.dot(u, n); + if (k > 1e-6) + return vec2.scaleAndAdd(u, p, u, Math.min(d / k, MITER_LIMIT * d)); + } + return vec2.scaleAndAdd(vec2.create(), p, n, d); +}; + +const dilate = (points: ReadonlyVec2[], edges: Edge[]): vec2[] => { const n = points.length; const area = points.reduce( (s, p, i) => s + cross2(p, points[(i + 1) % n], [0, 0]), @@ -74,53 +64,40 @@ const dilate = (points: ReadonlyVec2[], d: number): vec2[] => { return vec2.fromValues(e[1] * sign, -e[0] * sign); }; - return points.map((p, i) => { - const n1 = normal(points[(i + n - 1) % n], p); - const n2 = normal(p, points[(i + 1) % n]); + return points.flatMap((p, i) => { + const prev = (i + n - 1) % n; + const next = (i + 1) % n; + const n1 = normal(points[prev], p); + const n2 = normal(p, points[next]); + const d1 = edges[prev].offset; + const d2 = edges[i].offset; + if (!d1 && !d2) return [vec2.clone(p)]; + + // A miter at a convex corner pokes out of the mesh where the corner is on its border + if (cross2(p, points[next], points[prev]) * sign > 0) { + const c1 = d1 ? cap(p, n1, d1, edges[prev].sides[1]) : vec2.clone(p); + const c2 = d2 ? cap(p, n2, d2, edges[i].sides[0]) : vec2.clone(p); + if (vec2.equals(c1, c2)) return [c1]; + // Around a vertex of many faces the caps would cut off the corner + const cut = cross2(c2, p, c1) * sign < 0; + return cut ? [c1, vec2.clone(p), c2] : [c1, c2]; + } - const m = vec2.add(vec2.create(), n1, n2); - vec2.scale(m, m, d / Math.max(1 + vec2.dot(n1, n2), 1e-6)); + const c = vec2.dot(n1, n2); + const det = Math.max(1 - c * c, 1e-6); + const m = vec2.scale(vec2.create(), n1, (d1 - d2 * c) / det); + vec2.scaleAndAdd(m, m, n2, (d2 - d1 * c) / det); + const limit = MITER_LIMIT * Math.max(d1, d2); const len = vec2.len(m); - if (len > MITER_LIMIT * d) vec2.scale(m, m, (MITER_LIMIT * d) / len); + if (len > limit) vec2.scale(m, m, limit / len); - return vec2.add(m, m, p); + return [vec2.add(m, m, p)]; }); }; -const dedupe = (vertices: readonly Vertex[]): Vertex[] => { - const unique = vertices.filter( - (v, i) => i === 0 || !vec3.equals(v.position, vertices[i - 1].position), - ); - while ( - unique.length > 1 && - vec3.equals(unique[unique.length - 1].position, unique[0].position) - ) - unique.pop(); - return unique; -}; - -// Remove duplicate and collinear vertices -const simplify = (input: readonly Vertex[]): Vertex[] => { - const vertices = dedupe(input); - return vertices.filter((vertex, i) => { - if (i === 0) return true; - - const p1 = vertices[i - 1].position; - const p3 = vertices[(i + 1) % vertices.length].position; - - const e1 = vec3.normalize( - vec3.create(), - vec3.sub(vec3.create(), p1, vertex.position), - ); - const e2 = vec3.normalize( - vec3.create(), - vec3.sub(vec3.create(), vertex.position, p3), - ); - - return Math.abs(vec3.dot(e1, e2)) < 0.9999; - }); -}; +export const defaultDomSize = (size: number) => + Math.max(250, Math.min(size, 1200)); export interface PolygonOptions { contentElement?: HTMLElement; @@ -128,6 +105,10 @@ export interface PolygonOptions { getDomSize?: (size: number) => number; // Outward edge offset in DOM pixels, hides seams between neighbours edgeOverlap?: number; + // Over a convex fold the overlap sticks out as a lip + convexOverlap?: number; + // Per edge from each vertex, edges without a neighbour would only overhang + edges?: readonly EdgeNeighbour[]; } export class Polygon extends Object3D { @@ -141,6 +122,10 @@ export class Polygon extends Object3D { #getDomSize: (size: number) => number; #edgeOverlap: number; + #convexOverlap: number; + #edges: readonly EdgeNeighbour[]; + #overlapFolds = true; + #clampUV = false; #clipPath: ReadonlyVec2[] = []; // path of the dilated polygon on a rectangular element #vertexPoints: ReadonlyVec2[] = []; @@ -152,15 +137,19 @@ export class Polygon extends Object3D { vertices: readonly Vertex[], { contentElement = document.createElement("div"), - getDomSize = (size) => Math.max(250, Math.min(size, 1200)), + getDomSize = defaultDomSize, edgeOverlap = 1, + convexOverlap = 0.4, + edges, }: PolygonOptions = {}, ) { super(); this.vertices = simplify(vertices); + this.#edges = edges ?? this.vertices.map(() => [null, null]); this.#getDomSize = getDomSize; this.#edgeOverlap = edgeOverlap; + this.#convexOverlap = convexOverlap; this.domElement.classList.add("c3r-polygon"); @@ -174,6 +163,28 @@ export class Polygon extends Object3D { this.#updateContent(); } + // Overlapping a fold leaves a lip, which shows through a transparent surface + get overlapFolds(): boolean { + return this.#overlapFolds; + } + + set overlapFolds(overlap: boolean) { + if (overlap === this.#overlapFolds) return; + this.#overlapFolds = overlap; + this.#fit(); + this.#updateContent(); + } + + get clampUV(): boolean { + return this.#clampUV; + } + + set clampUV(clamp: boolean) { + if (clamp === this.#clampUV) return; + this.#clampUV = clamp; + this.#updateContent(); + } + get uvScale(): ReadonlyVec2 { return this.#uvScale; } @@ -247,60 +258,9 @@ export class Polygon extends Object3D { } #fitPolygon(tri: Triangle): void { - const b1 = tri.norm; - const b2 = - Math.abs(vec3.dot(b1, [0, 0, 1])) < 0.9 - ? vec3.cross(vec3.create(), b1, [0, 0, 1]) - : vec3.cross(vec3.create(), b1, [0, 1, 0]); - const b3 = vec3.cross(vec3.create(), b2, b1); - - // Project the points to the polygon's plane - // prettier-ignore - const BT = mat3.fromValues( - b2[0], b2[1], b2[2], - b3[0], b3[1], b3[2], - b1[0], b1[1], b1[2], - ); - const BTI = mat3.invert(BT, BT); - if (!BTI) throw new Error("Degenerate polygon"); - - const points2d = this.vertices.map((v) => { - const p = vec3.transformMat3(vec3.create(), v.position, BTI); - return vec2.fromValues(p[0], p[1]); - }); - - const hull = convexHull(points2d); - - // For each edge of the hull, transform the points to make it axis aligned - // and keep the AABB with the smallest area - - let minArea = Infinity; - let minBounds: Bounds = [0, 0, 0, 0]; - let minTI = mat2.create(); - - hull.forEach((point, i) => { - const side = vec2.sub(vec2.create(), point, hull[(i + 1) % hull.length]); - vec2.normalize(side, side); - - const ATI = mat2.invert(mat2.create(), [ - side[0], - side[1], - side[1], - -side[0], - ]); - if (!ATI) return; - - const box = bounds( - hull.map((p) => vec2.transformMat2(vec2.create(), p, ATI)), - ); - - const area = (box[1] - box[0]) * (box[3] - box[2]); - if (area < minArea) { - minArea = area; - minBounds = box; - minTI = ATI; - } - }); + const plane = toPlane(tri.norm); + const points2d = this.vertices.map((v) => plane(v.position)); + const { bounds: minBounds, TI: minTI } = minRect(convexHull(points2d)); const [xmin, xmax, ymin, ymax] = minBounds; const boxWidth = xmax - xmin; @@ -319,7 +279,30 @@ export class Polygon extends Object3D { // Fits the element around the dilated polygon #layout(points: ReadonlyVec2[], tri: Triangle): void { - const dilated = dilate(points, this.#edgeOverlap); + const [c0, c1, c2] = points.map(([x, y]) => vec3.fromValues(x, y, 0)); + const toMesh = new Triangle(c0, c1, c2, [0, 0, 1]).getTransformationTo(tri); + const fromMesh = mat4.invert(mat4.create(), toMesh); + // A folded side projects close to the edge, the plain offset fits it better + const project = (side: NeighbourSide | null) => { + if (side?.fold !== "smooth" || !fromMesh) return null; + const [x, y] = vec3.transformMat4(vec3.create(), side.point, fromMesh); + return vec2.fromValues(x, y); + }; + + const edges = this.#edges.map((sides): Edge => { + const offsets = sides.map((side) => { + if (side?.fold === "smooth") return this.#edgeOverlap; + if (!side || !this.#overlapFolds) return 0; + return side.fold === "concave" + ? this.#edgeOverlap + : this.#convexOverlap; + }); + return { + offset: Math.max(...offsets), + sides: [project(sides[0]), project(sides[1])], + }; + }); + const dilated = dilate(points, edges); const [xmin, xmax, ymin, ymax] = bounds(dilated); this.#blockWidth = xmax - xmin; @@ -334,12 +317,7 @@ export class Polygon extends Object3D { this.#clipPath = dilated.map(fit); this.#vertexPoints = points.map(fit); - const [c0, c1, c2] = points.map(([x, y]) => - vec3.fromValues(x - xmin, y - ymin, 0), - ); - this.setMatrix( - new Triangle(c0, c1, c2, [0, 0, 1]).getTransformationTo(tri), - ); + this.setMatrix(mat4.translate(toMesh, toMesh, [xmin, ymin, 0])); } #updateContent(): void { @@ -380,6 +358,22 @@ export class Polygon extends Object3D { vec2.transformMat3(vec2.create(), p, toUV), ); + // CSS has no clamped background, squeeze the mapping onto the texture instead + if (this.#clampUV) { + for (const k of [0, 1]) { + const values = uvs.map((uv) => uv[k]); + const lo = Math.min(0, ...values); + const hi = Math.max(1, ...values); + const c = (Math.min(...values) + Math.max(...values)) / 2; + let s = 1; + for (const uv of clipUVs) { + if (uv[k] < lo) s = Math.min(s, (c - lo) / (c - uv[k])); + if (uv[k] > hi) s = Math.min(s, (hi - c) / (uv[k] - c)); + } + for (const uv of [...uvs, ...clipUVs]) uv[k] = c + s * (uv[k] - c); + } + } + const [txmin, txmax, tymin, tymax] = bounds([...uvs, ...clipUVs]); const xsize = txmax - txmin; diff --git a/packages/c3r/src/math/MeshBuilder.ts b/packages/c3r/src/math/MeshBuilder.ts new file mode 100644 index 0000000..12f3137 --- /dev/null +++ b/packages/c3r/src/math/MeshBuilder.ts @@ -0,0 +1,223 @@ +import { mat3, vec2, vec3 } from "gl-matrix"; +import type { ReadonlyVec2, ReadonlyVec3 } from "gl-matrix"; + +import { collinear, convexHull, minRect, toPlane } from "./planar.js"; +import type { EdgeNeighbour, NeighbourSide } from "./planar.js"; +import { Triangle } from "./Triangle.js"; +import type { Vertex } from "./Vertex.js"; + +export interface MeshFace { + vertices: Vertex[]; + edges: EdgeNeighbour[]; + unit: number; +} + +// cos 45° +const SMOOTH_FOLD = 0.7; + +// Of the mesh size, models round their coordinates +const WELD = 1e-3; + +// A separate polygon costs a layer and a seam, worth some rasterized area +const MERGE_SLACK = 1.25; + +interface Tri { + corners: readonly Vertex[]; + ids: number[]; + triangle: Triangle; + group: number; + face?: Face; +} + +interface Face { + seed: Tri; + tris: Tri[]; + ids: number[]; +} + +const key = (a: number, b: number) => `${a},${b}`; +const at = (loop: T[], i: number) => loop[(i + loop.length) % loop.length]; +const edgesOf = (ids: number[]) => ids.map((a, i) => [a, at(ids, i + 1)]); + +const uvMap = ({ corners, triangle }: Tri) => { + const plane = toPlane(triangle.norm); + const columns = ([a, b, c]: ReadonlyVec2[]) => + mat3.fromValues(a[0], a[1], 1, b[0], b[1], 1, c[0], c[1], 1); + const M = mat3.invert( + mat3.create(), + columns(corners.map((v) => plane(v.position))), + )!; + mat3.mul(M, columns(corners.map((v) => v.uv)), M); + return (p: ReadonlyVec3) => vec2.transformMat3(vec2.create(), plane(p), M); +}; + +// Shared edges run both ways and cancel out, null on a hole or pinch +const union = (face: number[], tri: number[]) => { + const edges = [...edgesOf(face), ...edgesOf(tri)]; + const all = new Set(edges.map(([a, b]) => key(a, b))); + const out = new Map(); + for (const [a, b] of edges) { + if (all.has(key(b, a))) continue; + if (out.has(a)) return null; + out.set(a, b); + } + const [start] = out.keys(); + const loop = [start]; + for (let v = out.get(start)!; v !== start; v = out.get(v)!) loop.push(v); + return loop.length === out.size ? loop : null; +}; + +export class MeshBuilder { + #eps = 0; + #positions: ReadonlyVec3[] = []; + #faces: Face[] = []; + #borders = new Map(); + + constructor( + groups: readonly (readonly (readonly Vertex[])[])[], + { uvTolerance = 1e-3 } = {}, + ) { + const tris = this.#weld(groups); + const ownersOf = () => + new Map( + tris.flatMap((t) => edgesOf(t.ids).map(([a, b]) => [key(a, b), t])), + ); + this.#splitTJunctions(tris, ownersOf()); + const owners = ownersOf(); + + tris.sort( + (a, b) => b.triangle.calculateArea() - a.triangle.calculateArea(), + ); + for (const seed of tris) + if (!seed.face) this.#merge(seed, owners, uvTolerance); + + for (const face of this.#faces) + face.ids.forEach((a, i) => + this.#borders.set(key(a, at(face.ids, i + 1)), { face, i }), + ); + } + + faces(group: number): MeshFace[] { + return this.#faces + .filter((face) => face.seed.group === group) + .map((face) => this.#output(face)); + } + + #weld(groups: readonly (readonly (readonly Vertex[])[])[]): Tri[] { + const points = groups.flat(2).map((v) => v.position); + const span = [0, 1, 2].map((k) => { + const values = points.map((p) => p[k]); + return Math.max(...values) - Math.min(...values); + }); + this.#eps = WELD * Math.hypot(...span) || 1e-9; + + // ponytail: linear scan, a spatial grid when meshes get large + const weld = (p: ReadonlyVec3) => { + const id = this.#positions.findIndex((q) => vec3.dist(p, q) <= this.#eps); + return id >= 0 ? id : this.#positions.push(p) - 1; + }; + + return groups.flatMap((triangles, group) => + triangles.flatMap((corners) => { + const [p, q, r] = corners.map((v) => v.position); + const triangle = new Triangle(p, q, r); + const ids = corners.map((v) => weld(v.position)); + const longest = Math.max(...triangle.calculateEdgeLengths()); + const sliver = triangle.calculateArea() <= 1e-6 * longest ** 2; + if (sliver || new Set(ids).size < 3) return []; + return [{ corners, ids, triangle, group }]; + }), + ); + } + + // So a long edge pairs up with the shorter edges of its neighbours + #splitTJunctions(tris: Tri[], owners: Map): void { + const all = [...this.#positions.keys()]; + for (const t of tris) + t.ids = edgesOf(t.ids).flatMap(([a, b]) => { + if (owners.has(key(b, a))) return [a]; + const [p, q] = [this.#positions[a], this.#positions[b]]; + const from = (id: number) => vec3.dist(p, this.#positions[id]); + const to = (id: number) => vec3.dist(this.#positions[id], q); + const detour = (id: number) => from(id) + to(id) - vec3.dist(p, q); + const inner = all.filter( + (id) => + from(id) > this.#eps && + to(id) > this.#eps && + detour(id) < this.#eps ** 2 / vec3.dist(p, q), + ); + return [a, ...inner.sort((i, j) => from(i) - from(j))]; + }); + } + + #merge(seed: Tri, owners: Map, uvTolerance: number): void { + const plane = toPlane(seed.triangle.norm); + const toUV = uvMap(seed); + const rect = (ids: number[]) => + minRect(convexHull(ids.map((id) => plane(this.#positions[id])))).area; + const fits = (t: Tri) => + t.group === seed.group && + vec3.dot(t.triangle.norm, seed.triangle.norm) > 1 - 1e-6 && + t.corners.every((v) => vec2.dist(toUV(v.position), v.uv) <= uvTolerance); + + const face: Face = { seed, tris: [seed], ids: seed.ids }; + this.#faces.push((seed.face = face)); + for (const t of face.tris) + for (const [a, b] of edgesOf(t.ids)) { + const neighbour = owners.get(key(b, a)); + if (!neighbour || neighbour.face || !fits(neighbour)) continue; + const ids = union(face.ids, neighbour.ids); + const cost = (rect(face.ids) + rect(neighbour.ids)) * MERGE_SLACK; + if (!ids || rect(ids) > cost) continue; + face.ids = ids; + face.tris.push(neighbour); + neighbour.face = face; + } + } + + #side(face: Face, a: number, b: number, end: 0 | 1): NeighbourSide | null { + const hit = this.#borders.get(key(b, a)); + if (!hit) return null; + const normal = face.seed.triangle.norm; + const dot = vec3.dot(hit.face.seed.triangle.norm, normal); + // The back of a double-sided sheet is no neighbour + if (dot < -0.99) return null; + const from = this.#positions[end ? b : a]; + const point = + this.#positions[at(hit.face.ids, end ? hit.i - 1 : hit.i + 2)]; + const rise = vec3.dot(vec3.sub(vec3.create(), point, from), normal); + const fold = + dot >= SMOOTH_FOLD ? "smooth" : rise > this.#eps ? "concave" : "convex"; + return { point, fold }; + } + + #output(face: Face): MeshFace { + const { ids } = face; + const toUV = uvMap(face.seed); + const point = (i: number) => this.#positions[at(ids, i)]; + // T-junctions leave straight vertices on the border + const corners = ids.flatMap((_, i) => + collinear(point(i - 1), point(i), point(i + 1)) ? [] : [i], + ); + + const sizes = face.tris.map(({ triangle: t }) => [ + t.calculateArea(), + Math.max(...t.calculateEdgeLengths()), + ]); + const area = sizes.reduce((sum, [w]) => sum + w, 0); + return { + vertices: corners.map((i) => ({ + position: point(i), + uv: toUV(point(i)), + })), + edges: corners.map((i, k): EdgeNeighbour => { + const j = at(corners, k + 1); + return [ + this.#side(face, ids[i], at(ids, i + 1), 0), + this.#side(face, at(ids, j - 1), at(ids, j), 1), + ]; + }), + unit: sizes.reduce((sum, [w, l]) => sum + w * l, 0) / area, + }; + } +} diff --git a/packages/c3r/src/math/planar.ts b/packages/c3r/src/math/planar.ts new file mode 100644 index 0000000..e8a0abc --- /dev/null +++ b/packages/c3r/src/math/planar.ts @@ -0,0 +1,93 @@ +import { + mat2, + vec2, + vec3, + type ReadonlyVec2, + type ReadonlyVec3, +} from "gl-matrix"; + +import type { Vertex } from "./Vertex.js"; + +export type Fold = "smooth" | "concave" | "convex"; + +export interface NeighbourSide { + point: ReadonlyVec3; + fold: Fold; +} + +export type EdgeNeighbour = [NeighbourSide | null, NeighbourSide | null]; + +export const cross2 = (a: ReadonlyVec2, b: ReadonlyVec2, o: ReadonlyVec2) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + +export const bounds = (points: ReadonlyVec2[]) => { + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + return [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; +}; + +export const convexHull = (points: ReadonlyVec2[]) => { + const chain = (sorted: ReadonlyVec2[]) => { + const out: ReadonlyVec2[] = []; + for (const p of sorted) { + while (out.length > 1 && cross2(out.at(-2)!, out.at(-1)!, p) <= 0) + out.pop(); + out.push(p); + } + return out.slice(0, -1); + }; + const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); + return [...chain(sorted), ...chain(sorted.reverse())]; +}; + +export const minRect = (hull: ReadonlyVec2[]) => { + const boxes = hull.map((p, i) => { + const [x, y] = vec2.normalize( + vec2.create(), + vec2.sub(vec2.create(), p, hull[(i + 1) % hull.length]), + ); + // A reflection, so it is its own inverse + const TI = mat2.fromValues(x, y, y, -x); + const box = bounds( + hull.map((q) => vec2.transformMat2(vec2.create(), q, TI)), + ); + return { area: (box[1] - box[0]) * (box[3] - box[2]), bounds: box, TI }; + }); + return boxes.reduce((a, b) => (b.area < a.area ? b : a)); +}; + +export const toPlane = (normal: ReadonlyVec3) => { + const axis = Math.abs(normal[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]; + const u = vec3.cross(vec3.create(), normal, axis); + vec3.normalize(u, u); + const v = vec3.cross(vec3.create(), u, normal); + return (p: ReadonlyVec3) => vec2.fromValues(vec3.dot(p, u), vec3.dot(p, v)); +}; + +const direction = (a: ReadonlyVec3, b: ReadonlyVec3) => { + const d = vec3.sub(vec3.create(), b, a); + return vec3.normalize(d, d); +}; + +export const collinear = (a: ReadonlyVec3, b: ReadonlyVec3, c: ReadonlyVec3) => + vec3.equals(a, b) || + Math.abs(vec3.dot(direction(a, b), direction(b, c))) >= 0.9999; + +export const simplify = (input: readonly Vertex[]): Vertex[] => { + const vertices = [...input]; + for (let i = 0, kept = 0; kept < vertices.length && vertices.length > 3;) { + const n = vertices.length; + const [a, b, c] = [i + n - 1, i, i + 1].map( + (k) => vertices[k % n].position, + ); + if (collinear(a, b, c)) { + vertices.splice(i, 1); + i %= vertices.length; + kept = 0; + } else { + i = (i + 1) % n; + kept++; + } + } + return vertices; +}; diff --git a/packages/c3r/test/c3r.test.ts b/packages/c3r/test/c3r.test.ts index cf85566..696dc7c 100644 --- a/packages/c3r/test/c3r.test.ts +++ b/packages/c3r/test/c3r.test.ts @@ -6,6 +6,7 @@ import { ElementObject, Group, Material, + MeshBuilder, OrthographicCamera, PerspectiveCamera, Polygon, @@ -54,10 +55,10 @@ describe("Polygon", () => { for (const v of vertices) { const [x, y, z] = vec3.transformMat4(vec3.create(), v.position, MI); expect(Math.abs(z)).toBeLessThan(1e-6); - expect(x).toBeGreaterThanOrEqual(-1e-6); - expect(x).toBeLessThanOrEqual(width + 1e-6); - expect(y).toBeGreaterThanOrEqual(-1e-6); - expect(y).toBeLessThanOrEqual(height + 1e-6); + expect(x).toBeGreaterThanOrEqual(-1e-3); + expect(x).toBeLessThanOrEqual(width + 1e-3); + expect(y).toBeGreaterThanOrEqual(-1e-3); + expect(y).toBeLessThanOrEqual(height + 1e-3); } // No shear @@ -136,9 +137,34 @@ describe("Polygon", () => { }); }); +describe("Polygon.clampUV", () => { + it("keeps the overlap on the texture", () => { + const square = [ + vertex(0, 0, 0, 0, 0), + vertex(1, 0, 0, 1, 0), + vertex(1, 1, 0, 1, 1), + vertex(0, 1, 0, 0, 1), + ]; + const poly = new Polygon(square, { + edges: square.map(() => [{ point: [0, 0, 1], fold: "concave" }, null]), + }); + const { style } = poly.contentElement; + + expect(parseFloat(style.backgroundSize)).toBeLessThan(100); + poly.clampUV = true; + for (const size of style.backgroundSize.split(" ")) + expect(parseFloat(size)).toBeCloseTo(100); + }); +}); + describe("Group.addTriangles", () => { - it("merges the triangles of each cube face into a quad", () => { - // From examples/cube.html + const size = (poly: Polygon) => + ["--c3r-width", "--c3r-height"].map((p) => + parseFloat(poly.domElement.style.getPropertyValue(p)), + ); + + // From examples/cube.html + const cube = () => { // prettier-ignore const positions = [100, 100, 100, 100, 100, -100, 100, -100, 100, 100, -100, -100, -100, 100, -100, -100, 100, 100, -100, -100, -100, -100, -100, 100, -100, 100, -100, 100, 100, -100, -100, 100, 100, 100, 100, 100, -100, -100, 100, 100, -100, 100, -100, -100, -100, 100, -100, -100, -100, 100, 100, 100, 100, 100, -100, -100, 100, 100, -100, 100, 100, 100, -100, -100, 100, -100, 100, -100, -100, -100, -100, -100]; // prettier-ignore @@ -159,12 +185,147 @@ describe("Group.addTriangles", () => { for (let i = 0; i < indices.length; i += 3) { triangles.push([at(indices[i]), at(indices[i + 1]), at(indices[i + 2])]); } + return triangles; + }; + + it("merges the triangles of each cube face into a quad", () => { + const group = new Group(); + group.addTriangles(cube()); + + expect(group.children).toHaveLength(6); + for (const child of group.children) { + const poly = child as Polygon; + expect(poly.vertices).toHaveLength(4); + for (const length of size(poly)) expect(length).toBeCloseTo(250.8); + poly.overlapFolds = false; + expect(size(poly)).toEqual([250, 250]); + } + }); + + it("merges the faces of a cube in any triangle order", () => { const group = new Group(); - group.addTriangles(triangles); + const [a, b, ...rest] = cube(); + group.addTriangles([a, ...rest.reverse(), b]); expect(group.children).toHaveLength(6); - for (const child of group.children) - expect((child as Polygon).vertices).toHaveLength(4); + }); + + it("keeps the detail of the triangles it merges", () => { + const at = (x: number, y: number) => vertex(x, y, 0, x / 4, y); + const group = new Group(); + group.addTriangles( + [0, 1, 2, 3].flatMap((x) => [ + [at(x, 0), at(x + 1, 0), at(x + 1, 1)], + [at(x, 0), at(x + 1, 1), at(x, 1)], + ]), + ); + + expect(group.children).toHaveLength(1); + // 250px per triangle diagonal + expect(Math.max(...size(group.children[0] as Polygon))).toBeCloseTo( + (250 / Math.SQRT2) * 4, + ); + }); + + it("overlaps concave folds unless told not to", () => { + const group = new Group(); + group.addTriangles(cube().map((tri) => tri.reverse())); + + for (const child of group.children) { + const poly = child as Polygon; + expect(size(poly)).toEqual([252, 252]); + poly.overlapFolds = false; + expect(size(poly)).toEqual([250, 250]); + } + }); + + it("overlaps only the edges towards a smooth neighbour", () => { + const group = new Group(); + group.addTriangles([ + [vertex(0, 0, 0, 0, 0), vertex(1, 0, 0, 1, 0), vertex(1, 1, 0, 1, 1)], + [vertex(0, 0, 0, 0, 0), vertex(1, 1, 0, 1, 1), vertex(0, 1, 0, 0.5, 1)], + ]); + + for (const child of group.children) { + const [width, height] = size(child as Polygon); + // The overlap does not extend the outer edges past the corners + expect(width).toBeCloseTo(250); + expect(height).toBeCloseTo(126); + } + }); + + it("does not poke out of the mesh at a sharp corner on its border", () => { + const group = new Group(); + // The texture mapping breaks between the triangles, so they stay apart + group.addTriangles([ + [vertex(0, 0, 0, 0, 0), vertex(1, 4, 0, 1, 0), vertex(-1, 4, 0, 0, 1)], + [vertex(0, 0, 0, 0, 0), vertex(1, 0, 0, 1, 1), vertex(1, 4, 0, 0, 1)], + [vertex(0, 0, 0, 0, 0), vertex(-1, 4, 0, 1, 0), vertex(-1, 0, 0, 0, 1)], + ]); + + const poly = group.children[0] as Polygon; + const [width, height] = size(poly); + const clip = (poly.domElement.firstElementChild as HTMLElement).style + .clipPath; + for (const [, x, y] of clip.matchAll(/([-\d.e]+)% ([-\d.e]+)%/g)) { + const p = vec3.fromValues((+x / 100) * width, (+y / 100) * height, 0); + vec3.transformMat4(p, p, poly.matrix); + expect(p[1]).toBeGreaterThan(-1e-6); + } + }); + + it("keeps a vertex shared by many faces covered", () => { + const tris = Array.from({ length: 8 }, (_, i) => { + const [a, b] = [i, i + 1].map((k) => (k * Math.PI) / 4); + // The texture mapping breaks between the triangles, so they stay apart + return [ + vertex(0, 0, 0, i, 0), + vertex(Math.cos(a), Math.sin(a), 0, i + 1, 0), + vertex(Math.cos(b), Math.sin(b), 0, i, 1), + ]; + }); + const group = new Group(); + group.addTriangles(tris); + + for (const child of group.children) { + const poly = child as Polygon; + const [width, height] = size(poly); + const clip = (poly.domElement.firstElementChild as HTMLElement).style + .clipPath; + const points = [...clip.matchAll(/([-\d.e]+)% ([-\d.e]+)%/g)].map( + ([, x, y]) => { + const p = vec3.fromValues((+x / 100) * width, (+y / 100) * height, 0); + return vec3.transformMat4(p, p, poly.matrix); + }, + ); + // The centre is on or inside every edge of the clip path + points.forEach((a, i) => { + const b = points[(i + 1) % points.length]; + expect(a[0] * b[1] - a[1] * b[0]).toBeGreaterThan(-1e-9); + }); + } + }); + + it("overlaps towards a neighbour whose vertices are slightly off", () => { + const group = new Group(); + group.addTriangles([ + [vertex(0, 0, 0, 0, 0), vertex(1, 0, 0, 1, 0), vertex(1, 1, 0, 1, 1)], + [vertex(0, 0, 0), vertex(1, 1.0005, 0), vertex(0, 1, 0)], + ]); + + expect(size(group.children[0] as Polygon)[1]).toBeCloseTo(126); + }); + + it("overlaps an edge facing a T-junction", () => { + const group = new Group(); + // The texture mapping breaks between the triangles, so they stay apart + group.addTriangles([ + [vertex(0, 0, 0, 0, 0), vertex(1, -1, 0, 1, 0), vertex(2, 0, 0, 0, 1)], + [vertex(0, 0, 0), vertex(1, 0, 0), vertex(0, 1, 0)], + [vertex(1, 0, 0), vertex(2, 0, 0), vertex(2, 1, 0)], + ]); + + expect(size(group.children[0] as Polygon)).toEqual([250, 126]); }); it("keeps triangles apart when the texture mapping is not continuous", () => { @@ -189,6 +350,72 @@ describe("Group.addTriangles", () => { }); }); +describe("MeshBuilder", () => { + // Unit squares at the given corners, uv mapped by position + const squares = (...corners: [number, number][]) => + corners.flatMap(([x, y]) => { + const at = (dx: number, dy: number) => + vertex(x + dx, y + dy, 0, (x + dx) / 4, (y + dy) / 4); + return [ + [at(0, 0), at(1, 0), at(1, 1)], + [at(0, 0), at(1, 1), at(0, 1)], + ]; + }); + + const area = ({ vertices }: { vertices: Vertex[] }) => + vertices.reduce((s, { position: [x1, y1] }, i) => { + const [x2, y2] = vertices[(i + 1) % vertices.length].position; + return s + (x1 * y2 - x2 * y1) / 2; + }, 0); + + it("merges within the uv tolerance", () => { + const [a, b] = squares([0, 0]); + const off = { ...b[2], uv: [b[2].uv[0] + 5e-4, b[2].uv[1]] as const }; + + expect(new MeshBuilder([[a, [b[0], b[1], off]]]).faces(0)).toHaveLength(1); + expect( + new MeshBuilder([[a, [b[0], b[1], off]]], { uvTolerance: 1e-4 }).faces(0), + ).toHaveLength(2); + }); + + it("does not grow the rectangle of a face by merging", () => { + const faces = new MeshBuilder([squares([0, 0], [1, 0], [0, 1])]).faces(0); + + expect(faces).toHaveLength(2); + expect(faces.map((f) => f.vertices.length)).toEqual([4, 4]); + }); + + it("keeps the faces of a ring free of holes", () => { + // A square with a hole, its first triangle spans the whole square + const at = (x: number, y: number) => vertex(x, y, 0, x / 4, y / 4); + const [a, b, c, d] = [at(0, 0), at(4, 4), at(0, 4), at(4, 0)]; + const hole = [at(0.5, 2.5), at(1.5, 2.5), at(1.5, 3.5), at(0.5, 3.5)]; + const faces = new MeshBuilder([ + [ + [a, d, b], + [a, b, hole[1]], + [b, hole[2], hole[1]], + [b, c, hole[2]], + [c, hole[3], hole[2]], + [c, a, hole[3]], + [a, hole[0], hole[3]], + [a, hole[1], hole[0]], + ], + ]).faces(0); + + expect(faces.length).toBeGreaterThan(1); + expect(faces.reduce((s, f) => s + area(f), 0)).toBeCloseTo(16 - 1); + }); + + it("resolves the neighbour of an edge in another group", () => { + const floor = [vertex(0, 0, 0), vertex(1, 0, 0), vertex(0, 1, 0)]; + const wall = [vertex(1, 0, 0), vertex(0, 0, 0), vertex(0, 0, 1)]; + const [face] = new MeshBuilder([[floor], [wall]]).faces(0); + + expect(face.edges[0][0]).toEqual({ point: [0, 0, 1], fold: "concave" }); + }); +}); + describe("Group", () => { it("moves a child out of its previous parent", () => { const a = new Group(); From 3a9c82f698988fa9f82e1a1095331f8edccfe420 Mon Sep 17 00:00:00 2001 From: K-Adam Date: Mon, 26 Oct 2026 19:38:05 +0100 Subject: [PATCH 3/9] Touchscreen controls --- examples/package.json | 1 + examples/three_fps.html | 114 +++++++++++++++++++++++++---- examples/three_geometry_earth.html | 3 +- package-lock.json | 8 ++ 4 files changed, 112 insertions(+), 14 deletions(-) diff --git a/examples/package.json b/examples/package.json index 7780dd3..91fbf27 100644 --- a/examples/package.json +++ b/examples/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "gl-matrix": "^3.4.4", + "nipplejs": "^1.0.4", "three": "^0.186.1", "vite": "^8.3.5" } diff --git a/examples/three_fps.html b/examples/three_fps.html index 42ec237..a77cc93 100644 --- a/examples/three_fps.html +++ b/examples/three_fps.html @@ -9,6 +9,7 @@ background-color: rgb(200, 200, 200); margin: 0px; overflow: hidden; + touch-action: none; } #info { @@ -26,6 +27,24 @@ a { color: #ffffff; } + + #joystick { + position: absolute; + left: 0; + bottom: 0; + width: 160px; + height: 160px; + } + + #jump { + position: absolute; + right: 40px; + bottom: 50px; + width: 70px; + height: 70px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + } @@ -36,12 +55,16 @@ Jochum Skoglund.
Click to look around with the mouse, WASD to move, SHIFT to run - and SPACE to jump. + and SPACE to jump.
On touch: joystick to move, drag to look, button + to jump. +
+