From 0cb911f0adfcf826c0f4256ae271c1e49f39074d Mon Sep 17 00:00:00 2001 From: Tomasz Cierpisz Date: Thu, 8 Oct 2026 18:23:36 +0200 Subject: [PATCH 1/3] Improve thumbnail rendering and sweep output https://github.com/CommonWealthRobotics/CaDoodle-Application/issues/313 --- .../bowlerstudio/creature/ThumbnailImage.java | 197 +++++++++++++----- .../scripting/cadoodle/Sweep.java | 23 +- 2 files changed, 150 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java b/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java index 7ced0305..15701b03 100644 --- a/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java +++ b/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java @@ -14,7 +14,6 @@ import com.neuronrobotics.bowlerstudio.BowlerKernel; import com.neuronrobotics.bowlerstudio.physics.TransformFactory; -import com.neuronrobotics.sdk.addons.kinematics.math.RotationNR; import com.neuronrobotics.sdk.addons.kinematics.math.TransformNR; import com.neuronrobotics.sdk.common.Log; @@ -24,22 +23,32 @@ import eu.mihosoft.vrl.v3d.Vector3d; import eu.mihosoft.vrl.v3d.parametrics.CSGDatabaseInstance; import javafx.application.Platform; +import javafx.geometry.Point3D; +import javafx.scene.AmbientLight; import javafx.embed.swing.SwingFXUtils; import javafx.scene.Group; import javafx.scene.Scene; import javafx.scene.SceneAntialiasing; import javafx.scene.SnapshotParameters; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.image.PixelReader; import javafx.scene.image.WritableImage; import javafx.scene.paint.Color; import javafx.scene.paint.PhongMaterial; import javafx.scene.shape.CullFace; import javafx.scene.shape.DrawMode; import javafx.scene.shape.MeshView; -import javafx.scene.transform.Transform; -import javafx.scene.PerspectiveCamera; import javafx.scene.transform.Affine; +import javafx.scene.PerspectiveCamera; +import javafx.scene.PointLight; public class ThumbnailImage implements ImagePorviderInterface { + public static final String CACHE_VERSION = "11"; + private static final int THUMBNAIL_MARGIN = 24; + private static final double CAMERA_AZIMUTH = -40.0; + private static final double CAMERA_ELEVATION = 31.0; + private HashMap csgs = new HashMap(); private HashMap views = new HashMap(); @@ -89,7 +98,7 @@ public boolean same(CSG a, CSG b) { if (!bounds.getMax().epsilonEquals(bounds2.getMax(), 0.01)) { return false; } - if (a.isHole() != b.isHide()) + if (a.isHole() != b.isHole()) return false; if (a.isWireFrame() != b.isWireFrame()) return false; @@ -104,6 +113,79 @@ public boolean same(CSG a, CSG b) { return true; } + private static Affine thumbnailView(javafx.geometry.Bounds bounds, double distance) { + double azimuth = Math.toRadians(CAMERA_AZIMUTH); + double elevation = Math.toRadians(CAMERA_ELEVATION); + + Point3D cameraDirection = new Point3D(Math.cos(elevation) * Math.cos(azimuth), + Math.cos(elevation) * Math.sin(azimuth), Math.sin(elevation)); + Point3D forward = cameraDirection.multiply(-1); + Point3D right = forward.crossProduct(new Point3D(0, 0, 1)).normalize(); + Point3D up = right.crossProduct(forward).normalize(); + Point3D center = new Point3D((bounds.getMinX() + bounds.getMaxX()) / 2, + (bounds.getMinY() + bounds.getMaxY()) / 2, (bounds.getMinZ() + bounds.getMaxZ()) / 2); + + Affine view = new Affine(); + view.setMxx(right.getX()); + view.setMxy(right.getY()); + view.setMxz(right.getZ()); + view.setTx(-right.dotProduct(center)); + + view.setMyx(-up.getX()); + view.setMyy(-up.getY()); + view.setMyz(-up.getZ()); + view.setTy(up.dotProduct(center)); + + view.setMzx(forward.getX()); + view.setMzy(forward.getY()); + view.setMzz(forward.getZ()); + view.setTz(distance - forward.dotProduct(center)); + return view; + } + + private WritableImage fitThumbnail(WritableImage source) { + PixelReader reader = source.getPixelReader(); + int minX = imageSize; + int minY = imageSize; + int maxX = -1; + int maxY = -1; + + for (int y = 0; y < imageSize; y++) { + for (int x = 0; x < imageSize; x++) { + int alpha = (reader.getArgb(x, y) >>> 24) & 0xff; + if (alpha <= 2) + continue; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + if (maxX < minX || maxY < minY) + return source; + + double sourceWidth = maxX - minX + 1; + double sourceHeight = maxY - minY + 1; + double available = imageSize - THUMBNAIL_MARGIN * 2.0; + double scale = Math.min(available / sourceWidth, available / sourceHeight); + double width = sourceWidth * scale; + double height = sourceHeight * scale; + + Canvas canvas = new Canvas(imageSize, imageSize); + GraphicsContext graphics = canvas.getGraphicsContext2D(); + graphics.drawImage(source, minX, minY, sourceWidth, sourceHeight, (imageSize - width) / 2, + (imageSize - height) / 2, width, height); + + SnapshotParameters params = new SnapshotParameters(); + params.setFill(Color.TRANSPARENT); + + WritableImage result = new WritableImage(imageSize, imageSize); + canvas.snapshot(params, result); + return result; + } + public WritableImage get(CSGDatabaseInstance instance, List incomingToDisplay, File image) throws NoImageException, IOException { if (image.exists()) { @@ -166,11 +248,8 @@ public WritableImage get(CSGDatabaseInstance instance, List incomingToDispl // Add all meshes to the group - double yOffset = (b.getMax().y - b.getMin().y) / 2; - double xOffset = (b.getMax().x - b.getMin().x) / 2; double zCenter = (b.getMax().z - b.getMin().z) / 2; - // Create a group to hold all the meshes - Group root = new Group(); + Group content = new Group(); for (CSG csg : csgList) { if (csg.isHide()) @@ -179,88 +258,102 @@ public WritableImage get(CSGDatabaseInstance instance, List incomingToDispl continue; try { if (!views.containsKey(csg.getName())) { - PhongMaterial material = new PhongMaterial(); MeshView newMesh = csg.movez(-zCenter).newMesh(); + if (csg.isHole()) { - material.setDiffuseColor(new Color(0.25, 0.25, 0.25, 0.75)); + PhongMaterial material = new PhongMaterial(new Color(0.25, 0.25, 0.25, 0.75)); + material.setSpecularColor(Color.BLACK); newMesh.setMaterial(material); newMesh.setOpacity(0.25); + } else if (newMesh.getMaterial() instanceof PhongMaterial) { + PhongMaterial material = (PhongMaterial) newMesh.getMaterial(); + material.setSpecularColor(Color.BLACK); } + if (csg.isWireFrame()) newMesh.setDrawMode(DrawMode.LINE); else newMesh.setDrawMode(DrawMode.FILL); - material.setSpecularColor(material.getDiffuseColor()); + newMesh.setCullFace(CullFace.BACK); views.put(csg.getName(), newMesh); csgs.put(csg.getName(), csg); Log.debug("Adding to thumbnail " + csg.getName()); } - root.getChildren().add(views.get(csg.getName())); + content.getChildren().add(views.get(csg.getName())); } catch (Throwable t) { com.neuronrobotics.sdk.common.Log.error(t); } } - // Calculate the bounds of all CSGs combined - double totalz = b.getMax().z - b.getMin().z; - double totaly = b.getMax().y - b.getMin().y; - double totalx = b.getMax().x - b.getMin().x; - - // Create a perspective camera PerspectiveCamera camera = new PerspectiveCamera(true); - // Calculate camera position to fit all objects in view - double maxDimension = Math.max(totalx, Math.max(totaly, totalz)); - double cameraDistance = (maxDimension / Math.tan(Math.toRadians(camera.getFieldOfView() / 2))) * 0.8; - - TransformNR camoffset = new TransformNR(xOffset, yOffset, 0); - TransformNR camDist = new TransformNR(0, 0, -cameraDistance); - TransformNR rot = new TransformNR(new RotationNR(-150, 45, 0)); - - TransformNR times = camoffset.times(rot.times(camDist)); - CountDownLatch latch = new CountDownLatch(1); AtomicReference imageRef = new AtomicReference<>(); BowlerKernel.runLater(() -> { - Affine af = TransformFactory.nrToAffine(times); try { - camera.getTransforms().add(af); - Scene scene = new Scene(root, imageSize, imageSize, true, SceneAntialiasing.BALANCED); + javafx.geometry.Bounds rendered = content.getBoundsInLocal(); + + double width = rendered.getWidth(); + double height = rendered.getHeight(); + double depth = rendered.getDepth(); + double radius = Math.sqrt(width * width + height * height + depth * depth) / 2; + + double halfFov = Math.toRadians(camera.getFieldOfView() / 2); + double cameraDistance = Math.max(1.0, radius / Math.sin(halfFov) * 1.2); + + content.getTransforms().add(thumbnailView(rendered, cameraDistance)); + + AmbientLight ambient = new AmbientLight(Color.color(0.38, 0.38, 0.38)); + + PointLight key = new PointLight(Color.color(0.56, 0.56, 0.56)); + key.setConstantAttenuation(1); + key.setLinearAttenuation(0); + key.setQuadraticAttenuation(0); + key.setTranslateX(-radius * 1.4); + key.setTranslateY(-radius * 1.8); + key.setTranslateZ(cameraDistance - radius * 1.8); + + PointLight fill = new PointLight(Color.color(0.10, 0.10, 0.10)); + fill.setConstantAttenuation(1); + fill.setLinearAttenuation(0); + fill.setQuadraticAttenuation(0); + fill.setTranslateX(radius * 1.4); + fill.setTranslateY(radius * 0.6); + fill.setTranslateZ(cameraDistance - radius); + + Group sceneRoot = new Group(content, ambient, key, fill); + Scene scene = new Scene(sceneRoot, imageSize, imageSize, true, SceneAntialiasing.BALANCED); scene.setFill(Color.TRANSPARENT); scene.setCamera(camera); - // Set up snapshot parameters - SnapshotParameters params = new SnapshotParameters(); - params.setFill(Color.TRANSPARENT); - params.setCamera(camera); - params.setDepthBuffer(true); - params.setTransform(Transform.scale(1, 1)); - // Set the near and far clip - camera.setNearClip(0.1); // Set the near clip plane - camera.setFarClip(9000.0); // Set the far clip plane + camera.setNearClip(0.1); + camera.setFarClip(Math.max(9000.0, cameraDistance + radius * 2)); + WritableImage snapshot = new WritableImage(imageSize, imageSize); - imageRef.set(snapshot); - root.snapshot(params, snapshot); - root.getChildren().clear(); + scene.snapshot(snapshot); + + imageRef.set(fitThumbnail(snapshot)); } catch (Throwable t) { Log.error(t); } finally { - latch.countDown(); // Signal completion + // Cached MeshViews must be detached before they can be reused. + content.getChildren().clear(); + latch.countDown(); } }); - boolean completed = false; try { - completed = latch.await(2, TimeUnit.SECONDS); + if (!latch.await(2, TimeUnit.SECONDS)) + throw new NoImageException("JavaFX thread did not complete within 2 seconds"); } catch (InterruptedException e) { - - } - - if (!completed) { - throw new NoImageException("JavaFX thread did not complete within 2 seconds"); + Thread.currentThread().interrupt(); + throw new NoImageException("Interrupted while waiting for JavaFX thumbnail rendering"); } - return imageRef.get(); + WritableImage result = imageRef.get(); + if (result == null) + throw new NoImageException("JavaFX thumbnail rendering failed"); + return result; } } diff --git a/src/main/java/com/neuronrobotics/bowlerstudio/scripting/cadoodle/Sweep.java b/src/main/java/com/neuronrobotics/bowlerstudio/scripting/cadoodle/Sweep.java index 971a9736..a931c5c7 100644 --- a/src/main/java/com/neuronrobotics/bowlerstudio/scripting/cadoodle/Sweep.java +++ b/src/main/java/com/neuronrobotics/bowlerstudio/scripting/cadoodle/Sweep.java @@ -15,7 +15,6 @@ import eu.mihosoft.vrl.v3d.CSG; import eu.mihosoft.vrl.v3d.ColinearPointsException; import eu.mihosoft.vrl.v3d.Cube; -import eu.mihosoft.vrl.v3d.Cylinder; import eu.mihosoft.vrl.v3d.Extrude; import eu.mihosoft.vrl.v3d.Polygon; import eu.mihosoft.vrl.v3d.Transform; @@ -181,11 +180,6 @@ public List process(List incoming) { collect.add(processedCSG); } } - // Add a small pin in the center for alignment - String orderedName = getOrderedName(); - CSG processedCSG = processGiven(null, b, j++, orderedName); - collect.add(processedCSG); - back.addAll(collect); } catch (Exception e) { com.neuronrobotics.sdk.common.Log.error(e); @@ -262,18 +256,11 @@ public File getFile() throws NoSuchFileException { } private CSG processGiven(Polygon p, Bounds b, int j, String name) { - CSG csg = null; - Color c = Color.YELLOW; - boolean hole = false; - if (p != null) { - c = p.getColor(); - if (c == null) - c = Color.ROSYBROWN; - hole = p.isHole(); - csg = sweep(p, name, b); - } else { - csg = new Cylinder(1, 20).toCSG(); - } + Color c = p.getColor(); + if (c == null) + c = Color.ROSYBROWN; + boolean hole = p.isHole(); + CSG csg = sweep(p, name, b); Transform nrToCSG = TransformFactory.nrToCSG(getLocation()); String pathname; try { From f3b0503ffbb82525c096d629f72ee89bff06d729 Mon Sep 17 00:00:00 2001 From: Tomasz Cierpisz Date: Fri, 9 Oct 2026 09:49:09 +0200 Subject: [PATCH 2/3] Add feature edges to thumbnails https://github.com/CommonWealthRobotics/CaDoodle-Application/issues/313 --- .../bowlerstudio/creature/ThumbnailImage.java | 136 ++++- .../threed/FeatureEdgeExtractor.java | 529 ++++++++++++++++++ 2 files changed, 648 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/neuronrobotics/bowlerstudio/threed/FeatureEdgeExtractor.java diff --git a/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java b/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java index 15701b03..d3643a43 100644 --- a/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java +++ b/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java @@ -14,6 +14,7 @@ import com.neuronrobotics.bowlerstudio.BowlerKernel; import com.neuronrobotics.bowlerstudio.physics.TransformFactory; +import com.neuronrobotics.bowlerstudio.threed.FeatureEdgeExtractor; import com.neuronrobotics.sdk.addons.kinematics.math.TransformNR; import com.neuronrobotics.sdk.common.Log; @@ -39,15 +40,20 @@ import javafx.scene.shape.CullFace; import javafx.scene.shape.DrawMode; import javafx.scene.shape.MeshView; +import javafx.scene.shape.TriangleMesh; import javafx.scene.transform.Affine; import javafx.scene.PerspectiveCamera; import javafx.scene.PointLight; public class ThumbnailImage implements ImagePorviderInterface { - public static final String CACHE_VERSION = "11"; + public static final String CACHE_VERSION = "13"; private static final int THUMBNAIL_MARGIN = 24; private static final double CAMERA_AZIMUTH = -40.0; private static final double CAMERA_ELEVATION = 31.0; + private static final double FEATURE_EDGE_ANGLE_DEGREES = 35.0; + private static final double FEATURE_EDGE_TOPOLOGY_TOLERANCE = 0.00001; + private static final double FEATURE_EDGE_STITCH_TOLERANCE = 0.002; + private static final double FEATURE_EDGE_WIDTH_PX = 2.0; private HashMap csgs = new HashMap(); private HashMap views = new HashMap(); @@ -143,6 +149,102 @@ private static Affine thumbnailView(javafx.geometry.Bounds bounds, double distan return view; } + private static void addFeatureEdgeMeshPoint(List points, Point3D point) { + points.add((float) point.getX()); + points.add((float) point.getY()); + points.add((float) point.getZ()); + } + + private static void addFeatureEdgeMeshFace(List faces, int a, int b, int c) { + faces.add(a); + faces.add(0); + faces.add(b); + faces.add(0); + faces.add(c); + faces.add(0); + } + + private static void addFeatureEdgePrism(List points, List faces, FeatureEdgeExtractor.Edge edge, + double radius, double zOffset) { + Point3D a = new Point3D(edge.a.x, edge.a.y, edge.a.z + zOffset); + Point3D b = new Point3D(edge.b.x, edge.b.y, edge.b.z + zOffset); + Point3D direction = b.subtract(a); + + if (direction.magnitude() < 1e-9) + return; + + direction = direction.normalize(); + + Point3D reference = Math.abs(direction.getY()) < 0.9 ? new Point3D(0, 1, 0) : new Point3D(1, 0, 0); + + Point3D u = direction.crossProduct(reference).normalize().multiply(radius); + Point3D v = direction.crossProduct(u).normalize().multiply(radius); + + Point3D[] ringA = {a.add(u).add(v), a.add(u).subtract(v), a.subtract(u).subtract(v), a.subtract(u).add(v)}; + + Point3D[] ringB = {b.add(u).add(v), b.add(u).subtract(v), b.subtract(u).subtract(v), b.subtract(u).add(v)}; + + int base = points.size() / 3; + + for (Point3D point : ringA) + addFeatureEdgeMeshPoint(points, point); + + for (Point3D point : ringB) + addFeatureEdgeMeshPoint(points, point); + + for (int i = 0; i < 4; i++) { + int j = (i + 1) % 4; + + addFeatureEdgeMeshFace(faces, base + i, base + j, base + 4 + j); + addFeatureEdgeMeshFace(faces, base + i, base + 4 + j, base + 4 + i); + } + + addFeatureEdgeMeshFace(faces, base, base + 2, base + 1); + addFeatureEdgeMeshFace(faces, base, base + 3, base + 2); + + addFeatureEdgeMeshFace(faces, base + 4, base + 5, base + 6); + addFeatureEdgeMeshFace(faces, base + 4, base + 6, base + 7); + } + + private static MeshView buildFeatureEdgeMesh(List edges, double radius, double zOffset) { + if (edges.isEmpty()) + return null; + + List points = new ArrayList<>(); + List faces = new ArrayList<>(); + + for (FeatureEdgeExtractor.Edge edge : edges) + addFeatureEdgePrism(points, faces, edge, radius, zOffset); + + if (points.isEmpty()) + return null; + + TriangleMesh mesh = new TriangleMesh(); + + float[] pointArray = new float[points.size()]; + for (int i = 0; i < points.size(); i++) + pointArray[i] = points.get(i); + + int[] faceArray = new int[faces.size()]; + for (int i = 0; i < faces.size(); i++) + faceArray[i] = faces.get(i); + + mesh.getPoints().setAll(pointArray); + mesh.getTexCoords().setAll(0f, 0f); + mesh.getFaces().setAll(faceArray); + + PhongMaterial material = new PhongMaterial(Color.BLACK); + material.setSpecularColor(Color.BLACK); + + MeshView view = new MeshView(mesh); + view.setMaterial(material); + view.setCullFace(CullFace.NONE); + view.setMouseTransparent(true); + view.setDepthTest(javafx.scene.DepthTest.ENABLE); + + return view; + } + private WritableImage fitThumbnail(WritableImage source) { PixelReader reader = source.getPixelReader(); int minX = imageSize; @@ -250,6 +352,7 @@ public WritableImage get(CSGDatabaseInstance instance, List incomingToDispl double zCenter = (b.getMax().z - b.getMin().z) / 2; Group content = new Group(); + List featureEdges = new ArrayList<>(); for (CSG csg : csgList) { if (csg.isHide()) @@ -282,6 +385,10 @@ public WritableImage get(CSGDatabaseInstance instance, List incomingToDispl } content.getChildren().add(views.get(csg.getName())); + if (!csg.isWireFrame()) + featureEdges.addAll(FeatureEdgeExtractor.extract(csg, FEATURE_EDGE_ANGLE_DEGREES, + FEATURE_EDGE_TOPOLOGY_TOLERANCE, FEATURE_EDGE_STITCH_TOLERANCE)); + } catch (Throwable t) { com.neuronrobotics.sdk.common.Log.error(t); } @@ -302,28 +409,23 @@ public WritableImage get(CSGDatabaseInstance instance, List incomingToDispl double halfFov = Math.toRadians(camera.getFieldOfView() / 2); double cameraDistance = Math.max(1.0, radius / Math.sin(halfFov) * 1.2); + double worldPerPixel = 2.0 * cameraDistance * Math.tan(halfFov) / imageSize; - content.getTransforms().add(thumbnailView(rendered, cameraDistance)); + MeshView edgeMesh = buildFeatureEdgeMesh(featureEdges, worldPerPixel * FEATURE_EDGE_WIDTH_PX * 0.5, + -zCenter); + + if (edgeMesh != null) + content.getChildren().add(edgeMesh); - AmbientLight ambient = new AmbientLight(Color.color(0.38, 0.38, 0.38)); + content.getTransforms().add(thumbnailView(rendered, cameraDistance)); - PointLight key = new PointLight(Color.color(0.56, 0.56, 0.56)); + AmbientLight ambient = new AmbientLight(Color.color(0.52, 0.52, 0.52)); + PointLight key = new PointLight(Color.color(0.64, 0.64, 0.64)); key.setConstantAttenuation(1); key.setLinearAttenuation(0); key.setQuadraticAttenuation(0); - key.setTranslateX(-radius * 1.4); - key.setTranslateY(-radius * 1.8); - key.setTranslateZ(cameraDistance - radius * 1.8); - - PointLight fill = new PointLight(Color.color(0.10, 0.10, 0.10)); - fill.setConstantAttenuation(1); - fill.setLinearAttenuation(0); - fill.setQuadraticAttenuation(0); - fill.setTranslateX(radius * 1.4); - fill.setTranslateY(radius * 0.6); - fill.setTranslateZ(cameraDistance - radius); - - Group sceneRoot = new Group(content, ambient, key, fill); + + Group sceneRoot = new Group(content, ambient, key); Scene scene = new Scene(sceneRoot, imageSize, imageSize, true, SceneAntialiasing.BALANCED); scene.setFill(Color.TRANSPARENT); scene.setCamera(camera); diff --git a/src/main/java/com/neuronrobotics/bowlerstudio/threed/FeatureEdgeExtractor.java b/src/main/java/com/neuronrobotics/bowlerstudio/threed/FeatureEdgeExtractor.java new file mode 100644 index 00000000..4c84abb2 --- /dev/null +++ b/src/main/java/com/neuronrobotics/bowlerstudio/threed/FeatureEdgeExtractor.java @@ -0,0 +1,529 @@ +package com.neuronrobotics.bowlerstudio.threed; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import eu.mihosoft.vrl.v3d.CSG; +import eu.mihosoft.vrl.v3d.Vector3d; + +public final class FeatureEdgeExtractor { + + public static final class Edge { + public final Vector3d a; + public final Vector3d b; + + private Edge(Vector3d a, Vector3d b) { + this.a = a; + this.b = b; + } + } + + private static final class CandidateEdge { + private final Vector3d a; + private final Vector3d b; + private final List normals = new ArrayList<>(); + + private CandidateEdge(Vector3d a, Vector3d b) { + this.a = a; + this.b = b; + } + } + + private static final class VertexKey { + private final long x; + private final long y; + private final long z; + + private VertexKey(long x, long y, long z) { + this.x = x; + this.y = y; + this.z = z; + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + + if (!(object instanceof VertexKey)) + return false; + + VertexKey other = (VertexKey) object; + + return x == other.x && y == other.y && z == other.z; + } + + @Override + public int hashCode() { + int result = Long.hashCode(x); + result = 31 * result + Long.hashCode(y); + result = 31 * result + Long.hashCode(z); + return result; + } + } + + private static final class BoundaryEdge { + private final Vector3d a; + private final Vector3d b; + private final Vector3d normal; + + private BoundaryEdge(Vector3d a, Vector3d b, Vector3d normal) { + this.a = a; + this.b = b; + this.normal = normal; + } + } + + private static final class SplitPoint { + private final double t; + private final Vector3d point; + + private SplitPoint(double t, Vector3d point) { + this.t = t; + this.point = point; + } + } + + private static final class StitchedSegment { + private final Vector3d a; + private final Vector3d b; + private final List normals = new ArrayList<>(); + + private StitchedSegment(Vector3d a, Vector3d b) { + this.a = a; + this.b = b; + } + } + + private FeatureEdgeExtractor() { + } + + public static List extract(CSG csg, double angleDegrees, double topologyRepairTolerance, + double stitchTolerance) { + + List visibleEdges = new ArrayList<>(); + + long[] triangles = csg.getTriangles(); + int triangleCount = (int) csg.getTriCount(); + + if (triangles == null || triangleCount == 0) + return visibleEdges; + + double repairTolerance = Math.max(0.0, topologyRepairTolerance); + + int vertexCount = (int) csg.getVertCount(); + int[] vertexRemap = null; + + double[] topologyX = null; + double[] topologyY = null; + double[] topologyZ = null; + + double[] featureX = null; + double[] featureY = null; + double[] featureZ = null; + + if (repairTolerance > 0.0 && vertexCount > 0) { + vertexRemap = new int[vertexCount]; + + topologyX = new double[vertexCount]; + topologyY = new double[vertexCount]; + topologyZ = new double[vertexCount]; + + featureX = new double[vertexCount]; + featureY = new double[vertexCount]; + featureZ = new double[vertexCount]; + + Map canonicalVertices = new HashMap<>(); + + for (int vertex = 0; vertex < vertexCount; vertex++) { + double x = csg.getVertex_X(vertex); + double y = csg.getVertex_Y(vertex); + double z = csg.getVertex_Z(vertex); + + long qx = Math.round(x / repairTolerance); + long qy = Math.round(y / repairTolerance); + long qz = Math.round(z / repairTolerance); + + VertexKey key = new VertexKey(qx, qy, qz); + Integer repairedIndex = canonicalVertices.get(key); + + if (repairedIndex == null) { + repairedIndex = canonicalVertices.size(); + canonicalVertices.put(key, repairedIndex); + + topologyX[repairedIndex] = qx * repairTolerance; + topologyY[repairedIndex] = qy * repairTolerance; + topologyZ[repairedIndex] = qz * repairTolerance; + + featureX[repairedIndex] = x; + featureY[repairedIndex] = y; + featureZ[repairedIndex] = z; + } + + vertexRemap[vertex] = repairedIndex; + } + } + + Map edgeMap = new LinkedHashMap<>(); + + for (int triangle = 0; triangle < triangleCount; triangle++) { + int sourceI0 = (int) triangles[triangle * 3]; + int sourceI1 = (int) triangles[triangle * 3 + 1]; + int sourceI2 = (int) triangles[triangle * 3 + 2]; + + int i0 = vertexRemap == null ? sourceI0 : vertexRemap[sourceI0]; + int i1 = vertexRemap == null ? sourceI1 : vertexRemap[sourceI1]; + int i2 = vertexRemap == null ? sourceI2 : vertexRemap[sourceI2]; + + if (i0 == i1 || i1 == i2 || i2 == i0) + continue; + + if (vertexRemap != null) { + double ux = topologyX[i1] - topologyX[i0]; + double uy = topologyY[i1] - topologyY[i0]; + double uz = topologyZ[i1] - topologyZ[i0]; + + double vx = topologyX[i2] - topologyX[i0]; + double vy = topologyY[i2] - topologyY[i0]; + double vz = topologyZ[i2] - topologyZ[i0]; + + double nx = uy * vz - uz * vy; + double ny = uz * vx - ux * vz; + double nz = ux * vy - uy * vx; + + if (Math.sqrt(nx * nx + ny * ny + nz * nz) < 1e-12) + continue; + } + + double ax = csg.getVertex_X(sourceI0); + double ay = csg.getVertex_Y(sourceI0); + double az = csg.getVertex_Z(sourceI0); + + double bx = csg.getVertex_X(sourceI1); + double by = csg.getVertex_Y(sourceI1); + double bz = csg.getVertex_Z(sourceI1); + + double cx = csg.getVertex_X(sourceI2); + double cy = csg.getVertex_Y(sourceI2); + double cz = csg.getVertex_Z(sourceI2); + + double ux = bx - ax; + double uy = by - ay; + double uz = bz - az; + + double vx = cx - ax; + double vy = cy - ay; + double vz = cz - az; + + double nx = uy * vz - uz * vy; + double ny = uz * vx - ux * vz; + double nz = ux * vy - uy * vx; + + double length = Math.sqrt(nx * nx + ny * ny + nz * nz); + + if (length < 1e-12) + continue; + + Vector3d normal = new Vector3d(nx / length, ny / length, nz / length); + + int[] indices = {i0, i1, i2}; + + for (int edgeIndex = 0; edgeIndex < 3; edgeIndex++) { + int ia = indices[edgeIndex]; + int ib = indices[(edgeIndex + 1) % 3]; + + int low = Math.min(ia, ib); + int high = Math.max(ia, ib); + + long edgeKey = ((long) low << 32) | (high & 0xffffffffL); + + CandidateEdge edge = edgeMap.get(edgeKey); + + if (edge == null) { + double lowX = vertexRemap == null ? csg.getVertex_X(low) : featureX[low]; + double lowY = vertexRemap == null ? csg.getVertex_Y(low) : featureY[low]; + double lowZ = vertexRemap == null ? csg.getVertex_Z(low) : featureZ[low]; + + double highX = vertexRemap == null ? csg.getVertex_X(high) : featureX[high]; + double highY = vertexRemap == null ? csg.getVertex_Y(high) : featureY[high]; + double highZ = vertexRemap == null ? csg.getVertex_Z(high) : featureZ[high]; + + edge = new CandidateEdge(new Vector3d(lowX, lowY, lowZ), new Vector3d(highX, highY, highZ)); + + edgeMap.put(edgeKey, edge); + } + + edge.normals.add(normal); + } + } + + double clampedAngle = Math.max(0.0, Math.min(180.0, angleDegrees)); + + double cosThreshold = Math.cos(Math.toRadians(clampedAngle)); + + double tolerance = Math.max(1e-9, stitchTolerance); + + List boundaries = new ArrayList<>(); + + for (CandidateEdge edge : edgeMap.values()) { + if (edge.normals.size() == 1) { + boundaries.add(new BoundaryEdge(edge.a, edge.b, edge.normals.get(0))); + continue; + } + + if (isFeature(edge.normals, cosThreshold)) + visibleEdges.add(new Edge(edge.a, edge.b)); + } + + visibleEdges.addAll(stitchBoundaries(boundaries, tolerance, cosThreshold)); + + return visibleEdges; + } + + private static boolean isFeature(List normals, double cosThreshold) { + + for (int i = 0; i < normals.size(); i++) { + for (int j = i + 1; j < normals.size(); j++) { + double dot = normals.get(i).dot(normals.get(j)); + + double planeDot = Math.max(-1.0, Math.min(1.0, dot)); + + if (planeDot < cosThreshold) + return true; + } + } + + return false; + } + + private static List stitchBoundaries(List boundaries, double tolerance, double cosThreshold) { + + List result = new ArrayList<>(); + + if (boundaries.isEmpty()) + return result; + + Map endpointMap = new LinkedHashMap<>(); + + for (BoundaryEdge edge : boundaries) { + endpointMap.putIfAbsent(pointKey(edge.a, tolerance), edge.a); + + endpointMap.putIfAbsent(pointKey(edge.b, tolerance), edge.b); + } + + List endpoints = new ArrayList<>(endpointMap.values()); + + List endpointsByX = new ArrayList<>(endpoints); + + List endpointsByY = new ArrayList<>(endpoints); + + List endpointsByZ = new ArrayList<>(endpoints); + + endpointsByX.sort(Comparator.comparingDouble(point -> point.x)); + + endpointsByY.sort(Comparator.comparingDouble(point -> point.y)); + + endpointsByZ.sort(Comparator.comparingDouble(point -> point.z)); + + Map segments = new LinkedHashMap<>(); + + for (BoundaryEdge edge : boundaries) { + List splits = new ArrayList<>(); + + splits.add(new SplitPoint(0.0, edge.a)); + splits.add(new SplitPoint(1.0, edge.b)); + + double minX = Math.min(edge.a.x, edge.b.x) - tolerance; + double maxX = Math.max(edge.a.x, edge.b.x) + tolerance; + + double minY = Math.min(edge.a.y, edge.b.y) - tolerance; + double maxY = Math.max(edge.a.y, edge.b.y) + tolerance; + + double minZ = Math.min(edge.a.z, edge.b.z) - tolerance; + double maxZ = Math.max(edge.a.z, edge.b.z) + tolerance; + + int candidateStart = lowerBound(endpointsByX, 0, minX); + + int candidateEnd = upperBound(endpointsByX, 0, maxX); + + List candidates = endpointsByX; + + int yStart = lowerBound(endpointsByY, 1, minY); + + int yEnd = upperBound(endpointsByY, 1, maxY); + + if (yEnd - yStart < candidateEnd - candidateStart) { + candidateStart = yStart; + candidateEnd = yEnd; + candidates = endpointsByY; + } + + int zStart = lowerBound(endpointsByZ, 2, minZ); + + int zEnd = upperBound(endpointsByZ, 2, maxZ); + + if (zEnd - zStart < candidateEnd - candidateStart) { + candidateStart = zStart; + candidateEnd = zEnd; + candidates = endpointsByZ; + } + + for (int candidateIndex = candidateStart; candidateIndex < candidateEnd; candidateIndex++) { + + Vector3d candidate = candidates.get(candidateIndex); + + if (candidate.x < minX || candidate.x > maxX || candidate.y < minY || candidate.y > maxY + || candidate.z < minZ || candidate.z > maxZ) + continue; + + double t = segmentParameter(candidate, edge.a, edge.b); + + if (t <= 1e-9 || t >= 1.0 - 1e-9) + continue; + + if (distanceToSegment(candidate, edge.a, edge.b) <= tolerance) + splits.add(new SplitPoint(t, candidate)); + } + + splits.sort(Comparator.comparingDouble(split -> split.t)); + + List unique = new ArrayList<>(); + + for (SplitPoint split : splits) { + if (unique.isEmpty() || distance(split.point, unique.get(unique.size() - 1).point) > tolerance) + unique.add(split); + } + + for (int i = 0; i + 1 < unique.size(); i++) { + Vector3d a = unique.get(i).point; + Vector3d b = unique.get(i + 1).point; + + if (distance(a, b) <= tolerance) + continue; + + String key = segmentKey(a, b, tolerance); + + StitchedSegment segment = segments.get(key); + + if (segment == null) { + segment = new StitchedSegment(a, b); + segments.put(key, segment); + } + + segment.normals.add(edge.normal); + } + } + + for (StitchedSegment segment : segments.values()) { + if (isFeature(segment.normals, cosThreshold)) + result.add(new Edge(segment.a, segment.b)); + } + + return result; + } + + private static long quantize(double value, double tolerance) { + + return Math.round(value / tolerance); + } + + private static String pointKey(Vector3d point, double tolerance) { + + return quantize(point.x, tolerance) + "," + quantize(point.y, tolerance) + "," + quantize(point.z, tolerance); + } + + private static String segmentKey(Vector3d a, Vector3d b, double tolerance) { + + String ka = pointKey(a, tolerance); + String kb = pointKey(b, tolerance); + + return ka.compareTo(kb) <= 0 ? ka + "|" + kb : kb + "|" + ka; + } + + private static double coordinate(Vector3d point, int axis) { + + if (axis == 0) + return point.x; + + if (axis == 1) + return point.y; + + return point.z; + } + + private static int lowerBound(List points, int axis, double value) { + + int low = 0; + int high = points.size(); + + while (low < high) { + int mid = (low + high) >>> 1; + + if (coordinate(points.get(mid), axis) < value) + low = mid + 1; + else + high = mid; + } + + return low; + } + + private static int upperBound(List points, int axis, double value) { + + int low = 0; + int high = points.size(); + + while (low < high) { + int mid = (low + high) >>> 1; + + if (coordinate(points.get(mid), axis) <= value) + low = mid + 1; + else + high = mid; + } + + return low; + } + + private static double segmentParameter(Vector3d point, Vector3d a, Vector3d b) { + + double abX = b.x - a.x; + double abY = b.y - a.y; + double abZ = b.z - a.z; + + double lengthSquared = abX * abX + abY * abY + abZ * abZ; + + if (lengthSquared < 1e-18) + return 0.0; + + return ((point.x - a.x) * abX + (point.y - a.y) * abY + (point.z - a.z) * abZ) / lengthSquared; + } + + private static double distanceToSegment(Vector3d point, Vector3d a, Vector3d b) { + + double t = Math.max(0.0, Math.min(1.0, segmentParameter(point, a, b))); + + double x = a.x + (b.x - a.x) * t; + double y = a.y + (b.y - a.y) * t; + double z = a.z + (b.z - a.z) * t; + + double dx = point.x - x; + double dy = point.y - y; + double dz = point.z - z; + + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + private static double distance(Vector3d a, Vector3d b) { + + double dx = a.x - b.x; + double dy = a.y - b.y; + double dz = a.z - b.z; + + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } +} From 46ae864f9c15a52744c4966f3262848c0ccbee42 Mon Sep 17 00:00:00 2001 From: Tomasz Cierpisz Date: Fri, 9 Oct 2026 18:04:43 +0200 Subject: [PATCH 3/3] Improve thumbnail supersampling https://github.com/CommonWealthRobotics/CaDoodle-Application/issues/313 --- .../bowlerstudio/creature/ThumbnailImage.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java b/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java index d3643a43..264e538d 100644 --- a/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java +++ b/src/main/java/com/neuronrobotics/bowlerstudio/creature/ThumbnailImage.java @@ -46,19 +46,20 @@ import javafx.scene.PointLight; public class ThumbnailImage implements ImagePorviderInterface { - public static final String CACHE_VERSION = "13"; - private static final int THUMBNAIL_MARGIN = 24; + public static final String CACHE_VERSION = "17"; + private static final int RENDER_SCALE = 3; + private static final int THUMBNAIL_MARGIN = 24 * RENDER_SCALE; private static final double CAMERA_AZIMUTH = -40.0; private static final double CAMERA_ELEVATION = 31.0; private static final double FEATURE_EDGE_ANGLE_DEGREES = 35.0; private static final double FEATURE_EDGE_TOPOLOGY_TOLERANCE = 0.00001; private static final double FEATURE_EDGE_STITCH_TOLERANCE = 0.002; - private static final double FEATURE_EDGE_WIDTH_PX = 2.0; + private static final double FEATURE_EDGE_WIDTH_PX = 2.0 * RENDER_SCALE; private HashMap csgs = new HashMap(); private HashMap views = new HashMap(); - private int imageSize = 300; + private int imageSize = 300 * RENDER_SCALE; public Bounds getSellectedBounds(List incomingToDisplay) { Vector3d min = null;