diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 7e20b82..3860c7c 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -5,15 +5,15 @@ objects fail with `unsupported_message` and do not modify the scene. ## Geometry -| Status | Objects | Notes | -| --------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| Included | Box, Capsule, Circle, Cone, Cylinder, Line, Point, Pointcloud, Polyline, Sphere, Torus | Native Three.js representations | -| Included | Mesh, Polyhedron | Polygonal faces use fan triangulation; complex faces should be triangulated upstream | -| Included helpers | Frame, Plane, Vector | Frame and Vector are visual helpers; Plane is displayed as a finite surface | -| Python mesh path | Brep | Python sends its view mesh while retaining the Brep identity for callbacks | -| Deferred | Arc, Bezier, Ellipse, Hyperbola, Parabola, Polygon, Graph | Planned after 1.0 | -| Not top-level objects | MeshFaceList, PolyhedronFace | Internal protobuf helper types | -| Non-renderable data | Projection, Quaternion, Reflection, Rotation, Scale, Shear, Transformation, Translation | Deliberately rejected as scene geometry | +| Status | Objects | Notes | +| --------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Included | Arc, Box, Capsule, Circle, Cone, Cylinder, Line, Point, Pointcloud, Polygon, Polyline, Sphere, Torus | Native Three.js representations | +| Included | Mesh, Polyhedron | Polygonal faces use fan triangulation; complex faces should be triangulated upstream | +| Included helpers | Frame, Plane, Vector | Frame and Vector are visual helpers; Plane is displayed as a finite surface | +| Python mesh path | Brep | Python sends its view mesh while retaining the Brep identity for callbacks | +| Deferred | Bezier, Ellipse, Hyperbola, Parabola, Graph | Planned after 1.0 | +| Not top-level objects | MeshFaceList, PolyhedronFace | Internal protobuf helper types | +| Non-renderable data | Projection, Quaternion, Reflection, Rotation, Scale, Shear, Transformation, Translation | Deliberately rejected as scene geometry | Circle is currently displayed as a filled disc. Native converters are retained; objects are not converted to meshes unless their integration explicitly does so, diff --git a/src/conversions/converter.ts b/src/conversions/converter.ts index 4650e6c..5905ae9 100644 --- a/src/conversions/converter.ts +++ b/src/conversions/converter.ts @@ -37,15 +37,7 @@ import * as THREE from "three"; import * as GEOCONV from "./geometry"; import * as DATASTRUCTCONV from "./datastructures"; -const UNIMPLEMENTED_RENDERABLES = [ - Arc, - Bezier, - Ellipse, - Graph, - Hyperbola, - Parabola, - Polygon, -]; +const UNIMPLEMENTED_RENDERABLES = [Bezier, Ellipse, Graph, Hyperbola, Parabola]; const NON_RENDERABLES = [ Projection, Quaternion, @@ -91,6 +83,8 @@ export function convertToThreeJSGeometry(object: unknown): THREE.Object3D { } switch (true) { + case object instanceof Arc: + return GEOCONV.arcToThreeJS(object); case object instanceof Box: return GEOCONV.boxToThreeJS(object); case object instanceof Capsule: @@ -111,6 +105,8 @@ export function convertToThreeJSGeometry(object: unknown): THREE.Object3D { return GEOCONV.pointToThreeJS(object); case object instanceof Pointcloud: return GEOCONV.pointcloudToThreeJS(object); + case object instanceof Polygon: + return GEOCONV.polygonToThreeJS(object); case object instanceof Polyline: return GEOCONV.polylineToThreeJS(object); case object instanceof Sphere: diff --git a/src/conversions/geometry.ts b/src/conversions/geometry.ts index abdfb83..fd0d6c8 100644 --- a/src/conversions/geometry.ts +++ b/src/conversions/geometry.ts @@ -178,17 +178,53 @@ function positionsFromPoints(points: readonly Point[]): Float32Array { } /** - * Convert a COMPAS Arc to a THREE.js object. + * Convert a COMPAS Arc to a THREE.Line. * - * NOTE: This function is currently unimplemented and will throw. The intended - * implementation should sample the arc (or use THREE.ArcCurve) and return a - * visible representation (e.g. a THREE.Line or a thin THREE.Mesh). + * Samples the arc between its start and end angle, measured in the plane of + * its circle's frame from the frame's x-axis, like COMPAS does. * * @param arc - COMPAS Arc protobuf object - * @returns A THREE object representing the arc (Line or Mesh) + * @param segments - number of segments for a full circle; an arc uses its share + * @returns A THREE.Line along the arc */ -export function arcToThreeJS(_arc: Arc) { - throw new Error("Method not implemented."); +export function arcToThreeJS(arc: Arc, segments = 64): THREE.Line { + const circle = arc.circle!; + const frame = circle.frame!; + const origin = new THREE.Vector3( + frame.point!.x, + frame.point!.y, + frame.point!.z, + ); + const xaxis = new THREE.Vector3( + frame.xaxis!.x, + frame.xaxis!.y, + frame.xaxis!.z, + ).normalize(); + const yaxis = new THREE.Vector3( + frame.yaxis!.x, + frame.yaxis!.y, + frame.yaxis!.z, + ).normalize(); + const sweep = arc.endAngle - arc.startAngle; + const count = Math.max( + 2, + Math.ceil((Math.abs(sweep) / (2 * Math.PI)) * segments), + ); + const points: THREE.Vector3[] = []; + for (let i = 0; i <= count; i++) { + const angle = arc.startAngle + (sweep * i) / count; + points.push( + origin + .clone() + .addScaledVector(xaxis, circle.radius * Math.cos(angle)) + .addScaledVector(yaxis, circle.radius * Math.sin(angle)), + ); + } + const geometry = new THREE.BufferGeometry().setFromPoints(points); + return new THREE.Line( + geometry, + new THREE.LineBasicMaterial({ color: 0x000000 }), + ); } /** @@ -478,18 +514,64 @@ export function pointcloudToThreeJS(pointcloud: Pointcloud): THREE.Points { } /** - * Convert a COMPAS Polygon to a THREE.Mesh. + * Convert a COMPAS Polygon to a filled THREE.Mesh with its outline as a child + * THREE.LineLoop. * - * NOTE: This function is currently unimplemented and will throw. The intended - * implementation should triangulate the polygon (possibly using a fan - * triangulation or a proper earcut library) and return a mesh with a - * BufferGeometry. + * The face is triangulated with three.js's earcut (`ShapeUtils`) in the + * polygon's own plane, found with Newell's method, so concave polygons fill + * correctly. A horizontal polygon's face always points up, whichever way its + * points run, so it's visible from above. * * @param polygon - COMPAS Polygon protobuf object * @returns THREE.Mesh representing the filled polygon */ -export function polygonToThreeJS(_polygon: Polygon): THREE.Mesh { - throw new Error("Not implemented"); +export function polygonToThreeJS(polygon: Polygon): THREE.Mesh { + let points = polygon.points.map((p) => new THREE.Vector3(p.x, p.y, p.z)); + const first = points[0]; + const last = points[points.length - 1]; + // A closing point repeating the first one isn't a vertex of its own. + if (points.length > 3 && first && last && first.distanceTo(last) < 1e-9) { + points = points.slice(0, -1); + } + + const normal = new THREE.Vector3(); + points.forEach((current, i) => { + const next = points[(i + 1) % points.length]!; + normal.x += (current.y - next.y) * (current.z + next.z); + normal.y += (current.z - next.z) * (current.x + next.x); + normal.z += (current.x - next.x) * (current.y + next.y); + }); + if (normal.lengthSq() < 1e-18) normal.set(0, 0, 1); + normal.normalize(); + if (normal.z < -1e-6) { + points = [...points].reverse(); + normal.negate(); + } + + // Any in-plane basis works for triangulating. + const u = new THREE.Vector3() + .crossVectors( + Math.abs(normal.z) < 0.9 + ? new THREE.Vector3(0, 0, 1) + : new THREE.Vector3(1, 0, 0), + normal, + ) + .normalize(); + const v = new THREE.Vector3().crossVectors(normal, u); + const contour = points.map((p) => new THREE.Vector2(p.dot(u), p.dot(v))); + const triangles = THREE.ShapeUtils.triangulateShape(contour, []); + + const geometry = new THREE.BufferGeometry().setFromPoints(points); + geometry.setIndex(triangles.flat()); + geometry.computeVertexNormals(); + const mesh = new THREE.Mesh(geometry); + + const outline = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ color: 0x000000 }), + ); + mesh.add(outline); + return mesh; } /** diff --git a/tests/converter.test.ts b/tests/converter.test.ts index c49cce0..58fdae7 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -1,4 +1,10 @@ -import { Box, Graph, Quaternion } from "@gramaziokohler/compas-pb-ts"; +import { + Arc, + Box, + Graph, + Polygon, + Quaternion, +} from "@gramaziokohler/compas-pb-ts"; import * as THREE from "three"; import { describe, expect, it } from "vitest"; @@ -32,6 +38,89 @@ describe("convertToThreeJSGeometry", () => { expect(converted.position.toArray()).toEqual([4, 5, 6]); }); + it("fills a concave polygon, facing up, with its outline", () => { + // An L shape of area 3, drawn clockwise seen from above, with a closing + // point repeating the first. + const corners = [ + [0, 0], + [0, 2], + [1, 2], + [1, 1], + [2, 1], + [2, 0], + [0, 0], + ]; + const polygon = new Polygon({ + data: { + guid: "polygon-guid", + name: "Polygon", + points: corners.flatMap(([x, y]) => [x!, y!, 0]), + }, + }); + + const mesh = convertToThreeJSGeometry(polygon) as THREE.Mesh; + + expect(mesh).toBeInstanceOf(THREE.Mesh); + // Six corners - the closing point is dropped - and four triangles. + expect(mesh.geometry.getAttribute("position").count).toBe(6); + expect(mesh.geometry.getIndex()!.count).toBe(12); + const normal = mesh.geometry.getAttribute("normal"); + expect(normal.getZ(0)).toBeCloseTo(1, 6); + const area = (() => { + const index = mesh.geometry.getIndex()!; + const position = mesh.geometry.getAttribute("position"); + const at = (i: number) => + new THREE.Vector3().fromBufferAttribute(position, index.getX(i)); + let total = 0; + for (let i = 0; i < index.count; i += 3) { + total += new THREE.Triangle(at(i), at(i + 1), at(i + 2)).getArea(); + } + return total; + })(); + expect(area).toBeCloseTo(3, 6); + expect(mesh.children[0]).toBeInstanceOf(THREE.LineLoop); + }); + + it("samples an arc between its start and end angles", () => { + const arc = new Arc({ + data: { + guid: "arc-guid", + name: "Arc", + circle: { + guid: "", + name: "Circle", + radius: 2, + frame: { + guid: "", + name: "Frame", + point: { guid: "", name: "", x: 1, y: 1, z: 0 }, + xaxis: { guid: "", name: "", x: 1, y: 0, z: 0 }, + yaxis: { guid: "", name: "", x: 0, y: 1, z: 0 }, + }, + }, + // compas-pb-ts 2.0.0 rejects an angle of exactly 0 as "missing". + startAngle: Math.PI / 2, + endAngle: Math.PI, + }, + }); + + const line = convertToThreeJSGeometry(arc) as THREE.Line; + + expect(line).toBeInstanceOf(THREE.Line); + const position = line.geometry.getAttribute("position"); + const start = new THREE.Vector3().fromBufferAttribute(position, 0); + const end = new THREE.Vector3().fromBufferAttribute( + position, + position.count - 1, + ); + expect(start.toArray().map((v) => +v.toFixed(6))).toEqual([1, 3, 0]); + expect(end.toArray().map((v) => +v.toFixed(6))).toEqual([-1, 1, 0]); + for (let i = 0; i < position.count; i++) { + const point = new THREE.Vector3().fromBufferAttribute(position, i); + expect(point.distanceTo(new THREE.Vector3(1, 1, 0))).toBeCloseTo(2, 5); + } + }); + it("rejects mathematical data instead of adding it to a scene", () => { const quaternion = new Quaternion({ data: {