Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/support-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 5 additions & 9 deletions src/conversions/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
110 changes: 96 additions & 14 deletions src/conversions/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
);
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down
91 changes: 90 additions & 1 deletion tests/converter.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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: {
Expand Down
Loading